Keep background apps alive, and notify from their unread count

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.
This commit is contained in:
2026-09-01 14:10:31 +02:00
parent 21a9f065f0
commit 8f989fb8f6
10 changed files with 343 additions and 65 deletions
+11 -14
View File
@@ -1,5 +1,5 @@
import type { Config, Group, WorkApp } from "../types";
import { Back, Cog, Collapse, EyeOff, Forward, Reload } from "./icons";
import { Back, Cog, Collapse, Forward, Reload } from "./icons";
import { Favicon, HEADING, ICON_CHROME } from "./ui";
interface Props {
@@ -13,7 +13,6 @@ interface Props {
onBack: () => void;
onForward: () => void;
onReload: () => void;
onPick: () => void;
}
/**
@@ -30,7 +29,7 @@ export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
export default function Nav({
config, activeId, collapsed,
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
onBack, onForward, onReload, onPick,
onBack, onForward, onReload,
}: Props) {
const groups = [...config.groups].sort((a, b) => a.order - b.order);
const inGroup = (id: string | null) =>
@@ -42,8 +41,9 @@ export default function Nav({
"flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " +
"dark:border-slate-800 dark:bg-slate-900";
/* Back, forward, reload, hide-an-element and settings, in that order —
navigation first because it is what gets reached for most. */
/* Back, forward, reload and settings. Nothing hides an element here that
lives on the right-click menu, where you are already pointing at the thing
you want gone. */
const controls = (
<>
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
@@ -55,14 +55,6 @@ export default function Nav({
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
<Reload />
</button>
<button
onClick={onPick}
disabled={disabled}
title="Hide an element on this page — or right-click it"
className={ICON_CHROME}
>
<EyeOff />
</button>
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
@@ -95,8 +87,13 @@ export default function Nav({
return (
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
{/* Only settings and the expander survive the rail. Back and forward
are a two-finger swipe, reload is ⌘R, and five buttons across 72px
is clutter standing in for a toolbar nobody asked for. */}
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
<div className="flex flex-wrap justify-center gap-0.5 px-1">{controls}</div>
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
<Collapse open={false} />
</button>
+22 -1
View File
@@ -42,6 +42,7 @@ export default function Settings({
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);
@@ -272,7 +273,27 @@ export default function Settings({
>
Check permission
</button>
<span className={HELP}>Clicking one opens the message it is about.</span>
<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>
-3
View File
@@ -14,9 +14,6 @@ export const Reload = (p: Props) => <S d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" {..
export const External = (p: Props) => (
<S d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" {...p} />
);
export const EyeOff = (p: Props) => (
<S d="M3 3l18 18M10.6 10.6a2 2 0 002.8 2.8M9.4 5.4A9.5 9.5 0 0112 5c5 0 9 4.5 9 7a11 11 0 01-2.4 3.5M6.2 6.9C4 8.3 3 10.4 3 12c0 2.5 4 7 9 7a9.6 9.6 0 004-.85" {...p} />
);
export const Trash = ({ className = "size-3.5" }: Props) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">