diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d706a73a..aa826eb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -363,7 +363,6 @@ jobs: build-desktop-electron-linux: needs: create-release - if: ${{ false }} strategy: fail-fast: false matrix: @@ -461,7 +460,6 @@ jobs: publish-electron-linux: needs: [create-release, build-desktop-electron-linux] - if: ${{ false }} runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -550,14 +548,14 @@ jobs: secrets: inherit 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 env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }} steps: - name: Verify final Linux release asset inventory - if: ${{ false }} + if: ${{ github.event.inputs.dry_run != 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPOSITORY: ${{ github.repository }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 91483224..46a2667a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [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 - **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. diff --git a/README.md b/README.md index 9c21c487..1964313b 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Linux AppImages need FUSE (`libfuse.so.2`). On Ubuntu/Debian install `libfuse2` 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** Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions. diff --git a/packages/electron/README.md b/packages/electron/README.md index a4db3672..04229790 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -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. diff --git a/packages/electron/linux-app-discovery.mjs b/packages/electron/linux-app-discovery.mjs new file mode 100644 index 00000000..4c8482c3 --- /dev/null +++ b/packages/electron/linux-app-discovery.mjs @@ -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 () => []; diff --git a/packages/electron/linux-autostart.mjs b/packages/electron/linux-autostart.mjs new file mode 100644 index 00000000..8f370fda --- /dev/null +++ b/packages/electron/linux-autostart.mjs @@ -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 }; +}; diff --git a/packages/electron/linux-autostart.test.mjs b/packages/electron/linux-autostart.test.mjs new file mode 100644 index 00000000..66f55b8d --- /dev/null +++ b/packages/electron/linux-autostart.test.mjs @@ -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 }); + } +}); diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 8deec16a..a7c0a20b 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -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; diff --git a/packages/electron/package.json b/packages/electron/package.json index 645323b7..0ecd4e8f 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -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", diff --git a/packages/electron/path-open-utils.mjs b/packages/electron/path-open-utils.mjs new file mode 100644 index 00000000..c83caf8e --- /dev/null +++ b/packages/electron/path-open-utils.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.`; +}; diff --git a/packages/electron/scripts/smoke-linux-app-discovery.mjs b/packages/electron/scripts/smoke-linux-app-discovery.mjs new file mode 100644 index 00000000..dd6fc0a7 --- /dev/null +++ b/packages/electron/scripts/smoke-linux-app-discovery.mjs @@ -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 }); +} diff --git a/packages/electron/scripts/smoke-path-open-utils.mjs b/packages/electron/scripts/smoke-path-open-utils.mjs new file mode 100644 index 00000000..be11cad1 --- /dev/null +++ b/packages/electron/scripts/smoke-path-open-utils.mjs @@ -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 }); +} diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 57ebd16e..90c881ee 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -274,6 +274,10 @@ export const SettingsView: React.FC = ({ 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 = ({ 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.')) { diff --git a/packages/ui/src/hooks/useTraySync.ts b/packages/ui/src/hooks/useTraySync.ts index 93268460..0348f34e 100644 --- a/packages/ui/src/hooks/useTraySync.ts +++ b/packages/ui/src/hooks/useTraySync.ts @@ -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 => diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 2379463c..1c2ab633 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -661,6 +661,9 @@ export const checkForDesktopUpdates = async (): Promise => { 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('desktop_check_for_updates'); return info as UpdateInfo; }; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 6ce2f82e..692c5b8b 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 89baaf06..9e9708ee 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index a6aa451b..7633107f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 478d5fdb..d052e735 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -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': 'スマートフォンがこのアプリを開き続けられるようにします。ディスプレイはオフにできます。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 5066f388..ed71de4d 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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': '휴대폰이 이 앱을 계속 열 수 있도록 합니다. 디스플레이는 꺼질 수 있습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 1699f680..e0e47eab 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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ć.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 3f4b08be..635a98f3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index c590e73a..308718a6 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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": "Щоб телефон і далі міг відкривати застосунок. Екран усе одно може вимикатися.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 0373577b..686dd99b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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': '让手机可以继续打开此应用。屏幕仍可关闭。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 15d61334..9645d467 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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': '讓手機可以持續開啟此應用程式。螢幕仍可關閉。', diff --git a/packages/ui/src/lib/openInApps.ts b/packages/ui/src/lib/openInApps.ts index 84049711..663db849 100644 --- a/packages/ui/src/lib/openInApps.ts +++ b/packages/ui/src/lib/openInApps.ts @@ -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; }; diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index bc8ddb31..92d932d1 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -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',