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:
committed by
GitHub
co-authored by
Cursor Agent
Serhii Dziupin
jibanez-staticduo
parent
ddbb3c1db0
commit
18b58bdb6b
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 () => [];
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
+196
-33
@@ -17,6 +17,18 @@ import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs';
|
||||
import { assertUpdaterCapability } from './updater-capability.mjs';
|
||||
import { checkForDesktopUpdate } from './updater-check.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';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -44,6 +56,9 @@ const getLoginItemOptions = () => {
|
||||
};
|
||||
|
||||
const readLoginItemSettings = () => {
|
||||
if (process.platform === 'linux') {
|
||||
return null;
|
||||
}
|
||||
if (process.platform !== 'darwin' && process.platform !== 'win32') return null;
|
||||
try {
|
||||
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 INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24;
|
||||
const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json';
|
||||
const LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS = 30_000;
|
||||
const OPENCODE_SHUTDOWN_GRACE_MS = 100;
|
||||
const { autoUpdater } = updaterPkg;
|
||||
|
||||
@@ -244,7 +260,7 @@ const readDesktopKeepAwakeStatus = () => {
|
||||
};
|
||||
|
||||
const readDesktopMinimizeToTrayStatus = () => {
|
||||
const supported = process.platform === 'win32';
|
||||
const supported = process.platform === 'win32' || process.platform === 'linux';
|
||||
return {
|
||||
supported,
|
||||
enabled: supported && readSettingsRoot().desktopMinimizeToTrayEnabled === true,
|
||||
@@ -252,7 +268,7 @@ const readDesktopMinimizeToTrayStatus = () => {
|
||||
};
|
||||
|
||||
const shouldHideMainWindowToTray = (browserWindow) => {
|
||||
if (process.platform !== 'win32') return false;
|
||||
if (process.platform !== 'win32' && process.platform !== 'linux') return false;
|
||||
if (!state.trayController) return false;
|
||||
if (!browserWindow || browserWindow.isDestroyed()) return false;
|
||||
if (browserWindow.__ocMiniChat === true) return false;
|
||||
@@ -1540,6 +1556,18 @@ const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken
|
||||
].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 availability = { localAvailable };
|
||||
if (envTargetUrl) {
|
||||
@@ -2400,6 +2428,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol === 'devtools:') return true;
|
||||
if (url.protocol === `${UI_PROTOCOL}:`) return true;
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
if (state.localOrigin) {
|
||||
try {
|
||||
@@ -2447,8 +2476,11 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
});
|
||||
|
||||
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) {
|
||||
browserWindow.__ocInitScript = initScript;
|
||||
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -2499,6 +2531,7 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig =
|
||||
rendererRuntimeConfig.clientToken,
|
||||
rendererRuntimeConfig.requestHeaders,
|
||||
);
|
||||
syncInitScriptToWindows(state.initScript);
|
||||
|
||||
const mainWindow = state.mainWindow;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
@@ -2722,8 +2755,9 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
|
||||
void shell.openExternal(url).catch(() => {});
|
||||
});
|
||||
browserWindow.webContents.on('dom-ready', () => {
|
||||
const initScript = browserWindow.__ocInitScript || state.initScript;
|
||||
const initScript = state.initScript || browserWindow.__ocInitScript;
|
||||
if (initScript) {
|
||||
browserWindow.__ocInitScript = initScript;
|
||||
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -3007,6 +3041,74 @@ const buildInstalledApps = async (apps) => {
|
||||
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 sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
|
||||
if (!fs.existsSync(sshConfigPath)) return [];
|
||||
@@ -3488,12 +3590,23 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
return APP_VERSION;
|
||||
|
||||
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 };
|
||||
const settings = app.getLoginItemSettings(getLoginItemOptions());
|
||||
return { supported: true, enabled: settings.openAtLogin === true };
|
||||
}
|
||||
|
||||
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 };
|
||||
const enabled = args.enabled === true;
|
||||
const settingsArgs = {
|
||||
@@ -3512,7 +3625,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
}
|
||||
|
||||
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;
|
||||
await mutateSettingsRoot((root) => {
|
||||
root.desktopMinimizeToTrayEnabled = enabled;
|
||||
@@ -3690,13 +3803,19 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
case 'desktop_open_path': {
|
||||
const targetPath = typeof args.path === 'string' ? args.path.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') {
|
||||
const openArgs = appName ? ['-a', appName, targetPath] : [targetPath];
|
||||
const openArgs = appName ? ['-a', appName, validated.path] : [validated.path];
|
||||
spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3714,18 +3833,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
}
|
||||
|
||||
case 'desktop_reveal_path': {
|
||||
const targetPath = typeof args.path === 'string' ? args.path.trim() : '';
|
||||
if (!targetPath) {
|
||||
throw new Error('Path is required');
|
||||
}
|
||||
|
||||
const stats = await fsp.stat(targetPath).catch(() => null);
|
||||
if (stats?.isDirectory()) {
|
||||
await shell.openPath(targetPath);
|
||||
const validated = await validateLocalPath(typeof args.path === 'string' ? args.path.trim() : '');
|
||||
if (validated.stats.isDirectory()) {
|
||||
const errorMessage = await shell.openPath(validated.path);
|
||||
if (errorMessage) {
|
||||
throw new Error(`Failed to reveal path: ${errorMessage}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
shell.showItemInFolder(targetPath);
|
||||
shell.showItemInFolder(validated.path);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3736,19 +3853,31 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
if (!projectPath || !appId || !appName) {
|
||||
throw new Error('Project path, app id, and app name are required');
|
||||
}
|
||||
const validated = await validateLocalPath(projectPath, 'Project path');
|
||||
if (process.platform === 'win32') {
|
||||
if (appId === 'finder') {
|
||||
const error = await shell.openPath(projectPath);
|
||||
const error = await shell.openPath(validated.path);
|
||||
if (error) throw new Error(error);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3759,14 +3888,26 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
if (!filePath || !appId || !appName) {
|
||||
throw new Error('File path, app id, and app name are required');
|
||||
}
|
||||
const validated = await validateLocalPath(filePath, 'File path');
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3774,8 +3915,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
if (process.platform === 'win32') {
|
||||
return (await buildWindowsInstalledApps(args.apps)).map((app) => app.name);
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return filterLinuxInstalledApps(args.apps);
|
||||
}
|
||||
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 [];
|
||||
const results = await Promise.all(
|
||||
@@ -3799,8 +3943,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
}
|
||||
return results;
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return fetchLinuxAppIcons(Array.isArray(args.apps) ? args.apps : []);
|
||||
}
|
||||
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 results = [];
|
||||
@@ -3825,14 +3972,12 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
const hasCache = Boolean(cache);
|
||||
const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS;
|
||||
const refresh = async () => {
|
||||
const apps = process.platform === 'win32'
|
||||
? await buildWindowsInstalledApps(args.apps)
|
||||
: await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
|
||||
const apps = await buildPlatformInstalledApps(Array.isArray(args.apps) ? args.apps : []);
|
||||
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2));
|
||||
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 };
|
||||
}
|
||||
if (!hasCache || isCacheStale || args.force === true) {
|
||||
@@ -3862,6 +4007,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
localAvailable: Boolean(state.sidecarUrl || state.localOrigin),
|
||||
});
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
@@ -4662,8 +4808,10 @@ const resolveTraySurface = () => {
|
||||
const trayIconAssets = () => {
|
||||
const dir = path.join(resourceRoot(), 'icons', 'tray');
|
||||
const statusDir = path.join(dir, 'status');
|
||||
if (process.platform === 'win32') {
|
||||
const iconPath = getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico');
|
||||
if (process.platform === 'win32' || process.platform === 'linux') {
|
||||
const iconPath = process.platform === 'linux'
|
||||
? (getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.png'))
|
||||
: (getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico'));
|
||||
return {
|
||||
idleIconPath: iconPath,
|
||||
unseenIconPath: iconPath,
|
||||
@@ -4695,7 +4843,7 @@ const trayIconAssets = () => {
|
||||
};
|
||||
|
||||
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;
|
||||
const assets = trayIconAssets();
|
||||
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) {
|
||||
const { localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl();
|
||||
state.localOrigin = localOrigin;
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"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: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",
|
||||
"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",
|
||||
|
||||
@@ -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'
|
||||
&& (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
|
||||
|
||||
@@ -417,12 +421,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const settingsSearchResults = React.useMemo(() => {
|
||||
return buildSettingsSearchResults({
|
||||
query: settingsSearchQuery,
|
||||
runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac, isWindows },
|
||||
runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac, isWindows, isLinux },
|
||||
visiblePageSlugs,
|
||||
t,
|
||||
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 => {
|
||||
if (result.id.startsWith('agents.')) {
|
||||
|
||||
@@ -104,7 +104,7 @@ type DesktopBridgeGlobal = {
|
||||
const isTrayPlatform = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
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 =>
|
||||
|
||||
@@ -661,6 +661,9 @@ export const checkForDesktopUpdates = async (): Promise<UpdateInfo | 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');
|
||||
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.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.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.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.',
|
||||
|
||||
@@ -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.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.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.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.",
|
||||
|
||||
@@ -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.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.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.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.',
|
||||
|
||||
@@ -954,7 +954,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'アプリの再起動が必要です。オフにすると、OpenChamber はメニューバー項目を作成せず、セッション、承認、使用量の更新も実行しません。',
|
||||
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'OpenChamber をシステムトレイへ最小化または閉じる',
|
||||
'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.keepAwake': 'OpenChamber の実行中はコンピューターのスリープを防止',
|
||||
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'スマートフォンがこのアプリを開き続けられるようにします。ディスプレイはオフにできます。',
|
||||
|
||||
@@ -921,7 +921,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '앱을 다시 시작해야 합니다. 끄면 OpenChamber가 메뉴 막대 항목을 만들지 않으며 세션, 승인 및 사용량 업데이트도 실행하지 않습니다.',
|
||||
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'OpenChamber를 시스템 트레이로 최소화하거나 닫기',
|
||||
'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.keepAwake': 'OpenChamber가 실행되는 동안 컴퓨터 절전 방지',
|
||||
'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.minimizeToTrayAria': 'Minimalizuj i zamykaj OpenChamber 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.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ć.',
|
||||
|
||||
@@ -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.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.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.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.",
|
||||
|
||||
@@ -921,7 +921,7 @@ export const settingsDict = {
|
||||
"settings.openchamber.desktopNetwork.field.macMenuBarDescription": "Потребує перезапуску застосунку. Якщо вимкнено, OpenChamber не створює елемент смуги меню та не запускає пов’язані оновлення сесій, запитів на підтвердження й використання.",
|
||||
"settings.openchamber.desktopNetwork.field.minimizeToTrayAria": "Згортати й закривати OpenChamber у системний трей",
|
||||
"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.keepAwake": "Не давати комп’ютеру засинати, поки OpenChamber запущено",
|
||||
"settings.openchamber.desktopNetwork.field.keepAwakeDescription": "Щоб телефон і далі міг відкривати застосунок. Екран усе одно може вимикатися.",
|
||||
|
||||
@@ -921,7 +921,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '需要重启应用。关闭后,OpenChamber 不会创建菜单栏项目,也不会运行相关的会话、审批和用量更新。',
|
||||
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': '将 OpenChamber 最小化和关闭到系统托盘',
|
||||
'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.keepAwake': 'OpenChamber 运行时保持电脑唤醒',
|
||||
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '让手机可以继续打开此应用。屏幕仍可关闭。',
|
||||
|
||||
@@ -1933,7 +1933,7 @@
|
||||
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': '需要重新啟動應用程式。關閉後,OpenChamber 不會建立選單列項目,也不會執行相關的工作階段、核准與用量更新。',
|
||||
'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': '將 OpenChamber 最小化和關閉到系統匣',
|
||||
'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.keepAwake': 'OpenChamber 執行時保持電腦喚醒',
|
||||
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '讓手機可以持續開啟此應用程式。螢幕仍可關閉。',
|
||||
|
||||
@@ -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 getPlatformOpenInApp = (app: OpenInApp): OpenInApp => {
|
||||
if (typeof window !== 'undefined' && window.__OPENCHAMBER_PLATFORM__ === 'win32') {
|
||||
if (app.id === 'finder') {
|
||||
if (typeof window === 'undefined') {
|
||||
return app;
|
||||
}
|
||||
|
||||
const platform = window.__OPENCHAMBER_PLATFORM__;
|
||||
if (app.id === 'finder') {
|
||||
if (platform === 'win32') {
|
||||
return { ...app, label: 'Explorer', appName: 'File Explorer' };
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return { ...app, label: 'File Manager' };
|
||||
}
|
||||
}
|
||||
return app;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,8 @@ interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext {
|
||||
isMac: boolean;
|
||||
// Windows desktop shell — for controls that only render on win32.
|
||||
isWindows: boolean;
|
||||
// Linux desktop shell — for controls that only render on linux.
|
||||
isLinux: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
@@ -385,7 +387,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
page: 'general',
|
||||
titleKey: 'settings.openchamber.desktopNetwork.field.launchAtLogin',
|
||||
descriptionKey: 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription',
|
||||
keywords: ['desktop', 'startup', 'login'],
|
||||
keywords: ['desktop', 'startup', 'login', 'launch', 'background', 'autostart'],
|
||||
isAvailable: (ctx) => ctx.isDesktopLocalOrigin,
|
||||
},
|
||||
{
|
||||
@@ -409,8 +411,8 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
page: 'general',
|
||||
titleKey: 'settings.openchamber.desktopNetwork.field.minimizeToTray',
|
||||
descriptionKey: 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription',
|
||||
keywords: ['desktop', 'tray', 'system tray', 'minimize', 'close', 'background', 'windows'],
|
||||
isAvailable: (ctx) => ctx.isDesktopLocalOrigin && ctx.isWindows,
|
||||
keywords: ['desktop', 'tray', 'system tray', 'minimize', 'close', 'background', 'windows', 'linux'],
|
||||
isAvailable: (ctx) => ctx.isDesktopLocalOrigin && (ctx.isWindows || ctx.isLinux),
|
||||
},
|
||||
{
|
||||
id: 'sessions.desktop-keep-awake',
|
||||
|
||||
Reference in New Issue
Block a user