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:
+73
-55
@@ -1,4 +1,5 @@
|
||||
import type { Config, Group, WorkApp } from "../types";
|
||||
import { Back, Cog, Collapse, EyeOff, Forward, Reload } from "./icons";
|
||||
import { Favicon, HEADING, ICON_CHROME } from "./ui";
|
||||
|
||||
interface Props {
|
||||
@@ -9,51 +10,65 @@ interface Props {
|
||||
onToggleCollapse: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onToggleGroup: (g: Group) => void;
|
||||
onBack: () => void;
|
||||
onForward: () => void;
|
||||
onReload: () => void;
|
||||
onPick: () => void;
|
||||
}
|
||||
|
||||
const RAIL = 52;
|
||||
/**
|
||||
* Wide enough that the macOS traffic lights fit inside the rail rather than
|
||||
* spilling over the page. Everything else about the rail follows from that.
|
||||
*/
|
||||
const RAIL = 72;
|
||||
const PANEL = 240;
|
||||
/** The strip the traffic lights sit in. Draggable, since there is no title bar. */
|
||||
const TITLEBAR = 36;
|
||||
|
||||
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
|
||||
|
||||
function Chevron({ dir }: { dir: "left" | "right" }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d={dir === "left" ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Cog() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Nav({
|
||||
config,
|
||||
activeId,
|
||||
collapsed,
|
||||
onSelect,
|
||||
onToggleCollapse,
|
||||
onOpenSettings,
|
||||
onToggleGroup,
|
||||
config, activeId, collapsed,
|
||||
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
||||
onBack, onForward, onReload, onPick,
|
||||
}: Props) {
|
||||
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
||||
const inGroup = (id: string | null) =>
|
||||
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
|
||||
const ungrouped = inGroup(null);
|
||||
const disabled = !activeId;
|
||||
|
||||
const shell =
|
||||
"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. */
|
||||
const controls = (
|
||||
<>
|
||||
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
|
||||
<Back />
|
||||
</button>
|
||||
<button onClick={onForward} disabled={disabled} title="Forward (or swipe right)" className={ICON_CHROME}>
|
||||
<Forward />
|
||||
</button>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------ icon rail
|
||||
if (collapsed) {
|
||||
const railBtn = (app: WorkApp) => {
|
||||
@@ -65,41 +80,38 @@ export default function Nav({
|
||||
title={app.name}
|
||||
aria-label={app.name}
|
||||
className={
|
||||
"relative grid size-9 cursor-pointer place-items-center rounded-lg transition-colors " +
|
||||
"relative grid size-10 cursor-pointer place-items-center rounded-lg transition-colors " +
|
||||
(active
|
||||
? "bg-slate-100 dark:bg-slate-800"
|
||||
: "opacity-70 hover:bg-slate-100 hover:opacity-100 dark:hover:bg-slate-800")
|
||||
}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute -left-2 h-5 w-[3px] rounded-full bg-sky-500" />
|
||||
)}
|
||||
<Favicon url={app.url} name={app.name} size={18} />
|
||||
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
||||
<Favicon url={app.url} name={app.name} size={20} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`${shell} w-[52px] items-center`} style={{ width: RAIL }}>
|
||||
<div className="flex w-full flex-col items-center gap-1 border-b border-slate-200 py-2 dark:border-slate-800">
|
||||
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
||||
<Cog />
|
||||
</button>
|
||||
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
|
||||
<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={onToggleCollapse} title="Expand" className={ICON_CHROME}>
|
||||
<Chevron dir="right" />
|
||||
<Collapse open={false} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3">
|
||||
{ungrouped.map(railBtn)}
|
||||
{ungrouped.length > 0 && groups.length > 0 && (
|
||||
<span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />
|
||||
<span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />
|
||||
)}
|
||||
{groups.map((g, i) => {
|
||||
const apps = inGroup(g.id);
|
||||
if (apps.length === 0) return null;
|
||||
return (
|
||||
<div key={g.id} className="flex flex-col items-center gap-1">
|
||||
{i > 0 && <span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />}
|
||||
{i > 0 && <span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />}
|
||||
{apps.map(railBtn)}
|
||||
</div>
|
||||
);
|
||||
@@ -125,22 +137,30 @@ export default function Nav({
|
||||
>
|
||||
<Favicon url={app.url} name={app.name} />
|
||||
<span className="truncate">{app.name}</span>
|
||||
{app.hidden.length > 0 && (
|
||||
<span
|
||||
title={`${app.hidden.length} element${app.hidden.length === 1 ? "" : "s"} hidden here`}
|
||||
className="ml-auto font-mono text-[10px] opacity-50"
|
||||
>
|
||||
{app.hidden.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className={shell} style={{ width: PANEL }}>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-800">
|
||||
<span className="truncate text-[13px] font-semibold tracking-tight">Apps</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
||||
<Cog />
|
||||
</button>
|
||||
<button onClick={onToggleCollapse} title="Collapse" className={ICON_CHROME}>
|
||||
<Chevron dir="left" />
|
||||
</button>
|
||||
</div>
|
||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="shrink-0" />
|
||||
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 pb-2 dark:border-slate-800">
|
||||
{controls}
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse"
|
||||
className={`${ICON_CHROME} ml-auto`}
|
||||
>
|
||||
<Collapse open />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||
@@ -158,9 +178,7 @@ export default function Nav({
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`size-3 transition-transform ${g.collapsed ? "" : "rotate-90"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user