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
4 changes: 2 additions & 2 deletions app/src/assets/design/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
--hover: color-mix(in srgb, var(--surface) 86%, var(--text));
--border: color-mix(in srgb, var(--surface) 82%, var(--text));
--muted: color-mix(in srgb, var(--text) 68%, var(--surface));
/* 50% keeps faint hints at 4.9:1 on the surface, the WCAG AA floor for normal text. */
--faint: color-mix(in srgb, var(--text) 50%, var(--surface));
/* 62% keeps faint hints above 4.5:1 on the surface (WCAG AA) in both the dark Joy greys and the Light theme. */
--faint: color-mix(in srgb, var(--text) 62%, var(--surface));
--input: color-mix(in srgb, var(--bg) 96%, var(--text));
--danger: #ff6b5e;

Expand Down
3 changes: 2 additions & 1 deletion app/src/assets/favorites.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
<title>Favoritos</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="logo.png">
<link rel="stylesheet" href="design/tokens.css">
<link rel="stylesheet" href="list.css">
</head>
<body>
<main>
<h1 data-i18n="home.favorites">Favoritos</h1>
<p class="lead">Use o botão ★ (ou <kbd data-i18n="shortcut.favorite">Ctrl+D</kbd>, ou segure <kbd>LB</kbd> e aperte <kbd>Y</kbd>) em qualquer página. Os favoritos também aparecem na tela inicial.</p>
<div class="bar"><input id="q" placeholder="Filtrar favoritos" data-i18n-aria-label="a11y.searchFavorites" aria-label="Pesquisar nos favoritos" autofocus autocomplete="off"></div>
<div class="bar"><input id="q" type="search" placeholder="Filtrar favoritos" data-i18n-aria-label="a11y.searchFavorites" aria-label="Pesquisar nos favoritos" autofocus autocomplete="off"></div>
<div id="list" data-i18n-aria-label="home.favorites" aria-label="Favoritos"></div>
</main>
<script src="i18n.js"></script>
Expand Down
3 changes: 2 additions & 1 deletion app/src/assets/history.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
<title>Histórico</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="logo.png">
<link rel="stylesheet" href="design/tokens.css">
<link rel="stylesheet" href="list.css">
</head>
<body>
<main>
<h1 data-i18n="home.history">Histórico</h1>
<p class="lead">Páginas visitadas em abas normais. Abas privadas e páginas do próprio JoyChromium nunca são gravadas.</p>
<div class="bar"><input id="q" placeholder="Pesquisar no histórico" data-i18n-aria-label="a11y.searchHistory" aria-label="Pesquisar no histórico" autofocus autocomplete="off"><button class="ghost" id="clear">Limpar tudo</button></div>
<div class="bar"><input id="q" type="search" placeholder="Pesquisar no histórico" data-i18n-aria-label="a11y.searchHistory" aria-label="Pesquisar no histórico" autofocus autocomplete="off"><button class="ghost" id="clear">Limpar tudo</button></div>
<div id="list" data-i18n-aria-label="home.history" aria-label="Histórico"></div>
</main>
<script src="i18n.js"></script>
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/atomicFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ export function writeFileAtomic(path: string, data: string): void {
renameSync(temporary, path);
}

