Files
work-app/src/App.tsx
T
Vincent ff4a0c6bc4 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.
2026-09-01 12:17:31 +02:00

171 lines
5.7 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import * as api from "./api";
import Nav from "./components/Nav";
import Settings from "./components/Settings";
import { BTN_PRIMARY } from "./components/ui";
import { useAppearance, type Theme } from "./hooks/useAppearance";
import type { Config, Group, HiddenEvent, SwitchEvent } from "./types";
export default function App() {
const [config, setConfig] = useState<Config | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [focusHidden, setFocusHidden] = useState<string | null>(null);
const [theme, setTheme] = useAppearance("system");
const stageRef = useRef<HTMLDivElement>(null);
const booted = useRef(false);
// Read inside listeners, which are registered once and would otherwise
// capture the first render's value forever.
const activeRef = useRef<string | null>(null);
activeRef.current = activeId;
const collapsed = config?.settings.navCollapsed ?? false;
useEffect(() => {
api.getConfig().then((c) => {
setConfig(c);
setTheme(c.settings.theme);
const first = [...c.apps].sort((a, b) => a.order - b.order)[0];
setActiveId(first?.id ?? null);
});
}, [setTheme]);
/**
* Tells Rust where the stage is.
*
* An app's webview is a native view that takes no part in CSS layout, so the
* only way it lands in the right place is for the shell to measure the hole
* it left and report it.
*/
const report = useCallback(() => {
const el = stageRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
void api.setStage(r.x, r.y, r.width, r.height);
}, []);
useLayoutEffect(() => {
const el = stageRef.current;
if (!el || !config) return;
report();
const ro = new ResizeObserver(report);
ro.observe(el);
window.addEventListener("resize", report);
return () => {
ro.disconnect();
window.removeEventListener("resize", report);
};
}, [config, report]);
// Webviews are built only once the stage has been measured, so they are born
// at the right size instead of against a guess.
useEffect(() => {
if (!config || booted.current || config.apps.length === 0) return;
booted.current = true;
report();
void api.bootstrap();
}, [config, report]);
useEffect(() => {
if (activeId) void api.setActive(activeId);
}, [activeId]);
useEffect(() => {
const unlisten = [
// A link in one app that points at another: Rust decided, the shell moves.
listen<SwitchEvent>("switch-app", async (e) => {
setActiveId(e.payload.appId);
await api.navigateApp(e.payload.appId, e.payload.url);
}),
// Something was right-clicked away inside a page.
listen<HiddenEvent>("hidden-added", () => {
void api.getConfig().then(setConfig);
}),
// "Manage hidden elements…" from a page's context menu.
listen<string>("manage-hidden", (e) => {
setFocusHidden(e.payload);
setSettingsOpen(true);
}),
];
return () => {
unlisten.forEach((p) => p.then((f) => f()));
};
}, []);
// A native view paints over anything the shell draws, so a dialog needs the
// stage out of the way rather than merely on a higher z-index.
useEffect(() => {
if (!config) return;
void (settingsOpen ? api.hideStage() : api.showStage());
}, [settingsOpen, config]);
const toggleCollapse = () => {
if (!config) return;
const next = !collapsed;
setConfig({ ...config, settings: { ...config.settings, navCollapsed: next } });
void api.setNavCollapsed(next);
};
const toggleGroup = (g: Group) => {
if (!config) return;
const updated = { ...g, collapsed: !g.collapsed };
setConfig({ ...config, groups: config.groups.map((x) => (x.id === g.id ? updated : x)) });
void api.updateGroup(updated);
};
const onTheme = (t: Theme) => {
setTheme(t);
void api.setTheme(t);
};
if (!config) return null;
return (
<div className="flex h-screen">
<Nav
config={config}
activeId={activeId}
collapsed={collapsed}
onSelect={setActiveId}
onToggleCollapse={toggleCollapse}
onOpenSettings={() => { setFocusHidden(null); setSettingsOpen(true); }}
onToggleGroup={toggleGroup}
onBack={() => activeId && api.historyGo(activeId, -1)}
onForward={() => activeId && api.historyGo(activeId, 1)}
onReload={() => activeId && api.historyGo(activeId, 0)}
onPick={() => activeId && api.pickHidden(activeId)}
/>
{/* The hole an app's native webview is positioned into. It stays empty
on purpose — anything drawn here would be painted over. */}
<div ref={stageRef} className="min-w-0 flex-1 bg-slate-100 dark:bg-slate-950">
{config.apps.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-[13px] text-slate-500 dark:text-slate-400">
No apps yet. Add the tools you work in and they appear in the nav.
</p>
<button onClick={() => setSettingsOpen(true)} className={BTN_PRIMARY}>
Add your first app
</button>
</div>
)}
</div>
{settingsOpen && (
<Settings
config={config}
theme={theme}
activeId={activeId}
focusHiddenFor={focusHidden}
onConfig={setConfig}
onTheme={onTheme}
onClose={() => { setSettingsOpen(false); setFocusHidden(null); }}
/>
)}
</div>
);
}