Browser pairing, notifications, element hiding; drop the top bar

Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1
against its Keychain key, then AES-128-CBC) and injects the result into
WKHTTPCookieStore. Only the configured apps' hosts and their sign-in
hosts survive the filter. Browsers are offered most-recently-used first,
since the first entry becomes the default and someone with four
Chromium browsers installed wants the one they actually browse in.

WKWebView defines window.Notification but it does nothing: constructing
one throws no error and shows no banner, so a page believes it notified
you. Measured on the machine as `api=function shim=no` before the shim
was made unconditional; `from page: Odoo - Test notification -> raised`
after.

Anything on a page can be right-clicked away. The rule is re-asserted on
every navigation, because the injected script only carries a snapshot
from when the view was built and a selector added since would otherwise
come back on reload.

The top bar is gone. Navigation lives beside the cog, the nav carries
the traffic lights, and two-finger swipe goes back and forward.

The seed is now the real app list, scoped to exact hosts so a Drive link
inside Gmail switches rather than being swallowed.
This commit is contained in:
2026-09-01 12:17:31 +02:00
parent e369c82774
commit ff4a0c6bc4
44 changed files with 2492 additions and 419 deletions
+234 -99
View File
@@ -1,49 +1,53 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import * as api from "../api";
import type { Config, Group, WorkApp } from "../types";
import type { Theme } from "../hooks/useAppearance";
import type { Browser, Config, Group, PairResult, WorkApp } from "../types";
import { Trash } from "./icons";
import {
BTN,
BTN_PRIMARY,
Badge,
Dialog,
Favicon,
HELP,
ICON_CHROME,
INPUT,
LABEL,
SUBPANEL,
SectionHeading,
Segmented,
SUBPANEL,
Spinner,
} from "./ui";
interface Props {
config: Config;
/** The app a test notification is fired from. */
activeId: string | null;
theme: Theme;
/** Opens straight onto the hidden-elements section when set. */
focusHiddenFor?: string | null;
onConfig: (c: Config) => void;
onTheme: (t: Theme) => void;
onClose: () => void;
}
function TrashIcon() {
return (
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
);
}
export default function Settings({ config, theme, onConfig, onTheme, onClose }: Props) {
export default function Settings({
config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose,
}: Props) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [groupId, setGroupId] = useState<string>("");
const [groupId, setGroupId] = useState("");
const [groupName, setGroupName] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
const [browsers, setBrowsers] = useState<Browser[]>([]);
const [browser, setBrowser] = useState("");
const [pairing, setPairing] = useState(false);
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
const [paired, setPaired] = useState<PairResult | null>(null);
const groups = [...config.groups].sort((a, b) => a.order - b.order);
// Same order the nav shows, so the two lists never disagree about position.
@@ -53,6 +57,18 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
(a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order,
);
useEffect(() => {
api.listBrowsers().then((b) => {
setBrowsers(b);
setBrowser(config.settings.pairedBrowser ?? b[0]?.id ?? "");
});
}, [config.settings.pairedBrowser]);
useEffect(() => {
if (!focusHiddenFor) return;
document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" });
}, [focusHiddenFor]);
const run = async (fn: () => Promise<Config>) => {
try {
onConfig(await fn());
@@ -78,6 +94,23 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
run(() => api.updateApp({ ...app, ...fields }));
const pair = async () => {
if (!browser) return;
setPairing(true);
setPaired(null);
try {
setPaired(await api.pairBrowser(browser));
onConfig(await api.getConfig());
setError(null);
} catch (e) {
setError(String(e));
} finally {
setPairing(false);
}
};
const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
return (
<>
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
@@ -92,87 +125,68 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
<section className="space-y-2">
<SectionHeading>Apps</SectionHeading>
<div className="space-y-1.5">
{config.apps.length === 0 && (
<p className={HELP}>Nothing yet. Add the first one below.</p>
)}
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
{orderedApps.map((app) => (
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<Favicon url={app.url} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
>
<Favicon url={app.url} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
</button>
</div>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>Name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Odoo"
className={`${INPUT} w-full`}
/>
<input value={name} onChange={(e) => setName(e.target.value)}
placeholder="Odoo" className={`${INPUT} w-full`} />
</label>
<label className="flex-[1.4] space-y-1">
<span className={LABEL}>URL</span>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
<input value={url} onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addApp()}
placeholder="example.com"
className={`${INPUT} w-full font-mono text-[11px]`}
/>
placeholder="example.com" className={`${INPUT} w-full font-mono text-[11px]`} />
</label>
<label className="w-[110px] space-y-1">
<span className={LABEL}>Group</span>
<select
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}
>
<select value={groupId} onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</label>
<button onClick={addApp} className={BTN_PRIMARY}>
Add
</button>
<button onClick={addApp} className={BTN_PRIMARY}>Add</button>
</div>
<p className={HELP}>
An app owns its URL's host. Links to any other app on this list switch to it;
@@ -180,21 +194,150 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
</p>
</section>
{/* --------------------------------------------------- groups */}
{/* ------------------------------------------- hidden elements */}
<section id="hidden-elements" className="space-y-2">
<SectionHeading>Hidden elements</SectionHeading>
{withHidden.length === 0 ? (
<p className={HELP}>
Nothing hidden. Right-click anything in an app and choose <em>Hide this
element</em> or use the eye button in the nav to pick one.
</p>
) : (
<div className="space-y-3">
{withHidden.map((app) => (
<div key={app.id} className={SUBPANEL}>
<div className="mb-2 flex items-center gap-2">
<Favicon url={app.url} name={app.name} />
<span className="text-[12px] font-medium">{app.name}</span>
<Badge tone="accent">{app.hidden.length}</Badge>
</div>
<div className="space-y-1">
{app.hidden.map((sel, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={sel}
onChange={(e) => {
const next = [...app.hidden];
next[i] = e.target.value;
run(() => api.setHidden(app.id, next));
}}
title="Edit the selector to widen or narrow what it hides"
className={`${INPUT} h-[26px] w-full flex-1 font-mono text-[11px]`}
/>
<button
onClick={() =>
run(() => api.setHidden(app.id, app.hidden.filter((_, j) => j !== i)))
}
title="Show this again"
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
<p className={HELP}>
Changes apply immediately, without a reload. A selector that stops matching
after the site changes simply hides nothing.
</p>
</section>
{/* -------------------------------------------------- pairing */}
<section className="space-y-2">
<SectionHeading>Browser pairing</SectionHeading>
{browsers.length === 0 ? (
<p className={HELP}>No browser with a readable cookie store was found.</p>
) : (
<>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>Import cookies from</span>
<select value={browser} onChange={(e) => setBrowser(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}>
{browsers.map((b) => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</label>
<button onClick={pair} disabled={pairing || !browser} className={BTN_PRIMARY}>
{pairing ? <Spinner /> : config.settings.lastPairedAt ? "Pair again" : "Pair"}
</button>
</div>
{config.settings.lastPairedAt && !paired && (
<p className={HELP}>Last paired {config.settings.lastPairedAt}.</p>
)}
{paired && (
<div className="space-y-1 rounded-lg bg-sky-500/10 px-3 py-2">
<p className="text-[12px] text-sky-700 dark:text-sky-300">
Imported {paired.imported} cookie{paired.imported === 1 ? "" : "s"} across{" "}
{paired.domains} domain{paired.domains === 1 ? "" : "s"}.
</p>
{paired.domainNames.length > 0 && (
<p className="font-mono text-[11px] leading-relaxed text-slate-600 dark:text-slate-300">
{paired.domainNames.join(" ")}
</p>
)}
{paired.warnings.map((w, i) => (
<p key={i} className="text-[11px] text-slate-600 dark:text-slate-300">{w}</p>
))}
</div>
)}
<p className={HELP}>
Only cookies for the apps above and their sign-in hosts are read nothing
else leaves the browser. macOS asks for Keychain permission the first time.
Google and Microsoft tie sessions to the browser that made them, so those two
may still ask you to sign in once; after that it sticks.
</p>
</>
)}
</section>
{/* --------------------------------------------- notifications */}
<section className="space-y-2">
<SectionHeading>Notifications</SectionHeading>
<div className="flex items-center gap-2">
<button
onClick={() => activeId && api.testNotification(activeId)}
disabled={!activeId}
className={BTN}
>
Send a test notification
</button>
<button
onClick={async () => setNotifyStatus(await api.notificationStatus())}
className={BTN}
>
Check permission
</button>
</div>
{notifyStatus && (
<p className={`${HELP} font-mono`}>{notifyStatus}</p>
)}
<p className={HELP}>
WKWebView defines a notification API that silently does nothing, so it is
replaced with one that forwards to macOS. Notifications raised by a service
worker in the background are not covered only those a page raises while open.
</p>
</section>
{/* ---------------------------------------------------- groups */}
<section className="space-y-2">
<SectionHeading>Groups</SectionHeading>
<div className="space-y-1.5">
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
{groups.map((g: Group) => (
<div
key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<div key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800">
<input
value={g.name}
onChange={(e) =>
run(() => api.updateGroup({ ...g, name: e.target.value }))
}
onChange={(e) => run(() => api.updateGroup({ ...g, name: e.target.value }))}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<span className="shrink-0 font-mono text-[10px] text-slate-400">
@@ -205,7 +348,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
title={`Delete ${g.name} — its apps stay, ungrouped`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
<Trash />
</button>
</div>
))}
@@ -213,17 +356,11 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>New group</span>
<input
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGroup()}
placeholder="Finance"
className={`${INPUT} w-full`}
/>
placeholder="Finance" className={`${INPUT} w-full`} />
</label>
<button onClick={addGroup} className={BTN}>
Add group
</button>
<button onClick={addGroup} className={BTN}>Add group</button>
</div>
</section>
@@ -242,9 +379,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
</section>
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
<button onClick={onClose} className={BTN}>
Done
</button>
<button onClick={onClose} className={BTN}>Done</button>
</div>
</div>
</Dialog>
@@ -262,7 +397,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
>
It disappears from the nav and its view is closed. The session it holds for{" "}
<span className="font-mono text-[12px]">{confirmDelete.url}</span> is kept, so
adding it back does not mean logging in again.
adding it back does not mean signing in again.
</Dialog>
)}
</>