Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <keyfile> 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/`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 19 additions & 4 deletions app/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -186,7 +187,11 @@ async function start(): Promise<void> {
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
Expand Down Expand Up @@ -215,7 +220,17 @@ let shellRef: Shell | undefined;
let bootMs = 0;

app.on("second-instance", (_event, argv) => {
shellRef?.focusFromSecondInstance(urlFromArgv(argv));
log.info(`second launch: ${JSON.stringify(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) {
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/services/commandRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/services/shellHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 20 additions & 8 deletions app/src/main/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -118,7 +121,7 @@ export class Shell {

async start(targets: readonly string[]): Promise<void> {
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
Expand Down Expand Up @@ -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();
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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?.();
}
Expand Down
6 changes: 5 additions & 1 deletion app/src/renderer/shell/quickmenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -53,6 +53,10 @@ export function QuickMenu({ state, onClose, onTabs }: { state: ShellState; onClo
<button class="menu-item" onClick={() => run({ type: "settings" })}>
<SettingsIcon /> {tr("menu.settings")}
</button>
{/* Back to whatever launched the browser (ConsoleMode, Steam, the desktop) without reaching for a mouse. */}
<button class="menu-item" onClick={() => run({ type: "window", action: "close" })}>
<CloseIcon /> {tr("menu.quit")}
</button>
</div>
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions app/src/shared/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const en: Record<string, string> = {
"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",
Expand Down Expand Up @@ -264,6 +265,7 @@ const ptBR: Record<string, string> = {
"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",
Expand Down
46 changes: 46 additions & 0 deletions app/src/shared/launchArgs.ts
Original file line number Diff line number Diff line change
@@ -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=<https 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"),
};
}
4 changes: 0 additions & 4 deletions app/src/shared/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://"));
}
Loading
Loading