feat: read the subscription list off YouTube, and an app-shaped menu bar panel

Takeout is a snapshot you have to go and fetch. This reads the live list
from youtube.com/feed/channels in a browser already signed in.

It runs JavaScript in the page rather than fetching it here, because
the session cannot be borrowed: Chromium encrypts its cookie store with
a per-app Keychain key, and Arc's cookies read with Chrome's key came
back undecryptable — 1346 of 2196, the session cookies among them. The
list is also lazily loaded, so it has to be scrolled to the end, which
only the page can do. Measured against a hand-scrolled count: 208 both
ways.

The browser is brought to the front first. A background tab has its DOM
discarded, so it reports its address while having nothing on it, which
reads exactly like an empty subscription list.

The page gives handles, not channel ids. Most belong to channels already
known here and are matched by name, costing nothing; only the rest are
looked up, four at a time. That mattered more than it sounds: with the
name selector wrong every name came back empty, nothing matched, all 208
were looked up blind, and the result claimed 58 channels should be
deleted. With names read correctly it is 4 lookups and 0 deletions.
Names arriving doubled — "3D OCD 3D OCD", which reads fine and matches
nothing — are collapsed.

osascript prints a string result in source form, quoted with the
newlines escaped, so a list of rows arrived as one line that looked
like no rows at all. It is unquoted before parsing.

The menu bar item is now a window, not an NSMenu: the app's own type,
spacing and colours, hanging from the icon and closing on blur. Adding a
channel is gone from it — the sidebar already does that.

Replacing the subscription list is confirmed in the main window, never
in a panel that closes when you look away, and the confirmation counts
what would go before anything happens.

Verified end to end: 208 read, 4 looked up, 0 removed, 48 downloads
untouched.
This commit is contained in:
Vincent Rozenberg
2026-09-04 01:34:29 +02:00
parent 5cc3612b5f
commit 41d638b232
12 changed files with 853 additions and 88 deletions
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from "react";
import { emitTo } from "@tauri-apps/api/event";
import {
hidePanel,
quitApp,
scrapeSubscriptions,
showMainWindow,
traySaveVideo,
} from "../api";
import { Spinner } from "./ui";
/**
* The menu bar panel.
*
* A window rather than a native menu, so it is the app's own type, spacing and
* colours instead of the system's. It hangs from the icon and closes when it
* loses focus, which is what a menu does; everything else about it is ours.
*/
interface RowProps {
label: string;
hint?: string;
onClick: () => void;
busy?: boolean;
icon: React.ReactNode;
}
function Row({ label, hint, onClick, busy, icon }: RowProps) {
return (
<button
onClick={onClick}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-left
transition-colors hover:bg-slate-100 disabled:cursor-wait
dark:hover:bg-slate-800"
>
<span className="grid size-6 shrink-0 place-items-center text-slate-400 dark:text-slate-500">
{busy ? <Spinner className="size-3.5" /> : icon}
</span>
<span className="min-w-0">
<span className="block truncate text-[12.5px] font-medium text-slate-700 dark:text-slate-200">
{label}
</span>
{hint && (
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
{hint}
</span>
)}
</span>
</button>
);
}
const ICON = "size-4";
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.8 } as const;
export default function TrayPanel() {
const [busy, setBusy] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
// The panel is its own window, so it carries no page background of the app's.
useEffect(() => {
document.documentElement.style.background = "transparent";
document.body.style.background = "transparent";
}, []);
const run = (id: string, fn: () => Promise<unknown>, closeAfter = true) => {
setBusy(id);
setNote(null);
fn()
.then(() => {
if (closeAfter) void hidePanel();
})
.catch((e) => setNote(String(e)))
.finally(() => setBusy(null));
};
const importSubscriptions = () =>
run(
"subs",
async () => {
const result = await scrapeSubscriptions();
// The window owns the confirmation: replacing the list is not a thing
// to agree to in a panel that closes when you look away.
await emitTo("main", "subs:scraped", result);
await showMainWindow();
},
false,
);
return (
<div
className="flex h-screen w-screen flex-col rounded-xl border border-slate-300 bg-white/95
p-1.5 shadow-2xl backdrop-blur-xl dark:border-slate-700 dark:bg-slate-900/95"
>
<div className="px-2 pb-1 pt-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
FlightTube
</div>
<Row
label="Download this video"
hint="From the browser it is playing in"
busy={busy === "video"}
onClick={() => run("video", traySaveVideo)}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 4v10.5M7.5 10l4.5 4.5 4.5-4.5M4 18.5v1A2.5 2.5 0 006.5 22h11a2.5 2.5 0 002.5-2.5v-1" />
</svg>
}
/>
<Row
label="Import subscriptions"
hint="Read them off your YouTube page"
busy={busy === "subs"}
onClick={importSubscriptions}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h9M4 12h9M4 17h5" />
<path d="M17 9v8m0 0l-2.5-2.5M17 17l2.5-2.5" />
</svg>
}
/>
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
<Row
label="Open FlightTube"
onClick={() => run("open", async () => showMainWindow())}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<rect x="3.5" y="5" width="17" height="14" rx="2" />
<path d="M9 5v14" />
</svg>
}
/>
<Row
label="Quit"
onClick={() => void quitApp()}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 5h3a2 2 0 012 2v10a2 2 0 01-2 2h-3M10 12H3m0 0l3.5-3.5M3 12l3.5 3.5" />
</svg>
}
/>
{note && (
<p
className="mt-1 max-h-16 overflow-y-auto rounded-lg bg-red-500/10 px-2 py-1.5 text-[11px]
leading-snug text-red-700 dark:text-red-300"
>
{note}
</p>
)}
</div>
);
}