/** Parses JSON from `path`; any read or parse failure is reported and returns undefined instead of throwing. */
/**
* Parses JSON from `path`; any read or parse failure is reported and returns undefined instead of throwing. A byte
* order mark is dropped first: Windows tools (Notepad, PowerShell 5) write one, and JSON.parse refuses it, which would
* quietly reset every setting to its default.
*/
export function readJsonFile(path: string, log: (message: string) => void): unknown {
if (!existsSync(path)) return undefined;
try {
return JSON.parse(readFileSync(path, "utf8"));
return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, ""));
} catch (error) {
log(`Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/services/commandRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export class CommandRouter {
break;
case "cycle-input-mode": h.cycleInputMode(); break;
case "media": await mediaControl(wc, command.action); break;
case "chrome-height": h.setLayout(command.height, 0); break;
case "chrome-height": h.setLayout(command.height, command.bottom ?? 0); break;
case "page-cover": h.setPageCovered(command.covered); break;
case "ui-settings": {
const patch: Partial<AppSettings> = {};
if (command.uiScale !== undefined) patch.uiScale = command.uiScale;
Expand Down
7 changes: 4 additions & 3 deletions app/src/main/services/layout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Tab } from "../tabs";

/** Where the tab views go: below the chrome, above the status line. */
/** Where the tab views go: below the chrome, above the controller keyboard while it is open. */
export interface LayoutInsets {
top: number;
bottom: number;
Expand All @@ -16,7 +16,8 @@ export interface ViewBounds {
/** Bounds for the tab views, or undefined when the window has no size yet. Pure so it can be unit tested. */
export function tabViewBounds(size: { width: number; height: number }, insets: LayoutInsets, chromeVisible: boolean): ViewBounds {
const top = chromeVisible ? insets.top : 0;
const bottom = chromeVisible ? insets.bottom : 0;
// The keyboard sits over the page whether or not the chrome is showing, so its room is kept either way.
const bottom = insets.bottom;
return { x: 0, y: top, width: size.width, height: Math.max(0, size.height - top - bottom) };
}

Expand All @@ -28,7 +29,7 @@ export interface LayoutHost {

/** Keeps the tab views under the chrome and in step with fullscreen, page fullscreen and TV/window mode. */
export class WindowLayout {
insets: LayoutInsets = { top: 90, bottom: 26 };
insets: LayoutInsets = { top: 90, bottom: 0 };

constructor(private readonly host: LayoutHost) {}

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 @@ -34,6 +34,8 @@ export interface ShellHost {
broadcast(): void;
syncViews(): void;
setLayout(top: number, bottom: number): void;
/** Hides the active tab's view under a modal overlay, which the shell draws over a snapshot of it. */
setPageCovered(covered: boolean): void;
setChromeVisible(visible: boolean): void;
/** Minimize even from TV mode, where a fullscreen window cannot be minimized as it is (E4-03). */
minimizeWindow(): void;
Expand Down
32 changes: 27 additions & 5 deletions app/src/main/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ export class Shell {
readonly tabs: TabManager;
private readonly layout: WindowLayout;
chromeVisible = true;
private pageCovered = false;
inputMode: InputMode = "Spatial";
keyboard: ShellState["keyboard"];
find: ShellState["find"];
suggestions: ShellState["suggestions"] = [];
private statusText = "STARTING · TV IDENTITY ON";
private statusText = "";
controllerConnected = false;
controllerFamily: ShellState["controllerFamily"] = "generic";
readonly permissions = new PermissionQueue();
Expand Down Expand Up @@ -266,8 +267,8 @@ export class Shell {

private wireWindow(): void {
this.window.on("resize", () => this.layout.apply());
this.window.on("maximize", () => this.layout.apply());
this.window.on("unmaximize", () => this.layout.apply());
this.window.on("maximize", () => { this.layout.apply(); this.broadcast(); });
this.window.on("unmaximize", () => { this.layout.apply(); this.broadcast(); });
const onActivity = (): void => this.broadcastActivity();
this.window.on("minimize", onActivity);
this.window.on("show", onActivity);
Expand Down Expand Up @@ -302,7 +303,7 @@ export class Shell {
this.attachShortcuts(tab.webContents);
}
if (!contentView.children.includes(tab.view)) contentView.addChildView(tab.view);
tab.view.setVisible(tab === this.tabs.active);
tab.view.setVisible(tab === this.tabs.active && !this.pageCovered);
}
for (const child of contentView.children) {
if (child instanceof Object && !this.tabs.tabs.some((t) => !t.sleeping && t.view === child)) contentView.removeChildView(child);
Expand All @@ -317,6 +318,16 @@ export class Shell {
this.layout.setInsets(top, bottom);
}

/**
* The tab views are native views stacked above the shell's own page, so nothing the shell draws can cover them. A
* modal overlay therefore paints a snapshot of the page and asks the live view to step aside until it closes.
*/
setPageCovered(covered: boolean): void {
if (this.pageCovered === covered) return;
this.pageCovered = covered;
this.syncViews();
}

setChromeVisible(visible: boolean): void {
if (!visible) clearTimeout(this.chromeHideTimer);
this.layout.setChromeVisible(visible, () => this.deps.store.current.windowMode === "tv");
Expand Down Expand Up @@ -422,6 +433,7 @@ export class Shell {
suggestions: this.suggestions,
shortcutMod: currentShortcutMod(),
nativeWindowControls: nativeWindowControls(),
maximized: this.window.isMaximized(),
windowMode: s.windowMode,
uiScale: s.uiScale,
overscan: s.overscan,
Expand Down Expand Up @@ -485,6 +497,16 @@ export class Shell {
}));
return shots;
});
ipcMain.handle(Channels.shellSnapshot, async (event): Promise<string> => {
const tab = this.tabs.active;
if (event.sender !== this.window.webContents || !tab || tab.sleeping) return "";
try {
const image = await tab.webContents.capturePage();
return image.isEmpty() ? "" : `data:image/jpeg;base64,${image.toJPEG(88).toString("base64")}`;
} catch {
return "";
}
});
ipcMain.handle(Channels.shellPreferences, (event): ShellPreferences => {
if (event.sender !== this.window.webContents) return { vibration: false };
return { vibration: this.deps.store.current.vibration };
Expand Down Expand Up @@ -605,7 +627,7 @@ export class Shell {

async handle(command: ShellCommand): Promise<void> {
// The bar comes back for anything the user asked for, except the renderer reporting its own height.
if (command.type !== "chrome-height") this.revealChrome();
if (command.type !== "chrome-height" && command.type !== "page-cover") this.revealChrome();
await this.router.run(command);
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/preload/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ contextBridge.exposeInMainWorld("shell", {
thumbnails(): Promise<{ id: number; dataUrl: string }[]> {
return ipcRenderer.invoke(Channels.shellThumbnails);
},
snapshot(): Promise<string> {
return ipcRenderer.invoke(Channels.shellSnapshot);
},
onActivity(callback: (activity: ShellActivity) => void): () => void {
const listener = (_event: unknown, activity: ShellActivity): void => callback(activity);
ipcRenderer.on(Channels.shellActivity, listener);
Expand Down
67 changes: 54 additions & 13 deletions app/src/renderer/shell/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { t } from "../../shared/i18n";
import { isInternal } from "../../shared/pages";
import { bridge, cmd } from "./bridge";
import {
BackIcon, CaptionCloseIcon, CaptionMaximizeIcon, CaptionMinimizeIcon, CloseIcon, ForwardIcon, GlobeIcon, FullscreenIcon, GamepadIcon, HomeIcon, MenuIcon, PlusIcon, ReloadIcon,
BackIcon, CaptionCloseIcon, CaptionMaximizeIcon, CaptionMinimizeIcon, CaptionRestoreIcon, CloseIcon, ForwardIcon, GlobeIcon, FullscreenIcon, GamepadIcon, HomeIcon, MenuIcon, PlusIcon, ReloadIcon,
SearchIcon, SettingsIcon, ShieldIcon, StarIcon, TabsIcon, TvIcon, VolumeIcon,
} from "./icons";
import { Keyboard } from "./keyboard";
Expand All @@ -21,7 +21,7 @@ import { vibrate } from "./haptics";
const Empty: ShellState = {
tabs: [], activeUrl: "", isFavorite: false, inputMode: "Spatial", adBlock: false, controllerConnected: false,
chromeVisible: true, theme: { accent: "#2B95FF", background: "#1C1C1C", surface: "#2B2B2B", text: "#F3F3F3" },
status: "", suggestions: [], shortcutMod: "Ctrl", nativeWindowControls: false, windowMode: "tv",
status: "", suggestions: [], shortcutMod: "Ctrl", nativeWindowControls: false, maximized: false, windowMode: "tv",
uiScale: 1, overscan: 0, highContrast: false, language: "pt-BR", controllerFamily: "generic",
};

Expand Down Expand Up @@ -59,17 +59,51 @@ export function App() {
root.setProperty("--safe-x", overscan);
}, [state.theme, state.uiScale, state.highContrast, state.windowMode, state.overscan, state.language]);

// The tab views sit below the chrome: report the chrome's real height whenever it changes.
// The tab views sit below the chrome and above the controller keyboard: report both whenever either changes.
const insets = useRef({ top: 0, bottom: 0 });
const [chromeTop, setChromeTop] = useState(0);
const sendInsets = () => cmd({ type: "chrome-height", height: insets.current.top, bottom: insets.current.bottom });
useEffect(() => {
const report = () => {
const top = document.getElementById("chrome")?.getBoundingClientRect().height ?? 0;
bridge().command({ type: "chrome-height", height: Math.round(top) } as never);
insets.current.top = Math.round(document.getElementById("chrome")?.getBoundingClientRect().height ?? 0);
setChromeTop(insets.current.top);
sendInsets();
};
const observer = new ResizeObserver(report);
if (chromeRef.current) observer.observe(chromeRef.current);
report();
return () => observer.disconnect();
}, []);
const keyboardInset = (bottom: number) => {
if (insets.current.bottom === bottom) return;
insets.current.bottom = bottom;
sendInsets();
};

// The page is a native view above this document, so a modal overlay would open behind it. The overlay paints a
// snapshot of the page instead, and only then asks the live view to step aside, so nothing flickers.
const modal = overlay !== "none" || Boolean(state.permission);
const [cover, setCover] = useState("");
const covered = useRef(false);
useEffect(() => {
if (!modal) {
setCover("");
if (covered.current) cmd({ type: "page-cover", covered: false });
covered.current = false;
return;
}
let live = true;
void bridge().snapshot().then((image) => {
if (!live) return;
setCover(image);
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!live) return;
covered.current = true;
cmd({ type: "page-cover", covered: true });
}));
});
return () => { live = false; };
}, [modal]);

useGamepad(state, overlay, setOverlay);

Expand Down Expand Up @@ -97,7 +131,9 @@ export function App() {
{!state.nativeWindowControls && (
<div class="window-controls">
<button class="window-btn" aria-label={tr("nav.minimize")} title={tr("nav.minimize")} onClick={() => cmd({ type: "window", action: "minimize" })}><CaptionMinimizeIcon /></button>
<button class="window-btn" aria-label={tr("nav.maximize")} title={tr("nav.maximize")} onClick={() => cmd({ type: "window", action: "maximize" })}><CaptionMaximizeIcon /></button>
<button class="window-btn" aria-label={tr(state.maximized ? "nav.restore" : "nav.maximize")} title={tr(state.maximized ? "nav.restore" : "nav.maximize")} onClick={() => cmd({ type: "window", action: "maximize" })}>
{state.maximized ? <CaptionRestoreIcon /> : <CaptionMaximizeIcon />}
</button>
<button class="window-btn close" aria-label={tr("nav.close")} title={tr("nav.close")} onClick={() => cmd({ type: "window", action: "close" })}><CaptionCloseIcon /></button>
</div>
)}
Expand All @@ -114,6 +150,7 @@ export function App() {
</button>

<AddressBar url={state.activeUrl} focused={addressFocused} setFocused={setAddressFocused} language={state.language} keyboard={Boolean(state.keyboard?.target === "address")}>
<Toasts status={state.status} />
<button
class={`address-btn ${state.isFavorite ? "on" : ""}`}
aria-label={tr("nav.favorite")} aria-pressed={state.isFavorite}
Expand All @@ -123,6 +160,15 @@ export function App() {
<StarIcon size={18} filled={state.isFavorite} />
</button>
</AddressBar>
{/* What used to be the status line, as toolbar indicators: Edge shows its tracking shield the same way. */}
<div class="status-line">
<span class={`indicator ${state.adBlock ? "on" : ""}`} role="img" aria-label={state.adBlock ? tr("status.adsOn") : tr("status.adsOff")} title={state.adBlock ? tr("status.adsOn") : tr("status.adsOff")}>
<ShieldIcon />
</span>
<span class={`indicator pad ${state.controllerConnected ? "on" : "off"}`} title={state.controllerConnected ? tr("status.padOn") : tr("status.padOff")}>
<GamepadIcon /> <span class="mode">{state.inputMode.toUpperCase()}</span>
</span>
</div>
<button
class="icon-btn" aria-label={tr("nav.menu")} title={`${tr("nav.menu")} · Start`}
aria-haspopup="dialog" aria-expanded={overlay === "menu"} onClick={() => setOverlay("menu")}
Expand All @@ -143,15 +189,10 @@ export function App() {
</div>

{state.permission && <PermissionDialog prompt={state.permission} language={state.language} />}
{state.keyboard && <Keyboard state={state} />}
{cover && <img class="page-cover" src={cover} alt="" style={{ top: `${state.chromeVisible ? chromeTop : 0}px` }} />}
{state.keyboard && <Keyboard state={state} onInset={keyboardInset} />}
{overlay === "tabs" && <TabSwitcher tabs={state.tabs} language={state.language} onClose={() => setOverlay("none")} />}
{overlay === "menu" && <QuickMenu state={state} onClose={() => setOverlay("none")} onTabs={() => setOverlay("tabs")} />}
<Toasts status={state.status} />
<div class="status-line">
<span class={state.adBlock ? "on" : ""}><ShieldIcon /> {state.adBlock ? tr("status.adsOn") : tr("status.adsOff")}</span>
<span class={state.controllerConnected ? "on" : ""}><GamepadIcon /> {state.controllerConnected ? tr("status.padOn") : tr("status.padOff")}</span>
<span class="mode">{state.inputMode.toUpperCase()}</span>
</div>
</div>
);
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/renderer/shell/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface ShellBridge {
/** Whether the window is on screen, so idle loops park while it is minimized. Returns its own unsubscribe. */
onActivity(callback: (activity: ShellActivity) => void): () => void;
thumbnails(): Promise<{ id: number; dataUrl: string }[]>;
/** The active page as an image, painted under a modal overlay while the live view is hidden. */
snapshot(): Promise<string>;
preferences(): Promise<ShellPreferences>;
setUi(patch: { uiScale?: number; overscan?: number; highContrast?: boolean; language?: string }): void;
}
Expand Down
16 changes: 16 additions & 0 deletions app/src/renderer/shell/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,20 @@ export const GlobeIcon = ({ size = 16, class: c }: Props) => (
const caption = { width: 10, height: 10, viewBox: "0 0 10 10", fill: "none", stroke: "currentColor", "stroke-width": 1, "aria-hidden": true };
export const CaptionMinimizeIcon = () => <svg {...caption}><path d="M0 5.5h10" /></svg>;
export const CaptionMaximizeIcon = () => <svg {...caption}><rect x="0.5" y="0.5" width="9" height="9" rx="1" /></svg>;
export const CaptionRestoreIcon = () => <svg {...caption}><rect x="0.5" y="2.5" width="7" height="7" rx="1" /><path d="M2.5 2.5V0.5h7v7h-2" /></svg>;
export const CaptionCloseIcon = () => <svg {...caption}><path d="M0.5 0.5l9 9M9.5 0.5l-9 9" /></svg>;
export const PlayPauseIcon = ({ size = 22, class: c }: Props) => (
<svg {...base(size)} class={c}><path d="M4 5v14l9-7z" /><path d="M16 5v14M20 5v14" /></svg>
);
export const SkipBackIcon = ({ size = 22, class: c }: Props) => (
<svg {...base(size)} class={c}><path d="M19 20 9 12l10-8z" /><path d="M5 19V5" /></svg>
);
export const SkipForwardIcon = ({ size = 22, class: c }: Props) => (
<svg {...base(size)} class={c}><path d="m5 4 10 8-10 8z" /><path d="M19 5v14" /></svg>
);
export const ZoomInIcon = ({ size = 22, class: c }: Props) => (
<svg {...base(size)} class={c}><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5M11 8v6M8 11h6" /></svg>
);
export const ZoomOutIcon = ({ size = 22, class: c }: Props) => (
<svg {...base(size)} class={c}><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5M8 11h6" /></svg>
);
Loading
Loading