feat(desktop): Linux AppImage releases and desktop feature parity (#2398)

* feat(electron): add Linux AppImage releases

* ci: cache Linux OpenCode CLI artifacts

* fix(ci): await Linux release inventory check

* fix(electron): add frameless window controls on Linux desktop

Linux AppImages were created without native WM decorations and without
in-app controls, leaving users unable to close the window with a mouse.

Treat Linux like Windows: frameless BrowserWindow plus the existing
WindowsWindowControls header buttons and app-menu entry. macOS keeps
hidden title bar with traffic lights unchanged.

Shared usesFramelessElectronChrome() helper drives main window, mini
chat, header insets, and titlebar controls.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Linux desktop feature parity: Open in, background start, tray, multi-window (#2392)

* feat(electron): Linux parity for Open in, background start, and tray

Enable Linux desktop feature parity with macOS/Windows: open projects in
the default file manager and discovered apps, XDG autostart with
--background launches, system tray (including minimize-to-tray), and
tray sync from the renderer.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(electron): allow packaged UI protocol navigations on Linux

Prevent openchamber-ui:// navigations from being handed to
shell.openExternal, which fails on Linux and blocked desktop UI flows.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(ui): surface Linux tray settings in settings search

Include isLinux in settings search runtime context so minimize-to-tray
is discoverable on Linux desktop, matching Windows search behavior.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(electron): stop Linux AppImage Waiting-for-OpenCode flicker

Sync updated boot-outcome init scripts to all BrowserWindows after
desktop_hosts_set, and prefer state.initScript on dom-ready so chooser
reloads inject local/ok instead of a stale not-configured outcome.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(desktop): restore Linux AppImage updater feed and error UX (#2396)

Treat missing latest-linux*.yml (404) as no update available instead of a
hard failure, and stop swallowing updater capability/download errors in the
desktop bridge so About/sidebar can show actionable messages.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* docs: credit Linux AppImage contributors in changelog

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: jibanez-staticduo <staticduo@gmail.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-07-24 09:59:32 +03:00
committed by GitHub
co-authored by Cursor Agent Serhii Dziupin jibanez-staticduo
parent ddbb3c1db0
commit 18b58bdb6b
27 changed files with 962 additions and 57 deletions
+2 -4
View File
@@ -363,7 +363,6 @@ jobs:
build-desktop-electron-linux: build-desktop-electron-linux:
needs: create-release needs: create-release
if: ${{ false }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -461,7 +460,6 @@ jobs:
publish-electron-linux: publish-electron-linux:
needs: [create-release, build-desktop-electron-linux] needs: [create-release, build-desktop-electron-linux]
if: ${{ false }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -550,14 +548,14 @@ jobs:
secrets: inherit secrets: inherit
finalize-release: finalize-release:
needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows, publish-npm, combine-electron-manifests, mobile-release] needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows, build-desktop-electron-linux, publish-electron-linux, publish-npm, combine-electron-manifests, mobile-release]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }} DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }}
steps: steps:
- name: Verify final Linux release asset inventory - name: Verify final Linux release asset inventory
if: ${{ false }} if: ${{ github.event.inputs.dry_run != 'true' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
+3
View File
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- **Desktop/Linux:** official AppImage releases for x64 and arm64, with in-app updates from the GitHub release feed. Missing update manifests are treated as “no update” instead of a hard failure, and updater errors surface in About/sidebar (thanks to @jibanez-staticduo).
- Desktop/Linux: frameless window controls, system tray minimize, launch at login via XDG autostart, multi-window support, and “Open in” for discovered installed apps (thanks to @jibanez-staticduo, @makeittech).
## [1.16.3] - 2026-07-22 ## [1.16.3] - 2026-07-22
- **Chat attachments:** added Office and OpenDocument files (`.docx`, `.pptx`, `.xlsx`, `.odt`, `.odp`, and `.ods`), with readable text and supported embedded images extracted before sending. Attachments also support more source-code formats, notebooks, HAR files with credentials and cookies removed, SVG and Draw.io files, and HEIC/HEIF images; the composer warns when the selected model may ignore an attachment type. - **Chat attachments:** added Office and OpenDocument files (`.docx`, `.pptx`, `.xlsx`, `.odt`, `.odp`, and `.ods`), with readable text and supported embedded images extracted before sending. Attachments also support more source-code formats, notebooks, HAR files with credentials and cookies removed, SVG and Draw.io files, and HEIC/HEIF images; the composer warns when the selected model may ignore an attachment type.
+1 -1
View File
@@ -105,7 +105,7 @@ Linux AppImages need FUSE (`libfuse.so.2`). On Ubuntu/Debian install `libfuse2`
APPIMAGE_EXTRACT_AND_RUN=1 ./OpenChamber-*-linux-*.AppImage APPIMAGE_EXTRACT_AND_RUN=1 ./OpenChamber-*-linux-*.AppImage
``` ```
Linux Desktop ships as AppImage with in-app window controls and auto-update when running from a writable AppImage. System tray and launch-at-login are not available on Linux yet (macOS/Windows only). Linux Desktop ships as AppImage with in-app window controls and auto-update when running from a writable AppImage.
### **VS Code** ### **VS Code**
Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions. Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions.
+1 -1
View File
@@ -88,7 +88,7 @@ Linux updates are supported only when the packaged app is running from a writabl
A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture. A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture.
The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls and auto-update; system tray and launch-at-login remain macOS/Windows only. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps and installed-app discovery work on macOS and Windows (Linux returns an empty list without errors). The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls, auto-update, system tray, and launch-at-login (XDG autostart). Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps and installed-app discovery work on macOS, Windows, and Linux.
The macOS menu bar item is enabled by default and can be disabled in General settings. The setting applies after restart; while disabled, Desktop does not create the native tray controller or start the renderer subscriptions, polling, quota refresh, or IPC updates that feed it. The macOS menu bar item is enabled by default and can be disabled in General settings. The setting applies after restart; while disabled, Desktop does not create the native tray controller or start the renderer subscriptions, polling, quota refresh, or IPC updates that feed it.
+318
View File
@@ -0,0 +1,318 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share'];
const TARGET_FIELD_CODES = new Set(['f', 'F', 'u', 'U']);
const TERMINAL_APP_IDS = new Set(['terminal', 'iterm2', 'ghostty']);
export const LINUX_CLI_BY_APP_ID = {
vscode: 'code',
cursor: 'cursor',
vscodium: 'codium',
windsurf: 'windsurf',
zed: 'zed',
'sublime-text': 'subl',
};
const uniqueStrings = (values) => {
const seen = new Set();
const result = [];
for (const value of values) {
const candidate = typeof value === 'string' ? value.trim() : '';
if (!candidate || seen.has(candidate)) continue;
seen.add(candidate);
result.push(candidate);
}
return result;
};
const desktopBoolean = (value) => String(value || '').trim().toLowerCase() === 'true';
const unescapeDesktopValue = (value) => String(value || '')
.replace(/\\s/g, ' ')
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r')
.replace(/\\\\/g, '\\');
const normalizeComparable = (value) => String(value || '')
.toLowerCase()
.replace(/\.desktop$/i, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
const normalizeCompactComparable = (value) => normalizeComparable(value).replace(/\s+/g, '');
export const stripDesktopExecFieldCodes = (execValue) => String(execValue || '')
.replace(/%%/g, '\^@')
.replace(/%[fFuUdDnNickvm]/g, '')
.replace(/%./g, '')
.replace(/\^@/g, '%')
.replace(/\s+/g, ' ')
.trim();
export const linuxApplicationDirs = ({ env = process.env, homeDir = os.homedir() } = {}) => {
const dataHome = typeof env.XDG_DATA_HOME === 'string' && env.XDG_DATA_HOME.trim()
? env.XDG_DATA_HOME.trim()
: path.join(homeDir || os.homedir(), '.local', 'share');
const dataDirs = typeof env.XDG_DATA_DIRS === 'string' && env.XDG_DATA_DIRS.trim()
? env.XDG_DATA_DIRS.split(':').filter(Boolean)
: DEFAULT_XDG_DATA_DIRS;
return uniqueStrings([
path.join(dataHome, 'applications'),
...dataDirs.map((dir) => path.join(dir, 'applications')),
'/usr/local/share/applications',
'/usr/share/applications',
]).map((entry) => path.resolve(entry));
};
const parseDesktopValues = (content) => {
const values = new Map();
let group = '';
for (const rawLine of String(content || '').split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('[') && line.endsWith(']')) {
group = line.slice(1, -1).trim();
continue;
}
if (group !== 'Desktop Entry') continue;
const separator = line.indexOf('=');
if (separator <= 0) continue;
const key = line.slice(0, separator).trim();
if (!key || key.includes('[') || values.has(key)) continue;
values.set(key, unescapeDesktopValue(line.slice(separator + 1)));
}
return values;
};
export const parseDesktopEntry = (content, filePath = '') => {
const values = parseDesktopValues(content);
if ((values.get('Type') || 'Application') !== 'Application') return null;
if (desktopBoolean(values.get('NoDisplay')) || desktopBoolean(values.get('Hidden'))) return null;
const name = String(values.get('Name') || '').trim();
const rawExec = String(values.get('Exec') || '').trim();
const exec = stripDesktopExecFieldCodes(rawExec);
if (!name || !rawExec || !exec) return null;
return {
id: path.basename(filePath || '').replace(/\.desktop$/i, '') || name,
name,
exec,
rawExec,
icon: String(values.get('Icon') || '').trim() || null,
categories: String(values.get('Categories') || '').split(';').map((entry) => entry.trim()).filter(Boolean),
filePath,
};
};
const collectDesktopFiles = async (dir) => {
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const files = [];
for (const entry of entries) {
const candidate = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await collectDesktopFiles(candidate));
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.desktop')) {
files.push(candidate);
}
}
return files;
};
export const readLinuxDesktopEntries = async (options = {}) => {
const dirs = Array.isArray(options.applicationDirs) ? options.applicationDirs : linuxApplicationDirs(options);
const files = [];
for (const dir of dirs) files.push(...await collectDesktopFiles(dir));
const seen = new Set();
const entries = [];
for (const filePath of files) {
try {
const parsed = parseDesktopEntry(await fsp.readFile(filePath, 'utf8'), filePath);
if (!parsed || seen.has(parsed.id)) continue;
seen.add(parsed.id);
entries.push(parsed);
} catch {
}
}
return entries.sort((left, right) => left.name.localeCompare(right.name));
};
export const discoverLinuxDesktopApps = readLinuxDesktopEntries;
export const desktopEntryMatchesApp = (entry, appName, appId = '') => {
const needles = uniqueStrings([appName, appId]).flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]).filter(Boolean);
const haystacks = [entry.name, entry.id, path.basename(entry.filePath || ''), entry.exec]
.flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]);
return needles.some((needle) => haystacks.some((haystack) => haystack === needle || haystack.includes(needle) || needle.includes(haystack)));
};
const parseExecCommand = (exec) => {
const args = [];
let current = '';
let quote = null;
let escaped = false;
for (const char of String(exec || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (quote) {
if (char === quote) quote = null;
else current += char;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
args.push(current);
current = '';
}
continue;
}
current += char;
}
if (escaped) current += '\\';
if (current) args.push(current);
return args;
};
export const buildCommandFromDesktopExec = (entry, targetPath) => {
const tokens = parseExecCommand(entry?.rawExec || entry?.exec || '');
if (tokens.length === 0) return null;
let targetInserted = false;
const args = [];
for (const token of tokens.slice(1)) {
let rendered = token.replace(/%([a-zA-Z%])/g, (_match, code) => {
if (TARGET_FIELD_CODES.has(code)) {
targetInserted = true;
return targetPath;
}
if (code === 'c') return entry.name || '';
if (code === 'k') return entry.filePath || '';
if (code === '%') return '%';
return '';
});
rendered = rendered.trim();
if (rendered) args.push(rendered);
}
if (!targetInserted) args.push(targetPath);
return { program: tokens[0], args };
};
const commandExists = (program, env = process.env) => {
if (!program) return false;
if (program.includes(path.sep)) {
try {
fs.accessSync(program, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
for (const dir of String(env.PATH || '').split(':').filter(Boolean)) {
try {
fs.accessSync(path.join(dir, program), fs.constants.X_OK);
return true;
} catch {
}
}
return false;
};
const findEntry = (entries, appId, appName) => entries.find((entry) => desktopEntryMatchesApp(entry, appName, appId)) || null;
export const buildLinuxOpenSpecs = ({ targetPath, appId, appName, targetKind = 'path', entries = [], env = process.env }) => {
if (appId === 'finder') {
return [{ kind: 'default', targetKind, targetPath }];
}
const specs = [];
if (TERMINAL_APP_IDS.has(appId)) {
const directory = targetKind === 'file' ? path.dirname(targetPath) : targetPath;
const terminalEntry = findEntry(entries, appId, appName);
if (terminalEntry) {
const spec = buildCommandFromDesktopExec(terminalEntry, directory);
if (spec) specs.push(spec);
}
specs.push({ program: 'xdg-terminal-exec', args: ['--working-directory', directory] });
if (commandExists('gnome-terminal', env)) {
specs.push({ program: 'gnome-terminal', args: [`--working-directory=${directory}`] });
}
if (commandExists('konsole', env)) {
specs.push({ program: 'konsole', args: ['--workdir', directory] });
}
if (commandExists('xfce4-terminal', env)) {
specs.push({ program: 'xfce4-terminal', args: [`--working-directory=${directory}`] });
}
if (commandExists('x-terminal-emulator', env)) {
specs.push({ program: 'x-terminal-emulator', args: [] });
}
return specs;
}
const cli = LINUX_CLI_BY_APP_ID[appId];
if (cli && commandExists(cli, env)) {
specs.push({ program: cli, args: appId === 'zed' ? [targetPath] : ['-n', targetPath] });
}
const entry = findEntry(entries, appId, appName);
if (entry) {
const spec = buildCommandFromDesktopExec(entry, targetPath);
if (spec) specs.push(spec);
}
return specs;
};
export const filterLinuxInstalledApps = async (apps, options = {}) => {
const entries = options.entries || await readLinuxDesktopEntries(options);
const requested = Array.isArray(apps) ? apps : [];
return requested
.map((appName) => String(appName || '').trim())
.filter((appName) => appName && entries.some((entry) => desktopEntryMatchesApp(entry, appName)));
};
export const buildLinuxInstalledApps = async (apps, options = {}) => {
const entries = options.entries || await readLinuxDesktopEntries(options);
const env = options.env || process.env;
const names = uniqueStrings(Array.isArray(apps) ? apps.map(String) : []);
return names
.filter((name) => {
const normalized = normalizeComparable(name);
if (normalized === 'finder' || normalized === 'file manager' || normalized === 'file explorer') {
return true;
}
if (normalized === 'terminal') {
return true;
}
if (entries.some((entry) => desktopEntryMatchesApp(entry, name))) {
return true;
}
const appId = Object.entries(LINUX_CLI_BY_APP_ID).find(([, cli]) => {
return normalizeComparable(name).includes(normalizeComparable(cli)) || normalizeCompactComparable(name).includes(cli);
})?.[0];
// Prefer direct name→id mapping from known Open In apps.
const knownIdByName = new Map([
['visual studio code', 'vscode'],
['cursor', 'cursor'],
['vscodium', 'vscodium'],
['windsurf', 'windsurf'],
['zed', 'zed'],
['sublime text', 'sublime-text'],
]);
const mappedId = knownIdByName.get(normalized) || appId;
const cli = mappedId ? LINUX_CLI_BY_APP_ID[mappedId] : '';
return Boolean(cli && commandExists(cli, env));
})
.map((name) => ({ name, iconDataUrl: null }));
};
export const fetchLinuxAppIcons = async () => [];
+99
View File
@@ -0,0 +1,99 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const AUTOSTART_FILE_NAME = 'openchamber.desktop';
export const resolveLinuxAutostartDirectory = ({
env = process.env,
homeDir = os.homedir(),
} = {}) => {
const configHome = typeof env.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.trim()
? env.XDG_CONFIG_HOME.trim()
: path.join(homeDir || os.homedir(), '.config');
return path.join(configHome, 'autostart');
};
export const resolveLinuxAutostartFilePath = (options = {}) =>
path.join(resolveLinuxAutostartDirectory(options), AUTOSTART_FILE_NAME);
export const resolveLinuxLaunchExecutable = ({
env = process.env,
execPath = process.execPath,
} = {}) => {
const appImage = typeof env.APPIMAGE === 'string' ? env.APPIMAGE.trim() : '';
if (appImage && path.isAbsolute(appImage)) {
return appImage;
}
return execPath;
};
const quoteDesktopExecArg = (value) => {
const text = String(value ?? '');
if (!/[ \t\n"$\\]/.test(text)) {
return text;
}
return `"${text.replace(/(["\\$`])/g, '\\$1')}"`;
};
export const buildLinuxAutostartDesktopEntry = ({
appName = 'OpenChamber',
executable,
backgroundArg,
env = process.env,
execPath = process.execPath,
} = {}) => {
const launchPath = executable || resolveLinuxLaunchExecutable({ env, execPath });
const args = [quoteDesktopExecArg(launchPath)];
if (typeof backgroundArg === 'string' && backgroundArg.trim()) {
args.push(backgroundArg.trim());
}
return [
'[Desktop Entry]',
'Type=Application',
`Name=${appName}`,
`Exec=${args.join(' ')}`,
'Terminal=false',
'X-GNOME-Autostart-enabled=true',
'StartupWMClass=openchamber',
'',
].join('\n');
};
export const readLinuxAutostartEnabled = async (options = {}) => {
const filePath = resolveLinuxAutostartFilePath(options);
try {
await fsp.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
};
export const setLinuxAutostartEnabled = async ({
enabled,
appName = 'OpenChamber',
backgroundArg,
env = process.env,
execPath = process.execPath,
homeDir = os.homedir(),
} = {}) => {
const directory = resolveLinuxAutostartDirectory({ env, homeDir });
const filePath = path.join(directory, AUTOSTART_FILE_NAME);
if (!enabled) {
await fsp.rm(filePath, { force: true });
return { supported: true, enabled: false, filePath };
}
await fsp.mkdir(directory, { recursive: true });
const contents = buildLinuxAutostartDesktopEntry({
appName,
backgroundArg,
env,
execPath,
});
await fsp.writeFile(filePath, contents, 'utf8');
return { supported: true, enabled: true, filePath };
};
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
buildLinuxAutostartDesktopEntry,
readLinuxAutostartEnabled,
resolveLinuxAutostartFilePath,
resolveLinuxLaunchExecutable,
setLinuxAutostartEnabled,
} from './linux-autostart.mjs';
test('prefers APPIMAGE path for Linux autostart Exec', () => {
assert.equal(
resolveLinuxLaunchExecutable({
env: { APPIMAGE: '/home/user/OpenChamber.AppImage' },
execPath: '/tmp/.mount_OpenChXXXX/openchamber',
}),
'/home/user/OpenChamber.AppImage',
);
});
test('builds a background autostart desktop entry', () => {
const entry = buildLinuxAutostartDesktopEntry({
executable: '/home/user/Open Chamber.AppImage',
backgroundArg: '--background',
});
assert.match(entry, /Type=Application/);
assert.match(entry, /Exec="\/home\/user\/Open Chamber\.AppImage" --background/);
assert.match(entry, /X-GNOME-Autostart-enabled=true/);
});
test('writes and removes the XDG autostart file', async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-autostart-'));
const env = { XDG_CONFIG_HOME: path.join(homeDir, 'config') };
const filePath = resolveLinuxAutostartFilePath({ env, homeDir });
try {
assert.equal(await readLinuxAutostartEnabled({ env, homeDir }), false);
const enabled = await setLinuxAutostartEnabled({
enabled: true,
backgroundArg: '--background',
env: { ...env, APPIMAGE: '/opt/OpenChamber.AppImage' },
homeDir,
});
assert.equal(enabled.enabled, true);
assert.equal(enabled.filePath, filePath);
assert.equal(await readLinuxAutostartEnabled({ env, homeDir }), true);
const contents = await fs.readFile(filePath, 'utf8');
assert.match(contents, /Exec=\/opt\/OpenChamber\.AppImage --background/);
const disabled = await setLinuxAutostartEnabled({
enabled: false,
env,
homeDir,
});
assert.equal(disabled.enabled, false);
assert.equal(await readLinuxAutostartEnabled({ env, homeDir }), false);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
+195 -32
View File
@@ -17,6 +17,18 @@ import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs';
import { assertUpdaterCapability } from './updater-capability.mjs'; import { assertUpdaterCapability } from './updater-capability.mjs';
import { checkForDesktopUpdate } from './updater-check.mjs'; import { checkForDesktopUpdate } from './updater-check.mjs';
import { resolveUpdaterFeed } from './updater-feed.mjs'; import { resolveUpdaterFeed } from './updater-feed.mjs';
import {
buildLinuxInstalledApps,
buildLinuxOpenSpecs,
fetchLinuxAppIcons,
filterLinuxInstalledApps,
readLinuxDesktopEntries,
} from './linux-app-discovery.mjs';
import {
readLinuxAutostartEnabled,
setLinuxAutostartEnabled,
} from './linux-autostart.mjs';
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -44,6 +56,9 @@ const getLoginItemOptions = () => {
}; };
const readLoginItemSettings = () => { const readLoginItemSettings = () => {
if (process.platform === 'linux') {
return null;
}
if (process.platform !== 'darwin' && process.platform !== 'win32') return null; if (process.platform !== 'darwin' && process.platform !== 'win32') return null;
try { try {
return app.getLoginItemSettings(getLoginItemOptions()); return app.getLoginItemSettings(getLoginItemOptions());
@@ -182,6 +197,7 @@ const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/i
const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA'; const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA';
const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24; const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24;
const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json'; const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json';
const LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS = 30_000;
const OPENCODE_SHUTDOWN_GRACE_MS = 100; const OPENCODE_SHUTDOWN_GRACE_MS = 100;
const { autoUpdater } = updaterPkg; const { autoUpdater } = updaterPkg;
@@ -244,7 +260,7 @@ const readDesktopKeepAwakeStatus = () => {
}; };
const readDesktopMinimizeToTrayStatus = () => { const readDesktopMinimizeToTrayStatus = () => {
const supported = process.platform === 'win32'; const supported = process.platform === 'win32' || process.platform === 'linux';
return { return {
supported, supported,
enabled: supported && readSettingsRoot().desktopMinimizeToTrayEnabled === true, enabled: supported && readSettingsRoot().desktopMinimizeToTrayEnabled === true,
@@ -252,7 +268,7 @@ const readDesktopMinimizeToTrayStatus = () => {
}; };
const shouldHideMainWindowToTray = (browserWindow) => { const shouldHideMainWindowToTray = (browserWindow) => {
if (process.platform !== 'win32') return false; if (process.platform !== 'win32' && process.platform !== 'linux') return false;
if (!state.trayController) return false; if (!state.trayController) return false;
if (!browserWindow || browserWindow.isDestroyed()) return false; if (!browserWindow || browserWindow.isDestroyed()) return false;
if (browserWindow.__ocMiniChat === true) return false; if (browserWindow.__ocMiniChat === true) return false;
@@ -1540,6 +1556,18 @@ const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken
].join(''); ].join('');
}; };
// Keep per-window init scripts aligned with state. Chooser/onboarding reloads after
// desktop_hosts_set; if only state.initScript is updated, dom-ready reinjects a stale
// not-configured outcome and the UI flickers on "Waiting for OpenCode".
const syncInitScriptToWindows = (initScript = state.initScript) => {
if (!initScript) return;
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.__ocInitScript = initScript;
}
}
};
const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => { const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => {
const availability = { localAvailable }; const availability = { localAvailable };
if (envTargetUrl) { if (envTargetUrl) {
@@ -2400,6 +2428,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
try { try {
const url = new URL(raw); const url = new URL(raw);
if (url.protocol === 'devtools:') return true; if (url.protocol === 'devtools:') return true;
if (url.protocol === `${UI_PROTOCOL}:`) return true;
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (state.localOrigin) { if (state.localOrigin) {
try { try {
@@ -2447,8 +2476,11 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
}); });
browserWindow.webContents.on('dom-ready', () => { browserWindow.webContents.on('dom-ready', () => {
const initScript = browserWindow.__ocInitScript || state.initScript; // Prefer authoritative state script so hosts_set updates survive reloads even if a
// window still holds a pre-activation / not-configured __ocInitScript.
const initScript = state.initScript || browserWindow.__ocInitScript;
if (initScript) { if (initScript) {
browserWindow.__ocInitScript = initScript;
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {}); void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
} }
}); });
@@ -2499,6 +2531,7 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig =
rendererRuntimeConfig.clientToken, rendererRuntimeConfig.clientToken,
rendererRuntimeConfig.requestHeaders, rendererRuntimeConfig.requestHeaders,
); );
syncInitScriptToWindows(state.initScript);
const mainWindow = state.mainWindow; const mainWindow = state.mainWindow;
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
@@ -2722,8 +2755,9 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
void shell.openExternal(url).catch(() => {}); void shell.openExternal(url).catch(() => {});
}); });
browserWindow.webContents.on('dom-ready', () => { browserWindow.webContents.on('dom-ready', () => {
const initScript = browserWindow.__ocInitScript || state.initScript; const initScript = state.initScript || browserWindow.__ocInitScript;
if (initScript) { if (initScript) {
browserWindow.__ocInitScript = initScript;
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {}); void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
} }
}); });
@@ -3007,6 +3041,74 @@ const buildInstalledApps = async (apps) => {
return results; return results;
}; };
let linuxDesktopEntriesCache = { expiresAt: 0, entries: null };
const getLinuxDesktopEntries = async () => {
const now = Date.now();
if (linuxDesktopEntriesCache.entries && linuxDesktopEntriesCache.expiresAt > now) {
return linuxDesktopEntriesCache.entries;
}
const entries = await readLinuxDesktopEntries();
linuxDesktopEntriesCache = { entries, expiresAt: now + LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS };
return entries;
};
const buildPlatformInstalledApps = async (apps) => {
if (process.platform === 'linux') {
return buildLinuxInstalledApps(apps);
}
if (process.platform === 'win32') {
return buildWindowsInstalledApps(apps);
}
return buildInstalledApps(apps);
};
const spawnDetachedLinux = (program, args) => new Promise((resolve, reject) => {
const child = spawn(program, args, {
detached: true,
stdio: 'ignore',
});
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
callback(value);
};
child.once('error', (error) => finish(reject, error));
child.once('spawn', () => {
child.unref();
finish(resolve);
});
});
const runLinuxSpecChain = async (specs, appName) => {
if (!Array.isArray(specs) || specs.length === 0) {
throw new Error(`Failed to open in ${appName}: no launch candidates`);
}
const failures = [];
for (const spec of specs) {
if (spec.kind === 'default') {
if (spec.targetKind === 'file') {
shell.showItemInFolder(spec.targetPath);
return;
}
const errorMessage = await shell.openPath(spec.targetPath);
if (!errorMessage) return;
failures.push(`default opener: ${errorMessage}`);
continue;
}
try {
await spawnDetachedLinux(spec.program, spec.args);
return;
} catch (error) {
failures.push(`${spec.program}: ${error instanceof Error ? error.message : String(error)}`);
}
}
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
};
const parseSshConfigImports = () => { const parseSshConfigImports = () => {
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config'); const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
if (!fs.existsSync(sshConfigPath)) return []; if (!fs.existsSync(sshConfigPath)) return [];
@@ -3488,12 +3590,23 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
return APP_VERSION; return APP_VERSION;
case 'desktop_get_launch_at_login': { case 'desktop_get_launch_at_login': {
if (process.platform === 'linux') {
return { supported: true, enabled: await readLinuxAutostartEnabled() };
}
if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false }; if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false };
const settings = app.getLoginItemSettings(getLoginItemOptions()); const settings = app.getLoginItemSettings(getLoginItemOptions());
return { supported: true, enabled: settings.openAtLogin === true }; return { supported: true, enabled: settings.openAtLogin === true };
} }
case 'desktop_set_launch_at_login': { case 'desktop_set_launch_at_login': {
if (process.platform === 'linux') {
const enabled = args.enabled === true;
return setLinuxAutostartEnabled({
enabled,
appName: app.getName(),
backgroundArg: BACKGROUND_START_ARG,
});
}
if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false }; if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false };
const enabled = args.enabled === true; const enabled = args.enabled === true;
const settingsArgs = { const settingsArgs = {
@@ -3512,7 +3625,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
case 'desktop_set_minimize_to_tray': { case 'desktop_set_minimize_to_tray': {
if (process.platform !== 'win32') return { supported: false, enabled: false }; if (process.platform !== 'win32' && process.platform !== 'linux') return { supported: false, enabled: false };
const enabled = args.enabled === true; const enabled = args.enabled === true;
await mutateSettingsRoot((root) => { await mutateSettingsRoot((root) => {
root.desktopMinimizeToTrayEnabled = enabled; root.desktopMinimizeToTrayEnabled = enabled;
@@ -3690,13 +3803,19 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
case 'desktop_open_path': { case 'desktop_open_path': {
const targetPath = typeof args.path === 'string' ? args.path.trim() : ''; const targetPath = typeof args.path === 'string' ? args.path.trim() : '';
const appName = typeof args.app === 'string' ? args.app.trim() : ''; const appName = typeof args.app === 'string' ? args.app.trim() : '';
if (!targetPath) throw new Error('Path is required'); const validated = await validateLocalPath(targetPath);
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const openArgs = appName ? ['-a', appName, targetPath] : [targetPath]; const openArgs = appName ? ['-a', appName, validated.path] : [validated.path];
spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref(); spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref();
return null; return null;
} }
await shell.openPath(targetPath); if (appName && process.platform !== 'linux' && process.platform !== 'win32') {
throw new Error(unsupportedAppSpecificOpenError('paths'));
}
const errorMessage = await shell.openPath(validated.path);
if (errorMessage) {
throw new Error(`Failed to open path: ${errorMessage}`);
}
return null; return null;
} }
@@ -3714,18 +3833,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
case 'desktop_reveal_path': { case 'desktop_reveal_path': {
const targetPath = typeof args.path === 'string' ? args.path.trim() : ''; const validated = await validateLocalPath(typeof args.path === 'string' ? args.path.trim() : '');
if (!targetPath) { if (validated.stats.isDirectory()) {
throw new Error('Path is required'); const errorMessage = await shell.openPath(validated.path);
if (errorMessage) {
throw new Error(`Failed to reveal path: ${errorMessage}`);
} }
const stats = await fsp.stat(targetPath).catch(() => null);
if (stats?.isDirectory()) {
await shell.openPath(targetPath);
return null; return null;
} }
shell.showItemInFolder(targetPath); shell.showItemInFolder(validated.path);
return null; return null;
} }
@@ -3736,19 +3853,31 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (!projectPath || !appId || !appName) { if (!projectPath || !appId || !appName) {
throw new Error('Project path, app id, and app name are required'); throw new Error('Project path, app id, and app name are required');
} }
const validated = await validateLocalPath(projectPath, 'Project path');
if (process.platform === 'win32') { if (process.platform === 'win32') {
if (appId === 'finder') { if (appId === 'finder') {
const error = await shell.openPath(projectPath); const error = await shell.openPath(validated.path);
if (error) throw new Error(error); if (error) throw new Error(error);
return null; return null;
} }
runSpecChain(buildWindowsOpenProjectSpecs({ projectPath, appId, appName }), appName); runSpecChain(buildWindowsOpenProjectSpecs({ projectPath: validated.path, appId, appName }), appName);
return null;
}
if (process.platform === 'linux') {
const entries = await getLinuxDesktopEntries();
await runLinuxSpecChain(buildLinuxOpenSpecs({
targetPath: validated.path,
appId,
appName,
targetKind: 'project',
entries,
}), appName);
return null; return null;
} }
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
throw new Error('desktop_open_in_app is only supported on macOS and Windows'); throw new Error(unsupportedAppSpecificOpenError('projects'));
} }
runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName); runSpecChain(buildOpenProjectSpecs({ projectPath: validated.path, appId, appName }), appName);
return null; return null;
} }
@@ -3759,14 +3888,26 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (!filePath || !appId || !appName) { if (!filePath || !appId || !appName) {
throw new Error('File path, app id, and app name are required'); throw new Error('File path, app id, and app name are required');
} }
const validated = await validateLocalPath(filePath, 'File path');
if (process.platform === 'win32') { if (process.platform === 'win32') {
runSpecChain(buildWindowsOpenFileSpecs({ filePath, appId, appName }), appName); runSpecChain(buildWindowsOpenFileSpecs({ filePath: validated.path, appId, appName }), appName);
return null;
}
if (process.platform === 'linux') {
const entries = await getLinuxDesktopEntries();
await runLinuxSpecChain(buildLinuxOpenSpecs({
targetPath: validated.path,
appId,
appName,
targetKind: 'file',
entries,
}), appName);
return null; return null;
} }
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
throw new Error('desktop_open_file_in_app is only supported on macOS and Windows'); throw new Error(unsupportedAppSpecificOpenError('files'));
} }
runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName); runSpecChain(buildOpenFileSpecs({ filePath: validated.path, appId, appName }), appName);
return null; return null;
} }
@@ -3774,8 +3915,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (process.platform === 'win32') { if (process.platform === 'win32') {
return (await buildWindowsInstalledApps(args.apps)).map((app) => app.name); return (await buildWindowsInstalledApps(args.apps)).map((app) => app.name);
} }
if (process.platform === 'linux') {
return filterLinuxInstalledApps(args.apps);
}
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
throw new Error('desktop_filter_installed_apps is only supported on macOS'); throw new Error('desktop_filter_installed_apps is only supported on macOS, Windows, and Linux');
} }
if (!Array.isArray(args.apps)) return []; if (!Array.isArray(args.apps)) return [];
const results = await Promise.all( const results = await Promise.all(
@@ -3799,8 +3943,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
return results; return results;
} }
if (process.platform === 'linux') {
return fetchLinuxAppIcons(Array.isArray(args.apps) ? args.apps : []);
}
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
throw new Error('desktop_fetch_app_icons is only supported on macOS'); throw new Error('desktop_fetch_app_icons is only supported on macOS, Windows, and Linux');
} }
const names = Array.isArray(args.apps) ? args.apps : []; const names = Array.isArray(args.apps) ? args.apps : [];
const results = []; const results = [];
@@ -3825,14 +3972,12 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
const hasCache = Boolean(cache); const hasCache = Boolean(cache);
const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS; const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS;
const refresh = async () => { const refresh = async () => {
const apps = process.platform === 'win32' const apps = await buildPlatformInstalledApps(Array.isArray(args.apps) ? args.apps : []);
? await buildWindowsInstalledApps(args.apps)
: await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
await fsp.mkdir(path.dirname(cachePath), { recursive: true }); await fsp.mkdir(path.dirname(cachePath), { recursive: true });
await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2)); await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2));
emitToAllWindows('openchamber:installed-apps-updated', apps); emitToAllWindows('openchamber:installed-apps-updated', apps);
}; };
if (process.platform !== 'darwin' && process.platform !== 'win32') { if (process.platform !== 'darwin' && process.platform !== 'win32' && process.platform !== 'linux') {
return { apps: [], hasCache: false, isCacheStale: false, supported: false }; return { apps: [], hasCache: false, isCacheStale: false, supported: false };
} }
if (!hasCache || isCacheStale || args.force === true) { if (!hasCache || isCacheStale || args.force === true) {
@@ -3862,6 +4007,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
localAvailable: Boolean(state.sidecarUrl || state.localOrigin), localAvailable: Boolean(state.sidecarUrl || state.localOrigin),
}); });
state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {}); state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {});
syncInitScriptToWindows(state.initScript);
log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome); log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome);
return null; return null;
} }
@@ -4662,8 +4808,10 @@ const resolveTraySurface = () => {
const trayIconAssets = () => { const trayIconAssets = () => {
const dir = path.join(resourceRoot(), 'icons', 'tray'); const dir = path.join(resourceRoot(), 'icons', 'tray');
const statusDir = path.join(dir, 'status'); const statusDir = path.join(dir, 'status');
if (process.platform === 'win32') { if (process.platform === 'win32' || process.platform === 'linux') {
const iconPath = getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico'); const iconPath = process.platform === 'linux'
? (getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.png'))
: (getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico'));
return { return {
idleIconPath: iconPath, idleIconPath: iconPath,
unseenIconPath: iconPath, unseenIconPath: iconPath,
@@ -4695,7 +4843,7 @@ const trayIconAssets = () => {
}; };
const setupTray = () => { const setupTray = () => {
if (!['darwin', 'win32'].includes(process.platform) || state.trayController) return; if (!['darwin', 'win32', 'linux'].includes(process.platform) || state.trayController) return;
if (process.platform === 'darwin' && readSettingsRoot().desktopMacMenuBarEnabled === false) return; if (process.platform === 'darwin' && readSettingsRoot().desktopMacMenuBarEnabled === false) return;
const assets = trayIconAssets(); const assets = trayIconAssets();
if (!fs.existsSync(assets.idleIconPath)) { if (!fs.existsSync(assets.idleIconPath)) {
@@ -4920,6 +5068,21 @@ app.whenReady().then(async () => {
}); });
} }
if (process.platform === 'linux' && app.isPackaged) {
try {
const enabled = await readLinuxAutostartEnabled();
if (enabled) {
await setLinuxAutostartEnabled({
enabled: true,
appName: app.getName(),
backgroundArg: BACKGROUND_START_ARG,
});
}
} catch (error) {
log.warn('[electron] failed to reconcile Linux autostart entry', error);
}
}
if (isBackgroundStart) { if (isBackgroundStart) {
const { localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); const { localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl();
state.localOrigin = localOrigin; state.localOrigin = localOrigin;
+1
View File
@@ -40,6 +40,7 @@
"rebuild:native": "node ./scripts/rebuild-native.mjs", "rebuild:native": "node ./scripts/rebuild-native.mjs",
"test:architecture": "node --test ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs", "test:architecture": "node --test ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs",
"test:updater": "node --test ./updater-capability.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/updater-e2e-fixture.test.mjs", "test:updater": "node --test ./updater-capability.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/updater-e2e-fixture.test.mjs",
"test:linux-desktop": "node --test ./linux-autostart.test.mjs && node ./scripts/smoke-linux-app-discovery.mjs && node ./scripts/smoke-path-open-utils.mjs",
"updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs", "updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs",
"verify:update-manifest": "node ./scripts/verify-update-manifest.mjs", "verify:update-manifest": "node ./scripts/verify-update-manifest.mjs",
"package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", "package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs",
+51
View File
@@ -0,0 +1,51 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
const accessErrorMessage = (label, targetPath, error) => {
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
return `${label} does not exist: ${targetPath}`;
}
if (error?.code === 'EACCES' || error?.code === 'EPERM') {
return `${label} is not accessible: ${targetPath}`;
}
return `${label} could not be checked: ${error?.message || String(error)}`;
};
export const normalizeRequiredPath = (rawPath, label = 'Path') => {
const targetPath = typeof rawPath === 'string' ? rawPath.trim() : '';
if (!targetPath) {
throw new Error(`${label} is required`);
}
return path.resolve(targetPath);
};
export const validateLocalPath = async (rawPath, label = 'Path') => {
const targetPath = normalizeRequiredPath(rawPath, label);
let stats;
try {
stats = await fsp.stat(targetPath);
} catch (error) {
throw new Error(accessErrorMessage(label, targetPath, error));
}
const accessMode = stats.isDirectory()
? fs.constants.R_OK | fs.constants.X_OK
: fs.constants.R_OK;
try {
await fsp.access(targetPath, accessMode);
} catch (error) {
throw new Error(accessErrorMessage(label, targetPath, error));
}
return { path: targetPath, stats };
};
export const unsupportedAppSpecificOpenError = (targetKind, platform = process.platform) => {
const platformName = platform === 'linux'
? 'Linux'
: platform === 'win32'
? 'Windows'
: platform;
return `Opening ${targetKind} in a specific app is not supported on ${platformName} yet. Use the default open action instead.`;
};
@@ -0,0 +1,112 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
buildCommandFromDesktopExec,
buildLinuxInstalledApps,
buildLinuxOpenSpecs,
filterLinuxInstalledApps,
linuxApplicationDirs,
parseDesktopEntry,
readLinuxDesktopEntries,
} from '../linux-app-discovery.mjs';
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-linux-apps-'));
try {
const dataHome = path.join(tempRoot, 'data-home');
const dataDir = path.join(tempRoot, 'system-data');
const userApps = path.join(dataHome, 'applications');
const systemApps = path.join(dataDir, 'applications');
await fs.mkdir(userApps, { recursive: true });
await fs.mkdir(systemApps, { recursive: true });
const codeDesktopPath = path.join(userApps, 'code.desktop');
await fs.writeFile(codeDesktopPath, [
'[Desktop Entry]',
'Type=Application',
'Name=Visual Studio Code',
'Exec="/opt/Visual Studio Code/code" --new-window %F --reuse-window %i %c %k',
'Icon=code',
'Categories=Development;IDE;',
'',
].join('\n'), 'utf8');
await fs.writeFile(path.join(userApps, 'hidden.desktop'), '[Desktop Entry]\nType=Application\nName=Hidden App\nExec=hidden %f\nHidden=true\n', 'utf8');
await fs.writeFile(path.join(userApps, 'nodisplay.desktop'), '[Desktop Entry]\nType=Application\nName=No Display App\nExec=nodisplay %f\nNoDisplay=true\n', 'utf8');
await fs.writeFile(path.join(userApps, 'missing-name.desktop'), '[Desktop Entry]\nType=Application\nExec=missing %f\n', 'utf8');
await fs.writeFile(path.join(userApps, 'missing-exec.desktop'), '[Desktop Entry]\nType=Application\nName=Missing Exec\nIcon=missing\n', 'utf8');
await fs.writeFile(path.join(systemApps, 'ghostty.desktop'), '[Desktop Entry]\nType=Application\nName=Ghostty\nExec=ghostty --working-directory=%f --open-uri=%u\nIcon=ghostty\n', 'utf8');
await fs.writeFile(path.join(systemApps, 'plain.desktop'), '[Desktop Entry]\nType=Application\nName=Plain Editor\nExec=plain-editor --flag\nIcon=plain\n', 'utf8');
const env = { XDG_DATA_HOME: dataHome, XDG_DATA_DIRS: dataDir, PATH: '/no/such/bin' };
const dirs = linuxApplicationDirs({ env, homeDir: tempRoot });
assert(dirs.includes(userApps), 'XDG_DATA_HOME applications dir should be included');
assert(dirs.includes(systemApps), 'XDG_DATA_DIRS applications dir should be included');
const entries = await readLinuxDesktopEntries({ applicationDirs: [userApps, systemApps], env, homeDir: tempRoot });
assert(entries.length === 3, `expected 3 visible valid entries, got ${entries.length}`);
assert(entries.some((entry) => entry.name === 'Visual Studio Code'), 'valid desktop entry should be parsed');
assert(entries.some((entry) => entry.name === 'Ghostty'), 'system desktop entry should be parsed');
assert(entries.some((entry) => entry.name === 'Plain Editor'), 'no-placeholder entry should be parsed');
assert(!entries.some((entry) => entry.name === 'Hidden App'), 'Hidden=true entry should be skipped');
assert(!entries.some((entry) => entry.name === 'No Display App'), 'NoDisplay=true entry should be skipped');
assert(!entries.some((entry) => entry.name === 'Missing Exec'), 'missing Exec entry should be skipped');
const codeEntry = parseDesktopEntry(await fs.readFile(codeDesktopPath, 'utf8'), codeDesktopPath);
assert(codeEntry?.name === 'Visual Studio Code', 'parser should read Name');
assert(codeEntry?.icon === 'code', 'parser should read Icon');
assert(codeEntry?.categories.includes('Development'), 'parser should split Categories');
assert(codeEntry?.rawExec?.includes('%F'), 'parser should preserve original Exec placeholders for launch construction');
assert(codeEntry?.exec === '"/opt/Visual Studio Code/code" --new-window --reuse-window', `parser should expose stripped Exec metadata, got ${codeEntry?.exec}`);
const command = buildCommandFromDesktopExec(codeEntry, '/tmp/My Project');
assert(command?.program === '/opt/Visual Studio Code/code', 'quoted Exec program should stay intact');
assert(command.args.slice(0, 3).join('|') === '--new-window|/tmp/My Project|--reuse-window', `Exec %F should stay at original position, got ${command.args.join('|')}`);
assert(!command.args.some((arg) => arg.includes('%')), 'Exec field codes should not leak into command args');
const ghosttyEntry = entries.find((entry) => entry.name === 'Ghostty');
const ghosttyCommand = buildCommandFromDesktopExec(ghosttyEntry, '/tmp/My Project');
assert(ghosttyCommand?.args.join('|') === '--working-directory=/tmp/My Project|--open-uri=/tmp/My Project', `embedded %f/%u should be substituted in place, got ${ghosttyCommand?.args.join('|')}`);
const urlEntry = parseDesktopEntry('[Desktop Entry]\nType=Application\nName=URL Handler\nExec=url-handler --url %U\n', '/tmp/url.desktop');
const urlCommand = buildCommandFromDesktopExec(urlEntry, 'file:///tmp/My%20Project');
assert(urlCommand?.args.join('|') === '--url|file:///tmp/My%20Project', `Exec %U should substitute URL targets, got ${urlCommand?.args.join('|')}`);
const plainEntry = entries.find((entry) => entry.name === 'Plain Editor');
const plainCommand = buildCommandFromDesktopExec(plainEntry, '/tmp/My Project');
assert(plainCommand?.args.join('|') === '--flag|/tmp/My Project', `target should append when Exec has no placeholder, got ${plainCommand?.args.join('|')}`);
const installed = await filterLinuxInstalledApps(['Visual Studio Code', 'Hidden App', 'Missing App'], { entries });
assert(installed.length === 1 && installed[0] === 'Visual Studio Code', 'filter should return only visible installed apps');
const appInfos = await buildLinuxInstalledApps(['Visual Studio Code', 'Ghostty'], { entries });
assert(appInfos.length === 2, 'installed app info should include matching entries');
assert(appInfos.every((entry) => Object.hasOwn(entry, 'iconDataUrl')), 'installed app info should include iconDataUrl key');
const specs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'vscode', appName: 'Visual Studio Code', targetKind: 'project', entries, env });
assert(specs.length === 1, 'desktop entry should provide an opener when CLI is absent');
assert(specs[0].program === '/opt/Visual Studio Code/code', 'desktop entry opener should use parsed program');
assert(specs[0].args.includes('/tmp/My Project'), 'desktop entry opener should include target');
const terminalFileSpecs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project/file.txt', appId: 'ghostty', appName: 'Ghostty', targetKind: 'file', entries, env });
assert(terminalFileSpecs[0]?.program === 'ghostty', 'terminal desktop entry should be preferred when present');
assert(terminalFileSpecs[0]?.args.join('|') === '--working-directory=/tmp/My Project|--open-uri=/tmp/My Project', `terminal file target should use dirname, got ${terminalFileSpecs[0]?.args.join('|')}`);
assert(terminalFileSpecs[1]?.program === 'xdg-terminal-exec', 'terminal specs should include xdg-terminal-exec fallback after desktop entry');
assert(terminalFileSpecs[1]?.args.join('|') === '--working-directory|/tmp/My Project', `terminal fallback should use file dirname, got ${terminalFileSpecs[1]?.args.join('|')}`);
const fallbackTerminalSpecs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'terminal', appName: 'Terminal', targetKind: 'project', entries, env });
assert(fallbackTerminalSpecs.length >= 1, 'missing terminal desktop entry should include xdg-terminal-exec fallback');
assert(fallbackTerminalSpecs[0]?.program === 'xdg-terminal-exec', 'missing terminal entry should use xdg-terminal-exec first');
assert(fallbackTerminalSpecs[0]?.args.join('|') === '--working-directory|/tmp/My Project', `xdg-terminal-exec fallback should keep working directory args, got ${fallbackTerminalSpecs[0]?.args.join('|')}`);
const defaultSpecs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'finder', appName: 'Finder', targetKind: 'project', entries, env });
assert(defaultSpecs[0].kind === 'default', 'finder maps to safe default Linux opener spec');
console.log(JSON.stringify({ ok: true, dirs, entries: entries.map((entry) => entry.name), command, ghosttyCommand, plainCommand, installed, specs, terminalFileSpecs, fallbackTerminalSpecs, defaultSpecs }, null, 2));
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
@@ -0,0 +1,77 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { validateLocalPath, unsupportedAppSpecificOpenError } from '../path-open-utils.mjs';
const assert = (condition, message) => {
if (!condition) {
throw new Error(message);
}
};
const expectRejects = async (label, callback, expected) => {
try {
await callback();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert(message.includes(expected), `${label}: expected "${expected}" in "${message}"`);
return message;
}
throw new Error(`${label}: expected rejection`);
};
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-path-open-'));
try {
const existingFile = path.join(tempRoot, 'existing.txt');
await fs.writeFile(existingFile, 'ok', 'utf8');
const validated = await validateLocalPath(existingFile);
assert(validated.path === existingFile, 'valid file path should resolve to the same absolute path');
assert(validated.stats.isFile(), 'valid file path should return file stats');
const validatedDirectory = await validateLocalPath(tempRoot, 'Directory');
assert(validatedDirectory.path === tempRoot, 'valid directory path should resolve to the same absolute path');
assert(validatedDirectory.stats.isDirectory(), 'valid directory path should return directory stats');
const missingMessage = await expectRejects(
'missing path',
() => validateLocalPath(path.join(tempRoot, 'missing.txt')),
'does not exist',
);
const emptyMessage = await expectRejects(
'empty path',
() => validateLocalPath(' '),
'Path is required',
);
const inaccessiblePath = path.join(tempRoot, 'inaccessible');
await fs.mkdir(inaccessiblePath, { mode: 0o700 });
await fs.chmod(inaccessiblePath, 0o000);
let inaccessibleMessage = '';
try {
inaccessibleMessage = await expectRejects(
'inaccessible path',
() => validateLocalPath(inaccessiblePath),
'is not accessible',
);
} finally {
await fs.chmod(inaccessiblePath, 0o700).catch(() => {});
}
const unsupported = unsupportedAppSpecificOpenError('projects', 'linux');
assert(
unsupported.includes('not supported on Linux') && unsupported.includes('default open action'),
'unsupported app-specific Linux message should point to default open action',
);
console.log(JSON.stringify({
ok: true,
validated: validated.path,
validatedDirectory: validatedDirectory.path,
missingMessage,
emptyMessage,
inaccessibleMessage,
unsupported,
}, null, 2));
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
@@ -274,6 +274,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return isDesktopShell() && typeof window !== 'undefined' return isDesktopShell() && typeof window !== 'undefined'
&& (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'win32'; && (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'win32';
}, []); }, []);
const isLinux = React.useMemo(() => {
return isDesktopShell() && typeof window !== 'undefined'
&& (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'linux';
}, []);
// keep platform check available for future window chrome tweaks // keep platform check available for future window chrome tweaks
@@ -417,12 +421,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const settingsSearchResults = React.useMemo(() => { const settingsSearchResults = React.useMemo(() => {
return buildSettingsSearchResults({ return buildSettingsSearchResults({
query: settingsSearchQuery, query: settingsSearchQuery,
runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac, isWindows }, runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac, isWindows, isLinux },
visiblePageSlugs, visiblePageSlugs,
t, t,
getPageTitle, getPageTitle,
}); });
}, [getPageTitle, isDesktopLocalOrigin, isMac, isWindows, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]); }, [getPageTitle, isDesktopLocalOrigin, isMac, isWindows, isLinux, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
const prepareSettingsSearchTarget = React.useCallback((result: SettingsSearchResult): string => { const prepareSettingsSearchTarget = React.useCallback((result: SettingsSearchResult): string => {
if (result.id.startsWith('agents.')) { if (result.id.startsWith('agents.')) {
+1 -1
View File
@@ -104,7 +104,7 @@ type DesktopBridgeGlobal = {
const isTrayPlatform = (): boolean => { const isTrayPlatform = (): boolean => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__; const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__;
return platform === 'darwin' || platform === 'win32'; return platform === 'darwin' || platform === 'win32' || platform === 'linux';
}; };
const isTrayEnabled = (): boolean => const isTrayEnabled = (): boolean =>
+3
View File
@@ -661,6 +661,9 @@ export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
return null; return null;
} }
// Propagate updater capability / feed errors so the UI can show actionable
// messages (missing AppImage, read-only path, network failure). Missing
// latest-linux*.yml is already normalized to available:false in main.
const info = await invokeDesktop<UpdateInfo>('desktop_check_for_updates'); const info = await invokeDesktop<UpdateInfo>('desktop_check_for_updates');
return info as UpdateInfo; return info as UpdateInfo;
}; };
@@ -954,7 +954,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Requires an app restart. When off, OpenChamber does not create the menu bar item or run its session, approval, and usage updates.', 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Requires an app restart. When off, OpenChamber does not create the menu bar item or run its session, approval, and usage updates.',
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'Minimize and close OpenChamber to the system tray', 'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'Minimize and close OpenChamber to the system tray',
'settings.openchamber.desktopNetwork.field.minimizeToTray': 'Minimize and close to the system tray', 'settings.openchamber.desktopNetwork.field.minimizeToTray': 'Minimize and close to the system tray',
'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'Keeps OpenChamber running in the Windows system tray when the main window is minimized or closed.', 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'Keeps OpenChamber running in the system tray when the main window is minimized or closed.',
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'Keep computer awake while OpenChamber is running', 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'Keep computer awake while OpenChamber is running',
'settings.openchamber.desktopNetwork.field.keepAwake': 'Keep computer awake while OpenChamber is running', 'settings.openchamber.desktopNetwork.field.keepAwake': 'Keep computer awake while OpenChamber is running',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Prevents system sleep so phones can keep reaching this app. The screen can still turn off.', 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Prevents system sleep so phones can keep reaching this app. The screen can still turn off.',
@@ -921,7 +921,7 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.macMenuBarDescription": "Requiere reiniciar la aplicación. Al desactivarlo, OpenChamber no crea el elemento de la barra de menús ni ejecuta sus actualizaciones de sesiones, aprobaciones y uso.", "settings.openchamber.desktopNetwork.field.macMenuBarDescription": "Requiere reiniciar la aplicación. Al desactivarlo, OpenChamber no crea el elemento de la barra de menús ni ejecuta sus actualizaciones de sesiones, aprobaciones y uso.",
"settings.openchamber.desktopNetwork.field.minimizeToTrayAria": "Minimizar y cerrar OpenChamber a la bandeja del sistema", "settings.openchamber.desktopNetwork.field.minimizeToTrayAria": "Minimizar y cerrar OpenChamber a la bandeja del sistema",
"settings.openchamber.desktopNetwork.field.minimizeToTray": "Minimizar y cerrar a la bandeja del sistema", "settings.openchamber.desktopNetwork.field.minimizeToTray": "Minimizar y cerrar a la bandeja del sistema",
"settings.openchamber.desktopNetwork.field.minimizeToTrayDescription": "Mantiene OpenChamber en ejecución en la bandeja del sistema de Windows cuando la ventana principal se minimiza o se cierra.", "settings.openchamber.desktopNetwork.field.minimizeToTrayDescription": "Mantiene OpenChamber en ejecución en la bandeja del sistema cuando la ventana principal se minimiza o se cierra.",
"settings.openchamber.desktopNetwork.field.keepAwakeAria": "Mantener el ordenador activo mientras OpenChamber está en ejecución", "settings.openchamber.desktopNetwork.field.keepAwakeAria": "Mantener el ordenador activo mientras OpenChamber está en ejecución",
"settings.openchamber.desktopNetwork.field.keepAwake": "Mantener el ordenador activo mientras OpenChamber está en ejecución", "settings.openchamber.desktopNetwork.field.keepAwake": "Mantener el ordenador activo mientras OpenChamber está en ejecución",
"settings.openchamber.desktopNetwork.field.keepAwakeDescription": "Para que los teléfonos puedan seguir abriendo esta app. La pantalla aún puede apagarse.", "settings.openchamber.desktopNetwork.field.keepAwakeDescription": "Para que los teléfonos puedan seguir abriendo esta app. La pantalla aún puede apagarse.",
@@ -842,7 +842,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Nécessite un redémarrage de l\'application. Lorsque cette option est désactivée, OpenChamber ne crée pas l\'élément de barre des menus et n\'exécute pas ses mises à jour de sessions, d\'approbations et d\'utilisation.', 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Nécessite un redémarrage de l\'application. Lorsque cette option est désactivée, OpenChamber ne crée pas l\'élément de barre des menus et n\'exécute pas ses mises à jour de sessions, d\'approbations et d\'utilisation.',
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'Réduire et fermer OpenChamber dans la zone de notification', 'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'Réduire et fermer OpenChamber dans la zone de notification',
'settings.openchamber.desktopNetwork.field.minimizeToTray': 'Réduire et fermer dans la zone de notification', 'settings.openchamber.desktopNetwork.field.minimizeToTray': 'Réduire et fermer dans la zone de notification',
'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'Garde OpenChamber actif dans la zone de notification Windows lorsque la fenêtre principale est réduite ou fermée.', 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'Garde OpenChamber actif dans la zone de notification lorsque la fenêtre principale est réduite ou fermée.',
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'Garder l\'ordinateur éveillé pendant qu\'OpenChamber fonctionne', 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'Garder l\'ordinateur éveillé pendant qu\'OpenChamber fonctionne',
'settings.openchamber.desktopNetwork.field.keepAwake': 'Garder l\'ordinateur éveillé pendant qu\'OpenChamber fonctionne', 'settings.openchamber.desktopNetwork.field.keepAwake': 'Garder l\'ordinateur éveillé pendant qu\'OpenChamber fonctionne',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Pour que les téléphones puissent continuer à ouvrir cette application. L\'écran peut toujours s\'éteindre.', 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Pour que les téléphones puissent continuer à ouvrir cette application. L\'écran peut toujours s\'éteindre.',
@@ -954,7 +954,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'アプリの再起動が必要です。オフにすると、OpenChamber はメニューバー項目を作成せず、セッション、承認、使用量の更新も実行しません。', 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'アプリの再起動が必要です。オフにすると、OpenChamber はメニューバー項目を作成せず、セッション、承認、使用量の更新も実行しません。',
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'OpenChamber をシステムトレイへ最小化または閉じる', 'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'OpenChamber をシステムトレイへ最小化または閉じる',
'settings.openchamber.desktopNetwork.field.minimizeToTray': 'システムトレイへ最小化または閉じる', 'settings.openchamber.desktopNetwork.field.minimizeToTray': 'システムトレイへ最小化または閉じる',
'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'メインウィンドウを最小化または閉じたときも、OpenChamber を Windows のシステムトレイで実行し続けます。', 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'メインウィンドウを最小化または閉じたときも、OpenChamber をシステムトレイで実行し続けます。',
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber の実行中はコンピューターのスリープを防止', 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber の実行中はコンピューターのスリープを防止',
'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber の実行中はコンピューターのスリープを防止', 'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber の実行中はコンピューターのスリープを防止',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'スマートフォンがこのアプリを開き続けられるようにします。ディスプレイはオフにできます。', 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'スマートフォンがこのアプリを開き続けられるようにします。ディスプレイはオフにできます。',
@@ -921,7 +921,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '앱을 다시 시작해야 합니다. 끄면 OpenChamber가 메뉴 막대 항목을 만들지 않으며 세션, 승인 및 사용량 업데이트도 실행하지 않습니다.', 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '앱을 다시 시작해야 합니다. 끄면 OpenChamber가 메뉴 막대 항목을 만들지 않으며 세션, 승인 및 사용량 업데이트도 실행하지 않습니다.',
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'OpenChamber를 시스템 트레이로 최소화하거나 닫기', 'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'OpenChamber를 시스템 트레이로 최소화하거나 닫기',
'settings.openchamber.desktopNetwork.field.minimizeToTray': '시스템 트레이로 최소화하고 닫기', 'settings.openchamber.desktopNetwork.field.minimizeToTray': '시스템 트레이로 최소화하고 닫기',
'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': '메인 창을 최소화하거나 닫아도 OpenChamber가 Windows 시스템 트레이에서 계속 실행됩니다.', 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': '메인 창을 최소화하거나 닫아도 OpenChamber가 시스템 트레이에서 계속 실행됩니다.',
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber가 실행되는 동안 컴퓨터 절전 방지', 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber가 실행되는 동안 컴퓨터 절전 방지',
'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber가 실행되는 동안 컴퓨터 절전 방지', 'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber가 실행되는 동안 컴퓨터 절전 방지',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '휴대폰이 이 앱을 계속 열 수 있도록 합니다. 디스플레이는 꺼질 수 있습니다.', 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '휴대폰이 이 앱을 계속 열 수 있도록 합니다. 디스플레이는 꺼질 수 있습니다.',
@@ -761,7 +761,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Wymaga ponownego uruchomienia aplikacji. Po wyłączeniu OpenChamber nie tworzy elementu paska menu ani nie uruchamia aktualizacji sesji, zatwierdzeń i użycia.', 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Wymaga ponownego uruchomienia aplikacji. Po wyłączeniu OpenChamber nie tworzy elementu paska menu ani nie uruchamia aktualizacji sesji, zatwierdzeń i użycia.',
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'Minimalizuj i zamykaj OpenChamber do zasobnika systemowego', 'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'Minimalizuj i zamykaj OpenChamber do zasobnika systemowego',
'settings.openchamber.desktopNetwork.field.minimizeToTray': 'Minimalizuj i zamykaj do zasobnika systemowego', 'settings.openchamber.desktopNetwork.field.minimizeToTray': 'Minimalizuj i zamykaj do zasobnika systemowego',
'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'Utrzymuje OpenChamber w zasobniku systemowym Windows, gdy główne okno jest zminimalizowane lub zamknięte.', 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'Utrzymuje OpenChamber w zasobniku systemowym, gdy główne okno jest zminimalizowane lub zamknięte.',
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'Utrzymuj komputer aktywny, gdy OpenChamber jest uruchomiony', 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'Utrzymuj komputer aktywny, gdy OpenChamber jest uruchomiony',
'settings.openchamber.desktopNetwork.field.keepAwake': 'Utrzymuj komputer aktywny, gdy OpenChamber jest uruchomiony', 'settings.openchamber.desktopNetwork.field.keepAwake': 'Utrzymuj komputer aktywny, gdy OpenChamber jest uruchomiony',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Aby telefony nadal mogły otwierać tę aplikację. Ekran nadal może się wyłączyć.', 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Aby telefony nadal mogły otwierać tę aplikację. Ekran nadal może się wyłączyć.',
@@ -921,7 +921,7 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.macMenuBarDescription": "Exige reiniciar o aplicativo. Quando desativado, o OpenChamber não cria o item da barra de menus nem executa as atualizações de sessões, aprovações e uso.", "settings.openchamber.desktopNetwork.field.macMenuBarDescription": "Exige reiniciar o aplicativo. Quando desativado, o OpenChamber não cria o item da barra de menus nem executa as atualizações de sessões, aprovações e uso.",
"settings.openchamber.desktopNetwork.field.minimizeToTrayAria": "Minimizar e fechar o OpenChamber na bandeja do sistema", "settings.openchamber.desktopNetwork.field.minimizeToTrayAria": "Minimizar e fechar o OpenChamber na bandeja do sistema",
"settings.openchamber.desktopNetwork.field.minimizeToTray": "Minimizar e fechar na bandeja do sistema", "settings.openchamber.desktopNetwork.field.minimizeToTray": "Minimizar e fechar na bandeja do sistema",
"settings.openchamber.desktopNetwork.field.minimizeToTrayDescription": "Mantém o OpenChamber em execução na bandeja do sistema do Windows quando a janela principal é minimizada ou fechada.", "settings.openchamber.desktopNetwork.field.minimizeToTrayDescription": "Mantém o OpenChamber em execução na bandeja do sistema quando a janela principal é minimizada ou fechada.",
"settings.openchamber.desktopNetwork.field.keepAwakeAria": "Manter o computador ativo enquanto o OpenChamber estiver em execução", "settings.openchamber.desktopNetwork.field.keepAwakeAria": "Manter o computador ativo enquanto o OpenChamber estiver em execução",
"settings.openchamber.desktopNetwork.field.keepAwake": "Manter o computador ativo enquanto o OpenChamber estiver em execução", "settings.openchamber.desktopNetwork.field.keepAwake": "Manter o computador ativo enquanto o OpenChamber estiver em execução",
"settings.openchamber.desktopNetwork.field.keepAwakeDescription": "Para que telefones continuem abrindo este app. A tela ainda pode desligar.", "settings.openchamber.desktopNetwork.field.keepAwakeDescription": "Para que telefones continuem abrindo este app. A tela ainda pode desligar.",
@@ -921,7 +921,7 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.macMenuBarDescription": "Потребує перезапуску застосунку. Якщо вимкнено, OpenChamber не створює елемент смуги меню та не запускає пов’язані оновлення сесій, запитів на підтвердження й використання.", "settings.openchamber.desktopNetwork.field.macMenuBarDescription": "Потребує перезапуску застосунку. Якщо вимкнено, OpenChamber не створює елемент смуги меню та не запускає пов’язані оновлення сесій, запитів на підтвердження й використання.",
"settings.openchamber.desktopNetwork.field.minimizeToTrayAria": "Згортати й закривати OpenChamber у системний трей", "settings.openchamber.desktopNetwork.field.minimizeToTrayAria": "Згортати й закривати OpenChamber у системний трей",
"settings.openchamber.desktopNetwork.field.minimizeToTray": "Згортати й закривати в системний трей", "settings.openchamber.desktopNetwork.field.minimizeToTray": "Згортати й закривати в системний трей",
"settings.openchamber.desktopNetwork.field.minimizeToTrayDescription": "Залишає OpenChamber запущеним у системному треї Windows, коли головне вікно згорнуто або закрито.", "settings.openchamber.desktopNetwork.field.minimizeToTrayDescription": "Залишає OpenChamber запущеним у системному треї, коли головне вікно згорнуто або закрито.",
"settings.openchamber.desktopNetwork.field.keepAwakeAria": "Не давати комп’ютеру засинати, поки OpenChamber запущено", "settings.openchamber.desktopNetwork.field.keepAwakeAria": "Не давати комп’ютеру засинати, поки OpenChamber запущено",
"settings.openchamber.desktopNetwork.field.keepAwake": "Не давати комп’ютеру засинати, поки OpenChamber запущено", "settings.openchamber.desktopNetwork.field.keepAwake": "Не давати комп’ютеру засинати, поки OpenChamber запущено",
"settings.openchamber.desktopNetwork.field.keepAwakeDescription": "Щоб телефон і далі міг відкривати застосунок. Екран усе одно може вимикатися.", "settings.openchamber.desktopNetwork.field.keepAwakeDescription": "Щоб телефон і далі міг відкривати застосунок. Екран усе одно може вимикатися.",
@@ -921,7 +921,7 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '需要重启应用。关闭后,OpenChamber 不会创建菜单栏项目,也不会运行相关的会话、审批和用量更新。', 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '需要重启应用。关闭后,OpenChamber 不会创建菜单栏项目,也不会运行相关的会话、审批和用量更新。',
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': '将 OpenChamber 最小化和关闭到系统托盘', 'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': '将 OpenChamber 最小化和关闭到系统托盘',
'settings.openchamber.desktopNetwork.field.minimizeToTray': '最小化和关闭到系统托盘', 'settings.openchamber.desktopNetwork.field.minimizeToTray': '最小化和关闭到系统托盘',
'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': '主窗口最小化或关闭时,让 OpenChamber 继续在 Windows 系统托盘中运行。', 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': '主窗口最小化或关闭时,让 OpenChamber 继续在系统托盘中运行。',
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 运行时保持电脑唤醒', 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 运行时保持电脑唤醒',
'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 运行时保持电脑唤醒', 'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 运行时保持电脑唤醒',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '让手机可以继续打开此应用。屏幕仍可关闭。', 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '让手机可以继续打开此应用。屏幕仍可关闭。',
@@ -1933,7 +1933,7 @@
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '需要重新啟動應用程式。關閉後,OpenChamber 不會建立選單列項目,也不會執行相關的工作階段、核准與用量更新。', 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '需要重新啟動應用程式。關閉後,OpenChamber 不會建立選單列項目,也不會執行相關的工作階段、核准與用量更新。',
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': '將 OpenChamber 最小化和關閉到系統匣', 'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': '將 OpenChamber 最小化和關閉到系統匣',
'settings.openchamber.desktopNetwork.field.minimizeToTray': '最小化和關閉到系統匣', 'settings.openchamber.desktopNetwork.field.minimizeToTray': '最小化和關閉到系統匣',
'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': '主視窗最小化或關閉時,讓 OpenChamber 繼續在 Windows 系統匣中執行。', 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': '主視窗最小化或關閉時,讓 OpenChamber 繼續在系統匣中執行。',
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 執行時保持電腦喚醒', 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 執行時保持電腦喚醒',
'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 執行時保持電腦喚醒', 'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 執行時保持電腦喚醒',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '讓手機可以持續開啟此應用程式。螢幕仍可關閉。', 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '讓手機可以持續開啟此應用程式。螢幕仍可關閉。',
+9 -1
View File
@@ -35,10 +35,18 @@ export const DEFAULT_OPEN_IN_APP_ID = 'finder';
export const OPEN_IN_ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']); export const OPEN_IN_ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']);
export const getPlatformOpenInApp = (app: OpenInApp): OpenInApp => { export const getPlatformOpenInApp = (app: OpenInApp): OpenInApp => {
if (typeof window !== 'undefined' && window.__OPENCHAMBER_PLATFORM__ === 'win32') { if (typeof window === 'undefined') {
return app;
}
const platform = window.__OPENCHAMBER_PLATFORM__;
if (app.id === 'finder') { if (app.id === 'finder') {
if (platform === 'win32') {
return { ...app, label: 'Explorer', appName: 'File Explorer' }; return { ...app, label: 'Explorer', appName: 'File Explorer' };
} }
if (platform === 'linux') {
return { ...app, label: 'File Manager' };
}
} }
return app; return app;
}; };
+5 -3
View File
@@ -24,6 +24,8 @@ interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext {
isMac: boolean; isMac: boolean;
// Windows desktop shell — for controls that only render on win32. // Windows desktop shell — for controls that only render on win32.
isWindows: boolean; isWindows: boolean;
// Linux desktop shell — for controls that only render on linux.
isLinux: boolean;
} }
const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
@@ -385,7 +387,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
page: 'general', page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.launchAtLogin', titleKey: 'settings.openchamber.desktopNetwork.field.launchAtLogin',
descriptionKey: 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription', descriptionKey: 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription',
keywords: ['desktop', 'startup', 'login'], keywords: ['desktop', 'startup', 'login', 'launch', 'background', 'autostart'],
isAvailable: (ctx) => ctx.isDesktopLocalOrigin, isAvailable: (ctx) => ctx.isDesktopLocalOrigin,
}, },
{ {
@@ -409,8 +411,8 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
page: 'general', page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.minimizeToTray', titleKey: 'settings.openchamber.desktopNetwork.field.minimizeToTray',
descriptionKey: 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription', descriptionKey: 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription',
keywords: ['desktop', 'tray', 'system tray', 'minimize', 'close', 'background', 'windows'], keywords: ['desktop', 'tray', 'system tray', 'minimize', 'close', 'background', 'windows', 'linux'],
isAvailable: (ctx) => ctx.isDesktopLocalOrigin && ctx.isWindows, isAvailable: (ctx) => ctx.isDesktopLocalOrigin && (ctx.isWindows || ctx.isLinux),
}, },
{ {
id: 'sessions.desktop-keep-awake', id: 'sessions.desktop-keep-awake',