From b0b3cd39f4ab6f4e466e65fa8db02c6761695c6e Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 14:49:44 -0300 Subject: [PATCH 1/3] feat(integrations): launch flags, joychromium:// links and quick tasks So launchers such as ConsoleMode, Playnite or Steam can open the browser the way they need it: - Command line: --tv (alias --kiosk, --fullscreen) and --window for this run only, --new-tab, and a URL. The first launch used to ignore its argv; a second launch now opens its URL in a new tab instead of replacing the one playing, and applies the mode. - The installer registers joychromium:// with the OS; public internal pages and joychromium://open?url= are accepted, anything else is dropped. macOS links arrive through open-url. - Windows jump list: New tab, TV mode, Window mode. - Quick menu: "Fechar o JoyChromium", so a controller user returns to the launcher without a mouse. - docs/INTEGRATIONS.md: flags, scheme, how to detect the install, which process to watch, and the ConsoleMode setup. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 2 +- app/package.json | 8 ++++ app/src/main/main.ts | 22 +++++++-- app/src/main/platform.ts | 12 +++++ app/src/main/services/commandRouter.ts | 4 +- app/src/main/services/shellHost.ts | 2 + app/src/main/shell.ts | 28 +++++++---- app/src/renderer/shell/quickmenu.tsx | 6 ++- app/src/shared/i18n.ts | 2 + app/src/shared/launchArgs.ts | 46 ++++++++++++++++++ app/src/shared/platform.ts | 4 -- app/tests/e2e/launch.spec.ts | 40 ++++++++++++++++ app/tests/unit/shared.test.ts | 20 ++++++-- docs/INTEGRATIONS.md | 65 ++++++++++++++++++++++++++ 15 files changed, 238 insertions(+), 25 deletions(-) create mode 100644 app/src/shared/launchArgs.ts create mode 100644 app/tests/e2e/launch.spec.ts create mode 100644 docs/INTEGRATIONS.md diff --git a/CLAUDE.md b/CLAUDE.md index fd9ecce..559dde9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,5 +36,5 @@ Navegador controller-first para TV, em Electron (Chromium) + TypeScript. Windows - Sem assinatura de código, por decisão de custo (ver `docs/PRD.md`, ADR-010): no macOS o build recebe assinatura ad-hoc via `scripts/after-pack.mjs` e o updater só avisa da versão nova. Nada no projeto pode depender de conta ou serviço pago — a assinatura VMP do Widevine (EVS) é gratuita e é a única conta do caminho. - `policy/policy.json` é assinado com `node app/scripts/policy-sign.mjs sign policy/policy.json` (chave em `~/.joychromium/policy-signing.key` no Linux e no macOS, `%USERPROFILE%\.joychromium\policy-signing.key` no Windows, sempre fora do repo — procedimento e backup em `docs/POLICY.md`). - Flatpak: `flatpak/dev.lipp.joychromium.yml` (runtime freedesktop 26.08 + `org.electronjs.Electron2.BaseApp`, Electron do `node_modules`) e `.github/workflows/flatpak.yml`, que só constrói o bundle como artefato. Dentro do Flatpak o updater não baixa nada: a tela de atualizações diz que quem atualiza é o Flathub (`FLATPAK_UPDATE_STATUS`). Publicar no Flathub depende de conta e revisão — o que falta está em `flatpak/README.md`. -- Release e instaladores: `docs/RELEASE.md`. Estado atual do produto (cobertura do PRD, provas, pendências por dono): `docs/STATUS.md` — mantenha-o atualizado junto de cada entrega. Roteiro de teste com controle na TV: `docs/CONTROLLER-TEST.md`. Wayland nativo (smoke do CI) e Steam Deck: `docs/WAYLAND.md`. +- Release e instaladores: `docs/RELEASE.md`. Estado atual do produto (cobertura do PRD, provas, pendências por dono): `docs/STATUS.md` — mantenha-o atualizado junto de cada entrega. Roteiro de teste com controle na TV: `docs/CONTROLLER-TEST.md`. Wayland nativo (smoke do CI) e Steam Deck: `docs/WAYLAND.md`. Launchers (flags `--tv`/`--window`/`--new-tab`, esquema `joychromium://`, ConsoleMode): `docs/INTEGRATIONS.md` — o parser é `src/shared/launchArgs.ts`. - Não subir `node_modules/`, `dist/`, `release/`. diff --git a/README.md b/README.md index d542349..2d7bc97 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Nothing here costs money ([PRD](docs/PRD.md), ADR-010): no code-signing certific Step-by-step instructions, including recovery when the system refuses to open the app, are in [docs/INSTALL.md](docs/INSTALL.md). -The current state of the app — what is proven, what is not, and who owes what — is in [docs/STATUS.md](docs/STATUS.md). More documentation: [docs/RELEASE.md](docs/RELEASE.md) (how a release is cut and what each installer does), [docs/POLICY.md](docs/POLICY.md) (the signing key and the remote policy), [docs/CONTROLLER-TEST.md](docs/CONTROLLER-TEST.md) (the on-a-real-TV checklist), [docs/SCREEN-READER.md](docs/SCREEN-READER.md) (the screen-reader pass), [docs/WAYLAND.md](docs/WAYLAND.md) (native Wayland and the Steam Deck recipe) and [docs/PRD.md](docs/PRD.md) (architecture and backlog). +The current state of the app — what is proven, what is not, and who owes what — is in [docs/STATUS.md](docs/STATUS.md). More documentation: [docs/RELEASE.md](docs/RELEASE.md) (how a release is cut and what each installer does), [docs/POLICY.md](docs/POLICY.md) (the signing key and the remote policy), [docs/CONTROLLER-TEST.md](docs/CONTROLLER-TEST.md) (the on-a-real-TV checklist), [docs/SCREEN-READER.md](docs/SCREEN-READER.md) (the screen-reader pass), [docs/WAYLAND.md](docs/WAYLAND.md) (native Wayland and the Steam Deck recipe), [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md) (command-line flags, the `joychromium://` scheme and launchers such as ConsoleMode) and [docs/PRD.md](docs/PRD.md) (architecture and backlog). ## Run from source diff --git a/app/package.json b/app/package.json index f9be82f..da2de9f 100644 --- a/app/package.json +++ b/app/package.json @@ -38,6 +38,14 @@ "build": { "appId": "dev.lipp.joychromium", "productName": "JoyChromium", + "protocols": [ + { + "name": "JoyChromium", + "schemes": [ + "joychromium" + ] + } + ], "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "directories": { "output": "release", diff --git a/app/src/main/main.ts b/app/src/main/main.ts index 7f67757..5263757 100644 --- a/app/src/main/main.ts +++ b/app/src/main/main.ts @@ -16,9 +16,10 @@ import { firstScreenLine } from "../shared/metrics"; import { Pages } from "../shared/pages"; import { effectiveMinimumChromium } from "../shared/policy"; import { dohTemplate, versionAtLeast } from "../shared/security"; -import { urlFromArgv } from "../shared/platform"; +import { t } from "../shared/i18n"; +import { parseLaunchArgs } from "../shared/launchArgs"; import { startupTargets } from "../shared/settings"; -import { installApplicationMenu } from "./platform"; +import { installApplicationMenu, installJumpList } from "./platform"; import { CrashReporting } from "./services/crashReporting"; import { watchWidevine } from "./services/widevine"; import { runInPageWorld } from "./controllerActions"; @@ -186,7 +187,11 @@ async function start(): Promise { const crashed = store.previousRunCrashed(); store.markRunning(); const lastSession = store.loadSession(); - const startUrl = process.env.JOYCHROMIUM_START; + const tr = (key: string) => t(settings.language, key); + installJumpList({ newTab: tr("menu.newTab"), tv: tr("menu.tv"), window: tr("menu.window") }); + const launch = parseLaunchArgs(process.argv); + if (launch.mode) shell.applyWindowMode(launch.mode, false); + const startUrl = process.env.JOYCHROMIUM_START ?? launch.url ?? openedUrl; const targets = startUrl ? [startUrl] : !settings.onboardingCompleted @@ -215,7 +220,16 @@ let shellRef: Shell | undefined; let bootMs = 0; app.on("second-instance", (_event, argv) => { - shellRef?.focusFromSecondInstance(urlFromArgv(argv)); + shellRef?.focusFromSecondInstance(parseLaunchArgs(argv)); +}); + +// macOS hands a joychromium:// link to the app as an event instead of argv, before the shell exists on a cold start. +let openedUrl: string | undefined; +app.on("open-url", (event, url) => { + event.preventDefault(); + const args = parseLaunchArgs([url]); + if (shellRef) shellRef.focusFromSecondInstance(args); + else openedUrl = args.url; }); if (hasInstanceLock) { diff --git a/app/src/main/platform.ts b/app/src/main/platform.ts index 6195539..ae51fbc 100644 --- a/app/src/main/platform.ts +++ b/app/src/main/platform.ts @@ -35,6 +35,18 @@ export function downloadsFolder(): string { return join(app.getPath("downloads"), "JoyChromium"); } +/** + * Right-click on the taskbar icon or the Start menu entry on Windows: one click to a new tab or to the mode a launcher + * would ask for, through the same command-line flags (see shared/launchArgs.ts). + */ +export function installJumpList(labels: { newTab: string; tv: string; window: string }): void { + if (platformId() !== "win32") return; + const task = (title: string, args: string) => ({ + program: process.execPath, arguments: args, title, description: title, iconPath: process.execPath, iconIndex: 0, + }); + app.setUserTasks([task(labels.newTab, "--new-tab"), task(labels.tv, "--tv"), task(labels.window, "--window")]); +} + /** Edit roles are what make Cmd+C/V/X/A and Cmd+Q work on macOS. Other platforms have no app menu. */ export function installApplicationMenu(): void { if (platformId() !== "darwin") { diff --git a/app/src/main/services/commandRouter.ts b/app/src/main/services/commandRouter.ts index 607fb7c..ddadc48 100644 --- a/app/src/main/services/commandRouter.ts +++ b/app/src/main/services/commandRouter.ts @@ -59,7 +59,7 @@ export class CommandRouter { } break; case "fullscreen-toggle": h.setChromeVisible(!h.chromeVisible); break; - case "window-mode": h.applyWindowMode(h.store.current.windowMode === "tv" ? "window" : "tv", true); break; + case "window-mode": h.applyWindowMode(h.windowMode === "tv" ? "window" : "tv", true); break; case "find": h.find = { text: command.text }; if (wc && command.text) wc.findInPage(command.text, { forward: !command.backwards, findNext: true }); @@ -97,7 +97,7 @@ export class CommandRouter { else if (h.keyboard) h.closeKeyboard(); else if (h.find) { h.find = undefined; wc?.stopFindInPage("clearSelection"); h.broadcast(); } else if (wc && !h.chromeVisible) await wc.executeJavaScript("document.exitFullscreen && document.exitFullscreen()", true).catch(() => undefined); - else if (h.store.current.windowMode === "tv") h.applyWindowMode("window", true); + else if (h.windowMode === "tv") h.applyWindowMode("window", true); break; } } diff --git a/app/src/main/services/shellHost.ts b/app/src/main/services/shellHost.ts index 3b0bdb5..ce5d6c8 100644 --- a/app/src/main/services/shellHost.ts +++ b/app/src/main/services/shellHost.ts @@ -40,6 +40,8 @@ export interface ShellHost { /** Minimize even from TV mode, where a fullscreen window cannot be minimized as it is (E4-03). */ minimizeWindow(): void; applyWindowMode(mode: WindowMode, persist: boolean): void; + /** The mode in effect: the one from the command line for this run, else the saved one. */ + readonly windowMode: WindowMode; resolvePermission(allow: boolean, remember: boolean): void; toggleFavorite(): void; navigate(url: string, allowHttp?: boolean): void; diff --git a/app/src/main/shell.ts b/app/src/main/shell.ts index ced93af..5c42331 100644 --- a/app/src/main/shell.ts +++ b/app/src/main/shell.ts @@ -6,6 +6,7 @@ import { diffShellState } from "../shared/statePatch"; import { statusText, type StatusId } from "../shared/status"; import { displayUrl, isInternal, isWebUrl, Pages } from "../shared/pages"; import type { WindowMode } from "../shared/platform"; +import type { LaunchArgs } from "../shared/launchArgs"; import { hostOf, isDangerousDownload, isThirdPartyRequest, allowPopup, safeFileName, trackingPolicy } from "../shared/security"; import { commandForShortcut } from "../shared/shortcuts"; import { userAgentForUrl } from "../shared/streamingUa"; @@ -60,6 +61,8 @@ export class Shell { readonly tabs: TabManager; private readonly layout: WindowLayout; chromeVisible = true; + /** A mode asked for on the command line (`--tv`, `--window`): it holds for this run without touching the setting. */ + private launchMode: WindowMode | undefined; private pageCovered = false; inputMode: InputMode = "Spatial"; keyboard: ShellState["keyboard"]; @@ -118,7 +121,7 @@ export class Shell { async start(targets: readonly string[]): Promise { await this.window.loadURL("joychromium://chrome/"); - this.applyWindowMode(this.deps.store.current.windowMode, false); + this.layout.applyMode(this.windowMode === "tv"); this.window.show(); const first = targets[0] === undefined ? undefined : this.tabs.open(targets[0], { activate: true }); // The screen the user was waiting for: the shell is loaded above and this is the first tab's own document. Reported @@ -330,14 +333,19 @@ export class Shell { setChromeVisible(visible: boolean): void { if (!visible) clearTimeout(this.chromeHideTimer); - this.layout.setChromeVisible(visible, () => this.deps.store.current.windowMode === "tv"); + this.layout.setChromeVisible(visible, () => this.windowMode === "tv"); this.layout.apply(); this.broadcast(); } + get windowMode(): WindowMode { + return this.launchMode ?? this.deps.store.current.windowMode; + } + applyWindowMode(mode: WindowMode, persist: boolean): void { if (persist) this.deps.store.update((s) => ({ ...s, windowMode: mode })); - this.layout.applyMode(this.deps.store.current.windowMode === "tv"); + this.launchMode = persist ? undefined : mode; + this.layout.applyMode(this.windowMode === "tv"); this.broadcast(); } @@ -353,11 +361,15 @@ export class Shell { } /** A second launch focuses this window instead of opening another copy of the profile. */ - focusFromSecondInstance(url?: string): void { + /** A second launch (a launcher, a shortcut, a link from another app) is handed to this instance. */ + focusFromSecondInstance(args: LaunchArgs): void { if (this.window.isMinimized()) this.window.restore(); this.window.show(); this.window.focus(); - if (url) this.navigate(url); + if (args.mode) this.applyWindowMode(args.mode, false); + // A browser opens what another app hands it in a new tab instead of replacing what the user was watching. + if (args.url) this.tabs.open(args.url, { activate: true }); + else if (args.newTab) this.newTab(); } private housekeeping(): void { @@ -434,7 +446,7 @@ export class Shell { shortcutMod: currentShortcutMod(), nativeWindowControls: nativeWindowControls(), maximized: this.window.isMaximized(), - windowMode: s.windowMode, + windowMode: this.windowMode, uiScale: s.uiScale, overscan: s.overscan, highContrast: s.highContrast, @@ -639,11 +651,11 @@ export class Shell { private scheduleChromeHide(): void { clearTimeout(this.chromeHideTimer); - if (this.deps.store.current.windowMode !== "tv") return; + if (this.windowMode !== "tv") return; const tab = this.tabs.active; if (!tab || tab.sleeping || isInternal(tab.url)) return; this.chromeHideTimer = setTimeout(() => { - if (this.deps.store.current.windowMode === "tv" && !this.permissions.prompt && !this.keyboard) this.setChromeVisible(false); + if (this.windowMode === "tv" && !this.permissions.prompt && !this.keyboard) this.setChromeVisible(false); }, 4_000); this.chromeHideTimer.unref?.(); } diff --git a/app/src/renderer/shell/quickmenu.tsx b/app/src/renderer/shell/quickmenu.tsx index 025f852..91ed783 100644 --- a/app/src/renderer/shell/quickmenu.tsx +++ b/app/src/renderer/shell/quickmenu.tsx @@ -3,7 +3,7 @@ import type { ShellState } from "../../shared/ipc"; import { t } from "../../shared/i18n"; import { cmd } from "./bridge"; import { - FullscreenIcon, KeyboardIcon, PictureInPictureIcon, PlayPauseIcon, PlusIcon, SettingsIcon, SkipBackIcon, SkipForwardIcon, TabsIcon, + CloseIcon, FullscreenIcon, KeyboardIcon, PictureInPictureIcon, PlayPauseIcon, PlusIcon, SettingsIcon, SkipBackIcon, SkipForwardIcon, TabsIcon, VolumeIcon, ZoomInIcon, ZoomOutIcon, } from "./icons"; @@ -53,6 +53,10 @@ export function QuickMenu({ state, onClose, onTabs }: { state: ShellState; onClo + {/* Back to whatever launched the browser (ConsoleMode, Steam, the desktop) without reaching for a mouse. */} + ); diff --git a/app/src/shared/i18n.ts b/app/src/shared/i18n.ts index 27002ef..d16eba4 100644 --- a/app/src/shared/i18n.ts +++ b/app/src/shared/i18n.ts @@ -53,6 +53,7 @@ const en: Record = { "menu.zoomIn": "Zoom in", "menu.zoomOut": "Zoom out", "menu.settings": "Settings", + "menu.quit": "Close JoyChromium", "permission.title": "Permission", "permission.wants": "wants to use", "permission.deny": "Deny", @@ -264,6 +265,7 @@ const ptBR: Record = { "menu.zoomIn": "Aumentar zoom", "menu.zoomOut": "Diminuir zoom", "menu.settings": "Configurações", + "menu.quit": "Fechar o JoyChromium", "permission.title": "Permissão", "permission.wants": "quer usar", "permission.deny": "Negar", diff --git a/app/src/shared/launchArgs.ts b/app/src/shared/launchArgs.ts new file mode 100644 index 0000000..c1c2290 --- /dev/null +++ b/app/src/shared/launchArgs.ts @@ -0,0 +1,46 @@ +import { internalPageFile, isWebUrl } from "./pages"; +import type { WindowMode } from "./platform"; + +/** + * What a launcher (ConsoleMode, Playnite, Steam, a desktop shortcut) can ask for on the command line, for the first + * launch and for a second launch handed to the running instance alike: + * + * JoyChromium.exe [--tv | --window] [--new-tab] [https://… | joychromium://…] + * + * `--tv` (alias `--kiosk`, `--fullscreen`) and `--window` set the mode for this run only; the saved preference stays. + * A URL opens in a new tab; `--new-tab` opens the new tab page. The OS hands `joychromium://` links here too, once the + * installer registered the scheme: an internal page (`joychromium://history`) or `joychromium://open?url=`, + * which lets a launcher that only takes URIs open a site. + */ +export interface LaunchArgs { + url?: string; + mode?: WindowMode; + newTab: boolean; +} + +const TV_FLAGS = ["--tv", "--kiosk", "--fullscreen"]; + +/** A web address, a public internal page, or the site inside an open link. Anything else another app hands us is ignored. */ +export function launchUrl(arg: string): string | undefined { + if (isWebUrl(arg)) return arg; + if (internalPageFile(arg, false)) return arg; + if (!/^joychromium:\/\/open\b/i.test(arg)) return undefined; + try { + const target = new URL(arg).searchParams.get("url") ?? ""; + return isWebUrl(target) ? target : undefined; + } catch { + return undefined; + } +} + +export function parseLaunchArgs(argv: readonly string[]): LaunchArgs { + const flags = argv.map((arg) => arg.toLowerCase()); + const tv = flags.some((arg) => TV_FLAGS.includes(arg)); + const window = flags.includes("--window"); + return { + url: argv.map(launchUrl).find((url) => url !== undefined), + // Both flags at once is a launcher's mistake: keep the saved mode rather than guess. + mode: tv === window ? undefined : tv ? "tv" : "window", + newTab: flags.includes("--new-tab"), + }; +} diff --git a/app/src/shared/platform.ts b/app/src/shared/platform.ts index f2526c2..80c14c5 100644 --- a/app/src/shared/platform.ts +++ b/app/src/shared/platform.ts @@ -37,7 +37,3 @@ export function updateInstaller(platform: string, flatpakId: string | undefined) return platform === "linux" && flatpakId ? "flathub" : "app"; } -/** A URL handed to a second instance, if the argv contains one. */ -export function urlFromArgv(argv: readonly string[]): string | undefined { - return argv.find((arg) => /^https?:\/\//i.test(arg) || arg.startsWith("joychromium://")); -} diff --git a/app/tests/e2e/launch.spec.ts b/app/tests/e2e/launch.spec.ts new file mode 100644 index 0000000..fa0f75b --- /dev/null +++ b/app/tests/e2e/launch.spec.ts @@ -0,0 +1,40 @@ +import { _electron as electron, expect, test } from "@playwright/test"; +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import electronPath from "electron"; + +// What a launcher such as ConsoleMode does: start the browser with flags and a URL, then launch it again while it runs. +test("command-line mode and URL, and a second launch handed to the running app", async () => { + const data = mkdtempSync(join(tmpdir(), "joychromium-launch-")); + writeFileSync(join(data, "settings.json"), JSON.stringify({ onboardingCompleted: true, startup: "NewTab", language: "en", windowMode: "tv" })); + const env = { ...process.env, JOYCHROMIUM_DATA: data }; + delete env.ELECTRON_RUN_AS_NODE; + const app = await electron.launch({ args: [".", "--window", "joychromium://history"], env }); + try { + const tabs = () => app.evaluate(() => globalThis.joy.tabs()); + const active = async () => (await tabs()).find((t) => t.isActive)?.url; + const fullScreen = () => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFullScreen()); + + // --window wins over the saved TV mode for this run, and the URL is the first tab. + await expect.poll(active, { timeout: 30_000 }).toBe("joychromium://history"); + expect(await fullScreen()).toBe(false); + // The flag is for this run: the saved preference is still TV. + expect(JSON.parse(readFileSync(join(data, "settings.json"), "utf8")).windowMode).toBe("tv"); + + // The CI has no network: the site the second launch opens is served by the test. + await app.evaluate(({ protocol }) => { + protocol.handle("https", () => new Response("Launched", { headers: { "content-type": "text/html" } })); + }); + // A second launch exits at once and hands its flags and URL to this instance, which opens the URL in a new tab. + const second = spawn(electronPath as unknown as string, [".", "--tv", "joychromium://open?url=https%3A%2F%2Fexample.com%2F"], { env, stdio: "ignore" }); + await new Promise((resolve) => second.on("exit", resolve)); + await expect.poll(async () => (await tabs()).length, { timeout: 15_000 }).toBe(2); + expect(await active()).toBe("https://example.com/"); + await expect.poll(fullScreen, { timeout: 15_000 }).toBe(true); + } finally { + await app.close().catch(() => undefined); + rmSync(data, { recursive: true, force: true }); + } +}); diff --git a/app/tests/unit/shared.test.ts b/app/tests/unit/shared.test.ts index 472f6e3..1f13aaf 100644 --- a/app/tests/unit/shared.test.ts +++ b/app/tests/unit/shared.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { readJsonFile } from "../../src/main/atomicFile"; +import { launchUrl, parseLaunchArgs } from "../../src/shared/launchArgs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { displayUrl, errorPageFor, internalPageFile, isDevOnlyFile, isInternal, isWebUrl, Pages } from "../../src/shared/pages"; @@ -20,7 +21,7 @@ import { buildSuggestions } from "../../src/shared/suggestion"; import { defaultModeFor, directionOf, nextMode, stickToScroll, stickToVelocity } from "../../src/shared/controllerInput"; import { activePad, padInUse, padsInUse, startedUsing, type PadSample } from "../../src/shared/controllerSelection"; import { - extraDangerousExtensions, shortcutLabel, shortcutModifier, updateInstaller, urlFromArgv, usesNativeWindowControls, + extraDangerousExtensions, shortcutLabel, shortcutModifier, updateInstaller, usesNativeWindowControls, } from "../../src/shared/platform"; import { diagnosticsLines, formatBootLine, formatMemoryLines, formatTabsLine } from "../../src/shared/diagnostics"; import { History } from "../../src/main/history"; @@ -342,9 +343,20 @@ describe("platform", () => { expect(extraDangerousExtensions("linux")).toContain(".desktop"); expect(extraDangerousExtensions("win32")).toEqual([]); }); - it("finds a URL handed to a second instance", () => { - expect(urlFromArgv(["/app/joychromium", "https://a.example/x"])).toBe("https://a.example/x"); - expect(urlFromArgv(["--flag"])).toBeUndefined(); + it("reads what a launcher asks for on the command line", () => { + expect(parseLaunchArgs(["/app/joychromium", "https://a.example/x"])).toEqual({ url: "https://a.example/x", mode: undefined, newTab: false }); + expect(parseLaunchArgs(["JoyChromium.exe", "--flag"])).toEqual({ url: undefined, mode: undefined, newTab: false }); + expect(parseLaunchArgs(["JoyChromium.exe", "--KIOSK", "joychromium://history"])).toMatchObject({ mode: "tv", url: "joychromium://history" }); + expect(parseLaunchArgs(["JoyChromium.exe", "--window", "--new-tab"])).toMatchObject({ mode: "window", newTab: true }); + // Contradictory flags keep the saved mode. + expect(parseLaunchArgs(["JoyChromium.exe", "--tv", "--window"]).mode).toBeUndefined(); + // Chromium's own switches are not URLs. + expect(parseLaunchArgs(["JoyChromium.exe", "--allow-file-access-from-files", "file:///C:/x.html"]).url).toBeUndefined(); + // What the OS hands over for joychromium:// links: public pages and open?url=, nothing else. + expect(launchUrl("joychromium://open?url=https%3A%2F%2Fwww.youtube.com%2Ftv")).toBe("https://www.youtube.com/tv"); + expect(launchUrl("joychromium://open?url=javascript%3Aalert(1)")).toBeUndefined(); + expect(launchUrl("joychromium://chrome/")).toBeUndefined(); + expect(launchUrl("joychromium://design")).toBeUndefined(); }); it("leaves updates to the host inside the Flatpak sandbox", () => { expect(updateInstaller("linux", "dev.lipp.joychromium")).toBe("flathub"); diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md new file mode 100644 index 0000000..65f3c2f --- /dev/null +++ b/docs/INTEGRATIONS.md @@ -0,0 +1,65 @@ +# Integrações + +Como outro programa abre o JoyChromium — um launcher de console como o [ConsoleMode](https://github.com/lippdev/consolemode), o Playnite, a Steam ou um atalho — e devolve o controle quando ele fecha. + +## Linha de comando + +``` +JoyChromium.exe [--tv | --window] [--new-tab] [URL] +``` + +| Argumento | Efeito | +|---|---| +| `--tv` (ou `--kiosk`, `--fullscreen`) | Abre em tela cheia, no modo TV, **só nesta execução**: a preferência salva não muda. | +| `--window` | Abre em janela, também só nesta execução. | +| `--new-tab` | Abre uma nova aba. | +| `https://…` | Abre o site. | +| `joychromium://history`, `…/favorites`, `…/settings`, `…/newtab` | Abre a página interna. | +| `joychromium://open?url=` | Abre o site. Serve para launchers que só aceitam URI. | + +`--tv` e `--window` juntos são ignorados. Qualquer outro esquema (`file:`, `javascript:`, `joychromium://chrome`) é descartado. + +Código: `app/src/shared/launchArgs.ts` (testado em `tests/unit/shared.test.ts`). O e2e `tests/e2e/launch.spec.ts` faz o caminho de um launcher: abre com flags e uma URL e lança uma segunda vez com o app aberto. + +## Instância única + +O app roda uma instância só. Uma segunda execução (do launcher, de um atalho, de um link) **sai na hora** e entrega os argumentos para a que está aberta, que: + +- restaura e foca a janela; +- aplica `--tv`/`--window`; +- abre a URL numa **aba nova**, sem trocar o que estava tocando (ou abre a nova aba, com `--new-tab`). + +Para quem vigia o processo: o processo da segunda execução morre logo. Quem continua vivo é a instância original. + +## Protocolo `joychromium://` + +O instalador registra o esquema no sistema: `protocols` em `app/package.json`, gravado pelo NSIS no Windows, pelo `Info.plist` no macOS e pelo `.desktop` no Linux. `start joychromium://newtab` (Windows), `open joychromium://newtab` (macOS) ou `xdg-open joychromium://newtab` abre o app ou entrega o link para a instância aberta. No macOS o link chega pelo evento `open-url`, tratado em `app/src/main/main.ts`. + +## Detectar a instalação no Windows + +- **Caminho padrão:** `%LOCALAPPDATA%\Programs\joychromium\JoyChromium.exe` (o NSIS instala por usuário, sem admin). +- **Registro:** `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\1fc20e06-e3c1-52c5-9ba1-fcb355e6851d`. `DisplayName` é `JoyChromium ` e `DisplayIcon` aponta para o `.exe`. `InstallLocation` vem vazio, então use o caminho do `DisplayIcon` (sem o `,0`). A chave é um UUID derivado do `appId` (`dev.lipp.joychromium`) e não muda entre versões. + +## Vigiar o processo e voltar + +- O Electron sobe **vários** `JoyChromium.exe` (principal, GPU, renderers). Só um tem janela: escolha o processo com `MainWindowHandle != 0` em vez do primeiro de `GetProcessesByName`, que pode ser um auxiliar. +- A sessão termina quando esse processo sai. Com o controle, o usuário fecha pelo **menu rápido (Start) → "Fechar o JoyChromium"**. Fechar a janela também fecha o app. +- Código de saída: sempre 0 num fechamento normal. + +## Atalhos rápidos no Windows + +A lista de atalhos do ícone (botão direito na barra de tarefas ou no Menu Iniciar) tem **Nova aba**, **Modo TV** e **Modo janela**. Eles usam as mesmas flags acima. + +## ConsoleMode + +Hoje, sem mudar nada no ConsoleMode, use o modo **Aplicativo personalizado**: + +- **Caminho:** `%LOCALAPPDATA%\Programs\joychromium\JoyChromium.exe` +- **Argumentos:** `--tv` +- **Processo a vigiar:** em branco (`JoyChromium`) + +Para virar alvo de primeira classe no ConsoleMode faltam três coisas, do lado de lá: + +1. Um modo `joychromium` ao lado de `bigPicture`, `xboxMode` e `playnite`. +2. A detecção pela chave de registro acima. +3. A escolha do processo com janela ao mover para a TV. From 3947dda461239270cebee77f55f4114e27687805 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 14:57:24 -0300 Subject: [PATCH 2/3] test(launch): log the second launch and report its output when it fails Co-Authored-By: Claude Opus 5.5 --- app/src/main/main.ts | 1 + app/tests/e2e/launch.spec.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/main.ts b/app/src/main/main.ts index 5263757..113f0f7 100644 --- a/app/src/main/main.ts +++ b/app/src/main/main.ts @@ -220,6 +220,7 @@ let shellRef: Shell | undefined; let bootMs = 0; app.on("second-instance", (_event, argv) => { + log.info(`second launch: ${JSON.stringify(argv)}`); shellRef?.focusFromSecondInstance(parseLaunchArgs(argv)); }); diff --git a/app/tests/e2e/launch.spec.ts b/app/tests/e2e/launch.spec.ts index fa0f75b..3efad0c 100644 --- a/app/tests/e2e/launch.spec.ts +++ b/app/tests/e2e/launch.spec.ts @@ -1,6 +1,6 @@ import { _electron as electron, expect, test } from "@playwright/test"; import { spawn } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import electronPath from "electron"; @@ -28,9 +28,17 @@ test("command-line mode and URL, and a second launch handed to the running app", protocol.handle("https", () => new Response("Launched", { headers: { "content-type": "text/html" } })); }); // A second launch exits at once and hands its flags and URL to this instance, which opens the URL in a new tab. - const second = spawn(electronPath as unknown as string, [".", "--tv", "joychromium://open?url=https%3A%2F%2Fexample.com%2F"], { env, stdio: "ignore" }); - await new Promise((resolve) => second.on("exit", resolve)); - await expect.poll(async () => (await tabs()).length, { timeout: 15_000 }).toBe(2); + const second = spawn(electronPath as unknown as string, [".", "--tv", "joychromium://open?url=https%3A%2F%2Fexample.com%2F"], { env }); + let output = ""; + second.stdout.on("data", (chunk) => (output += chunk)); + second.stderr.on("data", (chunk) => (output += chunk)); + const code = await new Promise((resolve) => second.on("exit", resolve)); + try { + await expect.poll(async () => (await tabs()).length, { timeout: 15_000 }).toBe(2); + } catch (error) { + const logs = readdirSync(join(data, "logs")).map((f) => readFileSync(join(data, "logs", f), "utf8")).join("\n"); + throw new Error(`second launch exited ${code}\n--- output\n${output}\n--- app log\n${logs}\n${error}`); + } expect(await active()).toBe("https://example.com/"); await expect.poll(fullScreen, { timeout: 15_000 }).toBe(true); } finally { From d70db06d709f3baa7611d49bb86b59377a98886f Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 15:05:41 -0300 Subject: [PATCH 3/3] ci(linux): set up the Chromium sandbox helper before the e2e launch.spec.ts spawns a second Electron by hand, and without a root-owned setuid chrome-sandbox it aborts on Linux before it can hand its URL to the running app. The setup used to run only in the later Wayland step. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 934727f..11ae485 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,12 @@ jobs: run: npm run test:e2e - name: End-to-end (real Electron, Linux under Xvfb) if: runner.os == 'Linux' - run: xvfb-run --auto-servernum --server-args="-screen 0 1600x900x24" npm run test:e2e + run: | + # Electron's SUID sandbox needs a root-owned helper; npm unpacks it without the setuid bit. Without it a plain + # second launch (launch.spec.ts) aborts, and so does the Wayland smoke below. + sudo chown root:root node_modules/electron/dist/chrome-sandbox + sudo chmod 4755 node_modules/electron/dist/chrome-sandbox + xvfb-run --auto-servernum --server-args="-screen 0 1600x900x24" npm run test:e2e - name: End-to-end (real Electron, macOS) if: runner.os == 'macOS' run: npm run test:e2e @@ -64,9 +69,6 @@ jobs: sudo apt-get update sudo apt-get install -y weston npm run build - # Electron's SUID sandbox needs a root-owned helper; npm unpacks it without the setuid bit. - sudo chown root:root node_modules/electron/dist/chrome-sandbox - sudo chmod 4755 node_modules/electron/dist/chrome-sandbox node scripts/wayland-smoke.mjs - name: Package (no publish) to prove the installers build run: npm run build && npx electron-builder --publish never