diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ea286f3..bd0025c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] } +tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png", "macos-private-api"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2f9aeb0..32196cc 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,9 +1,10 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", + "description": "Capability for the main window and the menu bar panel", "windows": [ - "main" + "main", + "tray" ], "permissions": [ "core:default", @@ -11,6 +12,9 @@ "dialog:default", "core:window:allow-start-dragging", "core:window:allow-is-fullscreen", - "notification:default" + "notification:default", + "core:event:allow-emit-to", + "core:event:allow-emit", + "core:event:allow-listen" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index e0bdf87..296723b 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen","notification:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Capability for the main window and the menu bar panel","local":true,"windows":["main","tray"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen","notification:default","core:event:allow-emit-to","core:event:allow-emit","core:event:allow-listen"]}} \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cb7799f..c5eec72 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,6 +10,7 @@ use crate::models::{ use crate::net; use crate::playlist_server::PlaylistServer; use crate::resolve; +use crate::subscriptions; use crate::takeout; use crate::thumbs; @@ -1613,6 +1614,129 @@ pub async fn set_download_defaults( Ok(()) } +/// Turns a scraped list into channels, and says what it could not resolve. +#[derive(Serialize)] +pub struct ScrapeResult { + pub channels: Vec, + /// Names whose channel could not be identified, so the user knows the list + /// handed on is short rather than wondering later. + pub unresolved: Vec, + pub looked_up: usize, +} + +/// Reads the subscription list out of the browser and resolves it to channels. +/// +/// The page gives handles, not channel ids. Most of them belong to channels +/// already known here, and those are matched by name — no request at all. +/// Only genuinely new ones are looked up, a few at a time, because a burst of +/// two hundred requests is how you earn an HTTP 429. +#[tauri::command] +pub async fn scrape_subscriptions( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let rows = crate::tray::scrape_subscriptions(&app).await?; + + let known: HashMap = state + .db + .lock() + .await + .list_channels()? + .into_iter() + .map(|c| (subscriptions::name_key(&c.title), c)) + .collect(); + + let mut channels: Vec = Vec::new(); + let mut to_look_up: Vec<(String, String)> = Vec::new(); + + for (href, name) in rows { + // A link that already carries the id costs nothing. + if let Some(id) = subscriptions::id_from_href(&href) { + channels.push(Channel { url: resolve::channel_url(&id), id, title: name }); + continue; + } + match known.get(&subscriptions::name_key(&name)) { + Some(c) if !name.is_empty() => channels.push(Channel { + id: c.id.clone(), + title: name, + url: c.url.clone(), + }), + _ => to_look_up.push((href, name)), + } + } + + let looked_up = to_look_up.len(); + let mut unresolved = Vec::new(); + + // Four at a time: enough to finish a normal import quickly, gentle enough + // that YouTube does not start refusing. + let found = futures::stream::iter(to_look_up.into_iter().map(|(href, name)| { + let http = state.http.clone(); + async move { + let url = subscriptions::handle_url(&href); + let page = match http + .get(&url) + .header("Accept-Language", "en-US,en;q=0.9") + .header("Cookie", "CONSENT=YES+1") + .send() + .await + { + Ok(r) => r.text().await.ok(), + Err(_) => None, + }; + let resolved = page + .as_deref() + .and_then(resolve::parse_channel) + .map(|(id, parsed)| Channel { + url: resolve::channel_url(&id), + id, + // The page's own name beats a scraped one only when the + // scrape had none. + title: if name.is_empty() { parsed } else { name.clone() }, + }); + (name, href, resolved) + } + })) + .buffer_unordered(4) + .collect::>() + .await; + + for (name, href, resolved) in found { + match resolved { + Some(c) => channels.push(c), + None => unresolved.push(if name.is_empty() { href } else { name }), + } + } + + if channels.is_empty() { + return Err("None of the channels on that page could be identified.".into()); + } + Ok(ScrapeResult { channels, unresolved, looked_up }) +} + +/// What replacing the subscription list with a scraped one would change. +#[tauri::command] +pub async fn preview_scraped_import( + channels: Vec, + state: State<'_, AppState>, +) -> Result { + state.db.lock().await.preview_replace(&channels) +} + +/// Replaces the subscription list with a scraped one, exactly as a Takeout +/// import does, files and all. +#[tauri::command] +pub async fn import_scraped( + channels: Vec, + state: State<'_, AppState>, +) -> Result { + let dropped = state.db.lock().await.paths_dropped_by_replace(&channels)?; + for p in &dropped { + let _ = tokio::fs::remove_file(p).await; + } + state.db.lock().await.replace_channels(&channels) +} + /// What deleting one subscription would take with it. #[derive(Serialize)] pub struct RemovalPreview { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc1e24d..8f895a1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod models; pub mod net; pub mod playlist_server; pub mod resolve; +pub mod subscriptions; pub mod takeout; pub mod thumbs; pub mod tray; @@ -85,7 +86,6 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) - .on_menu_event(tray::on_menu_event) .setup(|app| { let state = commands::build_state(&app.handle().clone())?; app.manage(state); @@ -137,6 +137,13 @@ pub fn run() { commands::set_library_path, commands::open_external, commands::add_channel, + commands::scrape_subscriptions, + tray::tray_save_video, + tray::show_main_window, + tray::hide_panel, + tray::quit_app, + commands::preview_scraped_import, + commands::import_scraped, commands::delete_channel, commands::preview_delete_channel, commands::set_download_defaults, diff --git a/src-tauri/src/subscriptions.rs b/src-tauri/src/subscriptions.rs new file mode 100644 index 0000000..15900a4 --- /dev/null +++ b/src-tauri/src/subscriptions.rs @@ -0,0 +1,207 @@ +//! Reading the subscription list out of a signed-in browser. +//! +//! The Takeout CSV is a snapshot you have to go and fetch. This reads the live +//! list from in a browser that is +//! already signed in, which is both current and no trouble to repeat. +//! +//! It runs JavaScript in the page rather than fetching it here, for two +//! reasons. The page is signed-in-only, and the session cookie cannot be +//! borrowed: Chromium encrypts its cookie store with a per-app Keychain key, so +//! Arc's cookies read with Chrome's key come back undecryptable — measured at +//! 1346 of 2196, the session cookies among them. And the list is lazily loaded, +//! so it has to be scrolled to the end, which only the page itself can do. + +/// The channels a page carries, as (handle, name). +pub type Scraped = Vec<(String, String)>; + +/// Collects what is currently rendered. Handles rather than channel ids: the +/// page carries no ids at all — no `ytInitialData`, and every link is a +/// `/@handle`. +/// +/// The name comes from `#text`. `#title` does not exist on this page and +/// `#channel-title` wraps a second copy of the name, which is worth knowing +/// because a wrong name is not a visible failure — it just means nothing +/// matches what is already stored, and every channel gets looked up over the +/// network instead. +pub const EXTRACT_JS: &str = r#"(function(){ + var els = document.querySelectorAll('ytd-channel-renderer'); + var rows = []; + for (var i = 0; i < els.length; i++) { + var a = els[i].querySelector('a[href^="/@"], a[href^="/channel/"]'); + if (!a) continue; + var t = els[i].querySelector('#text, #channel-title'); + var name = t ? t.textContent.replace(/\s+/g, ' ').trim() : ''; + rows.push(a.getAttribute('href') + '\t' + name); + } + return rows.join('\n'); +})()"#; + +/// One scroll to the end, so the next batch loads. +pub const SCROLL_JS: &str = "window.scrollTo(0, document.documentElement.scrollHeight); \ + document.querySelectorAll('ytd-channel-renderer').length"; + +/// True for the page this can read. +pub fn is_subscriptions_page(url: &str) -> bool { + let u = url.to_ascii_lowercase(); + u.contains("youtube.com/feed/channels") +} + +/// Parses what the page handed back. +/// +/// A name is allowed to be empty — the handle is what identifies the channel, +/// and a nameless row is still a subscription. +pub fn parse_rows(raw: &str) -> Scraped { + let mut out: Scraped = Vec::new(); + for line in raw.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let (href, name) = match line.split_once('\t') { + Some((h, n)) => (h.trim(), n.trim()), + None => (line, ""), + }; + if !href.starts_with("/@") && !href.starts_with("/channel/") { + continue; + } + // The same channel can be rendered twice while the list re-flows. + if out.iter().any(|(h, _)| h == href) { + continue; + } + out.push((href.to_string(), collapse_doubled(name))); + } + out +} + +/// Collapses a name that arrived twice over. +/// +/// YouTube's markup nests the channel name inside an element that also holds +/// it, so a slightly wrong selector yields "3D OCD 3D OCD". That reads fine to +/// a person and matches nothing at all. +pub fn collapse_doubled(name: &str) -> String { + let n = name.split_whitespace().collect::>().join(" "); + if n.is_empty() { + return n; + } + if n.len() % 2 == 1 { + let mid = n.len() / 2; + if n.is_char_boundary(mid) && n.is_char_boundary(mid + 1) && &n[mid..mid + 1] == " " { + let (a, b) = (&n[..mid], &n[mid + 1..]); + if a == b { + return a.to_string(); + } + } + } + n +} + +/// The full address for a scraped handle. +pub fn handle_url(href: &str) -> String { + format!("https://www.youtube.com{href}") +} + +/// The channel id, when the page gave one outright rather than a handle. +pub fn id_from_href(href: &str) -> Option { + let id = href.strip_prefix("/channel/")?; + let id = id.split('/').next().unwrap_or(id); + (id.starts_with("UC") && id.len() == 24).then(|| id.to_string()) +} + +/// Names as YouTube renders them and as a CSV stored them differ in case, +/// spacing and stray whitespace; this is what they are compared on. +pub fn name_key(name: &str) -> String { + name.split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_subscriptions_page_qualifies() { + assert!(is_subscriptions_page("https://www.youtube.com/feed/channels")); + assert!(is_subscriptions_page("https://youtube.com/feed/channels?flow=grid")); + assert!(!is_subscriptions_page("https://www.youtube.com/feed/subscriptions")); + assert!(!is_subscriptions_page("https://www.youtube.com/watch?v=abc")); + } + + #[test] + fn rows_come_back_as_handle_and_name() { + let raw = "/@3DOCD\t3D OCD\n/@mkbhd\tMarques Brownlee\n"; + assert_eq!( + parse_rows(raw), + vec![ + ("/@3DOCD".to_string(), "3D OCD".to_string()), + ("/@mkbhd".to_string(), "Marques Brownlee".to_string()), + ] + ); + } + + #[test] + fn junk_rows_are_dropped_and_repeats_collapse() { + // A re-flowing list can render the same channel twice, and anything + // that is not a channel link is not a subscription. + let raw = "/@a\tA\n\n/watch?v=x\tNot a channel\n/@a\tA\n/@b\t\n"; + assert_eq!( + parse_rows(raw), + vec![ + ("/@a".to_string(), "A".to_string()), + // A nameless row is still a subscription; the handle identifies it. + ("/@b".to_string(), String::new()), + ] + ); + } + + #[test] + fn a_channel_link_gives_its_id_without_a_lookup() { + assert_eq!( + id_from_href("/channel/UCBJycsmduvYEL83R_U4JriQ").as_deref(), + Some("UCBJycsmduvYEL83R_U4JriQ") + ); + assert_eq!(id_from_href("/@mkbhd"), None); + assert_eq!(id_from_href("/channel/UCshort"), None); + } + + #[test] + fn a_name_that_arrived_twice_is_collapsed() { + assert_eq!(collapse_doubled("3D OCD 3D OCD"), "3D OCD"); + assert_eq!(collapse_doubled(" Linus Tech Tips "), "Linus Tech Tips"); + // Not everything that repeats is doubled. + assert_eq!(collapse_doubled("Spanian 2"), "Spanian 2"); + assert_eq!(collapse_doubled("Corridor Crew"), "Corridor Crew"); + assert_eq!(collapse_doubled(""), ""); + } + + #[test] + fn doubled_names_survive_parsing() { + assert_eq!( + parse_rows("/@3DOCD\t3D OCD 3D OCD\n"), + vec![("/@3DOCD".to_string(), "3D OCD".to_string())] + ); + } + + #[test] + fn handles_become_addresses() { + assert_eq!(handle_url("/@mkbhd"), "https://www.youtube.com/@mkbhd"); + } + + #[test] + fn names_match_across_spacing_and_case() { + // What the page renders vs what a Takeout CSV stored. + assert_eq!(name_key(" Linus Tech Tips "), name_key("linus tech tips")); + assert_ne!(name_key("Corridor Crew"), name_key("Corridor Digital")); + } + + #[test] + fn the_extraction_script_asks_for_what_the_page_has() { + // Handles and names, since the page carries no channel ids. + assert!(EXTRACT_JS.contains("ytd-channel-renderer")); + assert!(EXTRACT_JS.contains(r#"a[href^="/@"]"#)); + // #text holds the name once; #title does not exist on this page. + assert!(EXTRACT_JS.contains("'#text, #channel-title'")); + assert!(SCROLL_JS.contains("scrollTo")); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 3995c3e..d86c6d2 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -10,16 +10,14 @@ use crate::commands::{self, AppState}; use crate::resolve; +use crate::subscriptions; use std::io::Write; -use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; -use tauri::tray::TrayIconBuilder; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_notification::NotificationExt; -pub const SAVE_VIDEO: &str = "tray.save_video"; -pub const ADD_CHANNEL: &str = "tray.add_channel"; -pub const SHOW: &str = "tray.show"; -pub const STATUS: &str = "tray.status"; +/// The panel window's label. +pub const PANEL: &str = "tray"; /// A running account of what the menu bar did, next to the database. /// @@ -42,21 +40,10 @@ fn log(app: &AppHandle, line: &str) { } } -/// The menu's own first line, kept so its text can be rewritten. A TrayIcon -/// does not hand its menu back, so the item is held here. -pub struct StatusItem(pub MenuItem); - /// The tray icon itself, held for as long as the app runs. Dropping it takes /// the icon out of the menu bar. pub struct Tray(#[allow(dead_code)] pub tauri::tray::TrayIcon); -/// Leaves the answer on the menu itself, which no permission can suppress. -fn set_status(app: &AppHandle, text: &str) { - if let Some(item) = app.try_state::() { - let _ = item.0.set_text(text); - } -} - /// Browsers worth asking, in the order they are asked. All but Safari answer /// the same Chromium-flavoured AppleScript. const CHROMIUM: [&str; 6] = [ @@ -137,14 +124,132 @@ async fn browser_youtube_url(app: &AppHandle) -> Result { Err("No YouTube page open in a browser.".into()) } +/// Runs JavaScript in a browser's active tab. +/// +/// Arc allows this out of the box. Chrome and its relatives ship with it off, +/// and say so in the error, which is passed straight back rather than being +/// flattened into "something went wrong". +pub async fn run_js_logged(app: &AppHandle, browser: &str, js: &str) -> Result { + let r = run_js(browser, js).await; + match &r { + Ok(out) => log(app, &format!("js ok, {} chars back", out.len())), + Err(e) => log(app, &format!("js failed: {e}")), + } + r +} + +pub async fn run_js(browser: &str, js: &str) -> Result { + let safari = browser == "Safari"; + let script = if safari { + format!("tell application \"Safari\" to return (do JavaScript {} in front document)", quote(js)) + } else { + format!( + "tell application {} to return (execute front window's active tab javascript {})", + quote(browser), + quote(js) + ) + }; + let out = tokio::process::Command::new("/usr/bin/osascript") + .arg("-e") + .arg(script) + .output() + .await + .map_err(|e| format!("Could not reach {browser}: {e}"))?; + + let stderr = String::from_utf8_lossy(&out.stderr); + if stderr.contains("Executing JavaScript through AppleScript is turned off") { + return Err(format!( + "{browser} will not run JavaScript for another app. Turn it on in {browser} under \ + View › Developer › Allow JavaScript from Apple Events, then try again. Arc allows \ + it already." + )); + } + if !out.status.success() { + return Err(format!("{browser} refused: {}", stderr.trim())); + } + Ok(applescript_unquote(&String::from_utf8_lossy(&out.stdout))) +} + +/// AppleScript string literal. +fn quote(s: &str) -> String { + applescript_string(s) +} + +/// The browser showing the subscriptions page, if one is. +async fn browser_on_subscriptions(app: &AppHandle) -> Result { + for (browser, safari_style) in CHROMIUM + .iter() + .map(|b| (*b, false)) + .chain(std::iter::once(("Safari", true))) + { + let Ok(out) = tokio::process::Command::new("/usr/bin/osascript") + .arg("-e") + .arg(url_script(browser, safari_style)) + .output() + .await + else { + continue; + }; + let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if subscriptions::is_subscriptions_page(&url) { + log(app, &format!("subscriptions page open in {browser}")); + return Ok(browser.to_string()); + } + } + Err("Open https://www.youtube.com/feed/channels in your browser first, \ + then try again." + .into()) +} + +/// Scrolls the subscriptions page to its end and reads the list off it. +/// +/// The list loads a batch at a time, so this scrolls until the count stops +/// growing rather than trusting one read — a first look sees about half. +pub async fn scrape_subscriptions(app: &AppHandle) -> Result { + let browser = browser_on_subscriptions(app).await?; + + let mut last = 0usize; + let mut settled = 0; + for round in 0..40 { + let n = run_js_logged(app, &browser, subscriptions::SCROLL_JS) + .await? + .parse::() + .unwrap_or(0); + // Nothing there yet only means the tab is still waking up. + if n == 0 && round < 8 { + tokio::time::sleep(std::time::Duration::from_millis(700)).await; + continue; + } + if n == last { + settled += 1; + if settled >= 3 { + break; + } + } else { + settled = 0; + } + last = n; + tokio::time::sleep(std::time::Duration::from_millis(900)).await; + } + + let raw = run_js_logged(app, &browser, subscriptions::EXTRACT_JS).await?; + let rows = subscriptions::parse_rows(&raw); + log(app, &format!("scraped {} channels from {browser}", rows.len())); + if rows.is_empty() { + return Err("That page has no channels on it. Is it the subscriptions page, \ + and are you signed in?" + .into()); + } + Ok(rows) +} + /// The answer, delivered without the window coming forward. /// -/// Three ways, because the first two can fail silently: a notification from the -/// app itself, the same through AppleScript if the plugin is unavailable, and -/// the menu's own first line, which always survives. +/// Two ways, because either can fail silently: a notification from the app +/// itself, and the same through AppleScript if the plugin is unavailable. +/// Both are also written to the log beside the database. async fn notify(app: &AppHandle, text: &str) { log(app, text); - set_status(app, text); if app .notification() @@ -167,13 +272,42 @@ async fn notify(app: &AppHandle, text: &str) { .await; } +/// Undoes how osascript prints a string result. +/// +/// It prints in source form — wrapped in quotes with the newlines and tabs +/// escaped — so a list of rows arrives as one line that looks nothing like +/// rows. Numbers and bare words come back untouched and pass straight through. +fn applescript_unquote(out: &str) -> String { + let out = out.trim(); + let Some(inner) = out.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { + return out.to_string(); + }; + let mut s = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + s.push(c); + continue; + } + match chars.next() { + Some('n') => s.push('\n'), + Some('t') => s.push('\t'), + Some('r') => s.push('\r'), + Some(other) => s.push(other), + None => break, + } + } + s +} + /// Quotes a string for AppleScript. Backslashes first, or the escaping of the /// quotes gets undone. fn applescript_string(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) } -async fn handle_save_video(app: AppHandle) { +#[tauri::command] +pub async fn tray_save_video(app: AppHandle) { let app = &app; log(app, "menu: save video"); let url = match browser_youtube_url(app).await { @@ -204,24 +338,12 @@ async fn handle_save_video(app: AppHandle) { }); } -async fn handle_add_channel(app: AppHandle) { - let app = &app; - log(app, "menu: add channel"); - let url = match browser_youtube_url(app).await { - Ok(u) => u, - Err(e) => return notify(app, &e).await, - }; - let state = app.state::(); - match commands::add_channel(url, app.clone(), state).await { - Ok(title) => { - let _ = app.emit("feed:changed", ()); - notify(app, &format!("Subscribed to {title}")).await; - } - Err(e) => notify(app, &e).await, +/// Brings the window forward, and closes the panel behind it. +#[tauri::command] +pub fn show_main_window(app: AppHandle) { + if let Some(p) = app.get_webview_window(PANEL) { + let _ = p.hide(); } -} - -fn show_window(app: &AppHandle) { if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.unminimize(); @@ -229,36 +351,74 @@ fn show_window(app: &AppHandle) { } } -pub fn on_menu_event(app: &AppHandle, event: MenuEvent) { - let app = app.clone(); - match event.id().as_ref() { - SAVE_VIDEO => { - tauri::async_runtime::spawn(handle_save_video(app)); - } - ADD_CHANNEL => { - tauri::async_runtime::spawn(handle_add_channel(app)); - } - SHOW => show_window(&app), - _ => {} +/// Closes the panel without doing anything else. +#[tauri::command] +pub fn hide_panel(app: AppHandle) { + if let Some(p) = app.get_webview_window(PANEL) { + let _ = p.hide(); } } +#[tauri::command] +pub fn quit_app(app: AppHandle) { + app.exit(0); +} + +/// The panel's own size. Fixed, because it is a menu: it does not resize. +const PANEL_W: f64 = 304.0; +const PANEL_H: f64 = 252.0; + +/// Opens the panel under the menu bar icon. +/// +/// A native menu would look like every other menu bar item; this is the app's +/// own window, styled like the rest of it, positioned to hang from the icon. +fn show_panel(app: &AppHandle, icon: tauri::Rect) { + let Some(win) = app.get_webview_window(PANEL) else { return }; + if win.is_visible().unwrap_or(false) { + let _ = win.hide(); + return; + } + + // The icon's rect is in physical pixels; the window is placed in the same + // space, centred under the icon and just below the menu bar. + if let (tauri::Position::Physical(pos), tauri::Size::Physical(size)) = + (icon.position, icon.size) + { + let scale = win.scale_factor().unwrap_or(1.0); + let w = PANEL_W * scale; + let x = pos.x as f64 + size.width as f64 / 2.0 - w / 2.0; + let y = pos.y as f64 + size.height as f64 + 6.0 * scale; + let _ = win.set_position(tauri::PhysicalPosition::new(x.max(8.0), y)); + } + + let _ = win.show(); + let _ = win.set_focus(); +} + pub fn build(app: &AppHandle) -> tauri::Result<()> { - // Disabled: it reports, it does not act. - let status = MenuItem::with_id(app, STATUS, "FlightTube", false, None::<&str>)?; - let menu = Menu::with_items( - app, - &[ - &status, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id(app, SAVE_VIDEO, "Download the video I'm watching", true, None::<&str>)?, - &MenuItem::with_id(app, ADD_CHANNEL, "Add the channel I'm watching", true, None::<&str>)?, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id(app, SHOW, "Open FlightTube", true, None::<&str>)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::quit(app, Some("Quit FlightTube"))?, - ], - )?; + // Hidden until the icon is clicked. Undecorated and transparent so the + // page can draw its own rounded, shadowed card. + let panel = WebviewWindowBuilder::new(app, PANEL, WebviewUrl::App("index.html#tray".into())) + .title("FlightTube") + .inner_size(PANEL_W, PANEL_H) + .decorations(false) + .transparent(true) + .resizable(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(false) + .focused(false) + .build()?; + + // Clicking away closes it, as a menu does. + { + let handle = panel.clone(); + panel.on_window_event(move |e| { + if let tauri::WindowEvent::Focused(false) = e { + let _ = handle.hide(); + } + }); + } let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?; @@ -267,17 +427,22 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> { // A template image takes the menu bar's own colour, light or dark. .icon_as_template(true) .tooltip("FlightTube") - .menu(&menu) - // The menu is the whole point; a left click should open it too. - .show_menu_on_left_click(true) + // No menu at all: the click opens the panel instead. + .show_menu_on_left_click(false) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { button: MouseButton::Left, button_state, rect, .. } = event + { + if button_state == MouseButtonState::Up { + show_panel(tray.app_handle(), rect); + } + } + }) .build(app)?; // TrayIcon is reference-counted and "the icon is removed when the last - // instance is dropped" — letting the handle fall out of scope here created - // the item and destroyed it in the same breath, which is exactly as - // invisible as never creating it. + // instance is dropped", so letting the handle fall out of scope here would + // create the item and destroy it in the same breath. app.manage(Tray(tray)); - app.manage(StatusItem(status)); Ok(()) } @@ -308,6 +473,21 @@ mod tests { assert!(url_script("Arc", false).contains("URL of active tab of front window")); } + #[test] + fn a_printed_string_result_is_unwrapped_into_real_rows() { + // What osascript actually prints for a multi-line string result: one + // quoted line with the newlines and tabs escaped. + let printed = r#""/@a\tA\n/@b\tB""#; + assert_eq!(applescript_unquote(printed), "/@a\tA\n/@b\tB"); + assert_eq!(applescript_unquote(printed).lines().count(), 2); + } + + #[test] + fn a_number_result_passes_through_untouched() { + assert_eq!(applescript_unquote("197\n"), "197"); + assert_eq!(applescript_unquote(""), ""); + } + #[test] fn notification_text_survives_quotes_and_backslashes() { assert_eq!(applescript_string(r#"a "b" c"#), r#""a \"b\" c""#); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0f4ef50..223b148 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,11 +32,14 @@ "$APPLOCALDATA/**" ] } - } + }, + "macOSPrivateApi": true }, "bundle": { "active": true, - "targets": ["app"], + "targets": [ + "app" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/App.tsx b/src/App.tsx index d7ea7ba..b87e7cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel, - deleteDownload, downloadVideo, interruptedDownloads, listFeed, openExternal, - previewDeleteChannel, - setDownloadDefaults, type RemovalPreview, + deleteDownload, downloadVideo, importScraped, interruptedDownloads, listFeed, + openExternal, previewDeleteChannel, previewScrapedImport, + setDownloadDefaults, type RemovalPreview, type ScrapeResult, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, } from "./api"; import AddChannel from "./components/AddChannel"; @@ -23,7 +23,8 @@ import { useWindowFullscreen } from "./hooks/useWindowFullscreen"; import { BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, DEFAULT_SUB_STYLE, QUALITIES, STREAM_QUALITIES, SUB_LANGS, - type FeedFilter, type FeedItem, type Quality, type RefreshProgress, type SubStyle, + type FeedFilter, type FeedItem, type ImportPreview, type Quality, + type RefreshProgress, type SubStyle, } from "./types"; const TOAST_MS = 2400; @@ -53,6 +54,8 @@ export default function App() { const [autoplayNext, setAutoplayNext] = useState(() => remembered("autoplayNext")); const [autoMode, setAutoMode] = useState(() => remembered("autoMode")); const [confirmAuto, setConfirmAuto] = useState<{ fetch: number; remove: number } | null>(null); + // A subscription list read out of the browser, waiting to be confirmed. + const [scraped, setScraped] = useState<(ScrapeResult & { preview: ImportPreview }) | null>(null); const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden")); const [sidebarPeek, setSidebarPeek] = useState(false); const [view, setView] = useState(() => { @@ -356,6 +359,19 @@ export default function App() { }); }, [quality, embedLang]); + // The menu bar reads the subscription list, but replacing what is here is + // not a thing to agree to in a panel that closes when you look away. + useEffect(() => { + const un = listen("subs:scraped", (e) => { + previewScrapedImport(e.payload.channels) + .then((preview) => setScraped({ ...e.payload, preview })) + .catch((err) => setFailure(String(err))); + }); + return () => { + void un.then((f) => f()); + }; + }, []); + // Anything saved from the menu bar arrives behind the app's back. useEffect(() => { const un = listen("feed:changed", () => reload()); @@ -826,6 +842,49 @@ export default function App() { )} + {scraped && ( + setScraped(null)} + onConfirm={() => { + const list = scraped.channels; + setScraped(null); + importScraped(list) + .then((n) => { + reload(); + say(`Imported ${n} subscriptions`); + void doRefresh(); + }) + .catch((e) => setFailure(String(e))); + }} + confirmLabel="Replace" + destructive={scraped.preview.removed_channels > 0} + wide + > +

+ Read {scraped.channels.length} channels off YouTube + {scraped.looked_up > 0 && <> ({scraped.looked_up} looked up by hand)}. +

+

+ This replaces the list here. {scraped.preview.removed_channels} channel + {scraped.preview.removed_channels === 1 ? "" : "s"} would go, taking{" "} + {scraped.preview.removed_videos} videos and{" "} + {scraped.preview.removed_downloads} downloaded file + {scraped.preview.removed_downloads === 1 ? "" : "s"} with them. +

+ {scraped.unresolved.length > 0 && ( +

+ {scraped.unresolved.length} could not be identified and are left out:{" "} + + {scraped.unresolved.slice(0, 8).join(", ")} + {scraped.unresolved.length > 8 && ` and ${scraped.unresolved.length - 8} more`} + + . Add those by hand if you want them. +

+ )} +
+ )} + {confirmAuto && ( invoke("check_yt_dlp_update" export const updateYtDlp = () => invoke("update_yt_dlp"); /** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */ +export interface ScrapeResult { + channels: Array<{ id: string; title: string; url: string }>; + unresolved: string[]; + looked_up: number; +} + +/** Reads the subscription list out of a signed-in browser page. */ +export const scrapeSubscriptions = () => invoke("scrape_subscriptions"); + +export const previewScrapedImport = (channels: ScrapeResult["channels"]) => + invoke("preview_scraped_import", { channels }); + +export const importScraped = (channels: ScrapeResult["channels"]) => + invoke("import_scraped", { channels }); + +/** The menu bar panel's own actions. */ +export const traySaveVideo = () => invoke("tray_save_video"); +export const showMainWindow = () => invoke("show_main_window"); +export const hidePanel = () => invoke("hide_panel"); +export const quitApp = () => invoke("quit_app"); + /** Adds one channel from any YouTube link. Returns its title. */ export const addChannel = (url: string) => invoke("add_channel", { url }); diff --git a/src/components/TrayPanel.tsx b/src/components/TrayPanel.tsx new file mode 100644 index 0000000..6e92260 --- /dev/null +++ b/src/components/TrayPanel.tsx @@ -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 ( + + ); +} + +const ICON = "size-4"; +const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.8 } as const; + +export default function TrayPanel() { + const [busy, setBusy] = useState(null); + const [note, setNote] = useState(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, 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 ( +
+
+ FlightTube +
+ + run("video", traySaveVideo)} + icon={ + + + + } + /> + + + + + } + /> + +
+ + run("open", async () => showMainWindow())} + icon={ + + + + + } + /> + void quitApp()} + icon={ + + + + } + /> + + {note && ( +

+ {note} +

+ )} +
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index 67804a9..c8eb98d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import TrayPanel from "./components/TrayPanel"; import "./index.css"; // The webview's own context menu offers Reload, Back and Inspect — page @@ -14,8 +15,10 @@ document.addEventListener("contextmenu", (e) => { if (!editable) e.preventDefault(); }); +// The menu bar panel is a second window on the same bundle, told apart by +// the hash it is opened with. +const panel = window.location.hash === "#tray"; + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , + {panel ? : }, );