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.
171 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|