The report was that a backgrounded site dies slowly and stops fetching. It did, for three separate reasons, and the first two fixes each traded one failure for another: Hiding inactive views lets WebKit suspend them, so nothing is hidden any more - every view keeps its size and stays in the window, and the active one is ordered on top. Telling a background page it was hidden then made Gmail throttle its own syncing; its unread count sat unchanged for two and a half minutes. That spoof is gone. Neither was enough on its own: a background page keeps its timers but loses the connection its updates arrive on, and WKWebView has no equivalent of Electron's backgroundThrottling. Background apps are now poked every 45s with the events a page uses to catch up after you return to a tab - it fetches without losing a half-written reply. Notifications now come from the unread count in the title rather than the site's notification code, which Gmail will not run while it believes you are looking at it. Measured: Gmail behind Odoo went 155 -> 157 untouched and raised "Gmail / 1 new". Also adds a background-app probe to Settings, which is what turned this from guesswork into measurement, and drops the nav's blocked-element count and the hide-element button.
384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
import * as api from "../api";
|
|
import type { Theme } from "../hooks/useAppearance";
|
|
import type { Config, Group, WorkApp } from "../types";
|
|
import { Trash } from "./icons";
|
|
import {
|
|
BTN,
|
|
BTN_PRIMARY,
|
|
Badge,
|
|
Dialog,
|
|
Favicon,
|
|
HELP,
|
|
ICON_CHROME,
|
|
INPUT,
|
|
LABEL,
|
|
SUBPANEL,
|
|
SectionHeading,
|
|
Segmented,
|
|
} 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;
|
|
}
|
|
|
|
export default function Settings({
|
|
config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose,
|
|
}: Props) {
|
|
const [name, setName] = useState("");
|
|
const [url, setUrl] = useState("");
|
|
const [groupId, setGroupId] = useState("");
|
|
const [groupName, setGroupName] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
|
|
|
|
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
|
|
const [reports, setReports] = useState<[string, string][]>([]);
|
|
|
|
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
|
|
|
// Same order the nav shows, so the two lists never disagree about position.
|
|
const groupRank = (id: string | null) =>
|
|
id === null ? -1 : (groups.find((g) => g.id === id)?.order ?? Number.MAX_SAFE_INTEGER);
|
|
const orderedApps = [...config.apps].sort(
|
|
(a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order,
|
|
);
|
|
|
|
|
|
useEffect(() => {
|
|
if (!focusHiddenFor) return;
|
|
document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" });
|
|
}, [focusHiddenFor]);
|
|
|
|
const run = async (fn: () => Promise<Config>) => {
|
|
try {
|
|
onConfig(await fn());
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
};
|
|
|
|
const addApp = async () => {
|
|
if (!name.trim() || !url.trim()) return;
|
|
await run(() => api.addApp(name, url, groupId || null));
|
|
setName("");
|
|
setUrl("");
|
|
};
|
|
|
|
const addGroup = async () => {
|
|
if (!groupName.trim()) return;
|
|
await run(() => api.addGroup(groupName));
|
|
setGroupName("");
|
|
};
|
|
|
|
const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
|
|
run(() => api.updateApp({ ...app, ...fields }));
|
|
|
|
|
|
const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
|
|
|
|
return (
|
|
<>
|
|
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
|
|
<div className="space-y-6">
|
|
{error && (
|
|
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-[12px] text-red-600 dark:text-red-400">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{/* ----------------------------------------------------- apps */}
|
|
<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>}
|
|
{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"
|
|
>
|
|
<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!`}
|
|
>
|
|
<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`} />
|
|
</label>
|
|
<label className="flex-[1.4] space-y-1">
|
|
<span className={LABEL}>URL</span>
|
|
<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]`} />
|
|
</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`}>
|
|
<option value="">No group</option>
|
|
{groups.map((g) => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<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;
|
|
everything else opens in your browser.
|
|
</p>
|
|
</section>
|
|
|
|
{/* ------------------------------------------- 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>
|
|
|
|
{/* ------------------------------------------------------ zoom */}
|
|
<section className="space-y-2">
|
|
<SectionHeading>Zoom</SectionHeading>
|
|
<div className="space-y-1.5">
|
|
{orderedApps.map((app) => (
|
|
<div
|
|
key={app.id}
|
|
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
|
>
|
|
<Favicon url={app.url} name={app.name} />
|
|
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
|
|
<input
|
|
type="range"
|
|
min={50}
|
|
max={200}
|
|
step={5}
|
|
value={Math.round((app.zoom ?? 1) * 100)}
|
|
onChange={(e) =>
|
|
run(() => api.setZoom(app.id, Number(e.target.value) / 100))
|
|
}
|
|
className="min-w-0 flex-1 cursor-pointer accent-sky-500"
|
|
/>
|
|
<span className="w-12 shrink-0 text-right font-mono text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
|
|
{Math.round((app.zoom ?? 1) * 100)}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className={HELP}>
|
|
⌘+ and ⌘− zoom the app you are in; ⌘0 puts it back to 100%. Each app keeps
|
|
its own size.
|
|
</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>
|
|
<button
|
|
onClick={async () => {
|
|
await api.probeApps();
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
setReports(await api.appReports());
|
|
}}
|
|
className={BTN}
|
|
>
|
|
Check background apps
|
|
</button>
|
|
</div>
|
|
{reports.length > 0 && (
|
|
<div className="space-y-0.5">
|
|
{reports.map(([name, report]) => (
|
|
<p key={name} className={`${HELP} font-mono`}>
|
|
<span className="font-semibold">{name}</span> · {report}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="hidden">
|
|
</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">
|
|
<input
|
|
value={g.name}
|
|
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">
|
|
{config.apps.filter((a) => a.groupId === g.id).length} apps
|
|
</span>
|
|
<button
|
|
onClick={() => run(() => api.deleteGroup(g.id))}
|
|
title={`Delete ${g.name} — its apps stay, ungrouped`}
|
|
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}>New group</span>
|
|
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && addGroup()}
|
|
placeholder="Finance" className={`${INPUT} w-full`} />
|
|
</label>
|
|
<button onClick={addGroup} className={BTN}>Add group</button>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ----------------------------------------------- appearance */}
|
|
<section className="space-y-2">
|
|
<SectionHeading>Appearance</SectionHeading>
|
|
<Segmented<Theme>
|
|
value={theme}
|
|
onChange={onTheme}
|
|
options={[
|
|
{ value: "system", label: "System" },
|
|
{ value: "light", label: "Light" },
|
|
{ value: "dark", label: "Dark" },
|
|
]}
|
|
/>
|
|
</section>
|
|
|
|
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
|
|
<button onClick={onClose} className={BTN}>Done</button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{confirmDelete && (
|
|
<Dialog
|
|
title={`Remove ${confirmDelete.name}?`}
|
|
onCancel={() => setConfirmDelete(null)}
|
|
onConfirm={async () => {
|
|
await run(() => api.deleteApp(confirmDelete.id));
|
|
setConfirmDelete(null);
|
|
}}
|
|
confirmLabel="Remove"
|
|
destructive
|
|
>
|
|
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 signing in again.
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
);
|
|
}
|