diff --git a/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index bbc5554..cba3296 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -346,9 +346,19 @@ every time. ## Downloads The webview saves a file perfectly well and mentions it to nobody, which makes a download -indistinguishable from a click that did nothing. So each one is announced in the corner as +indistinguishable from a click that did nothing. So each one is announced **in the nav** as it saves, and offers to show the finished file in the Finder. +In the nav, and not floating over the page, because that is where this was first put and +where it could never be seen: an app's webview is a native view painted above everything +the shell draws, so a card over the page is a card *behind* the page. The nav is the +shell's own. + +The events reaching the shell at all took a second fix. They are emitted from a spawned +task rather than from the download callback directly — that callback runs on the main +thread inside WebKit's download delegate, and delivering an event means running JavaScript +in a webview, which cannot happen from in there. Emitted directly they were simply lost. + Two things the API does not give, and how each is handled: - **No progress.** `DownloadEvent` reports a start and a finish and nothing between — no diff --git a/src-tauri/src/downloads.rs b/src-tauri/src/downloads.rs index 028932d..f3b8684 100644 --- a/src-tauri/src/downloads.rs +++ b/src-tauri/src/downloads.rs @@ -118,6 +118,24 @@ fn percent_decode(s: &str) -> String { String::from_utf8_lossy(&out).into_owned() } +/// Emits an event from off the download callback. +/// +/// The callback runs on the main thread inside WebKit's download delegate, and +/// delivering an event means running JavaScript in the webview — which cannot +/// happen from in there. Emitted directly it is simply lost: the download +/// completes, the file lands, and nothing is ever said about it, which is the +/// exact bug this whole feature exists to fix. +pub fn announce( + handle: &AppHandle, + event: &'static str, + payload: P, +) { + let handle = handle.clone(); + tauri::async_runtime::spawn(async move { + let _ = handle.emit(event, payload); + }); +} + pub fn next_id() -> u64 { NEXT_ID.fetch_add(1, Ordering::SeqCst) } diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 5db3436..a3cd8cf 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -737,7 +737,8 @@ pub fn create( .unwrap() .insert(url.to_string(), (id, path.clone())); - let _ = dl_handle.emit( + crate::downloads::announce( + &dl_handle, "download-started", crate::downloads::DownloadStarted { id, @@ -755,7 +756,8 @@ pub fn create( let found = state.download_paths.lock().unwrap().remove(&url.to_string()); if let Some((id, path)) = found { state.finished_downloads.lock().unwrap().insert(id); - let _ = dl_handle.emit( + crate::downloads::announce( + &dl_handle, "download-finished", crate::downloads::DownloadFinished { id, diff --git a/src/App.tsx b/src/App.tsx index 8372b50..23150ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,6 @@ import { listen } from "@tauri-apps/api/event"; import * as api from "./api"; import Nav from "./components/Nav"; -import Downloads from "./components/Downloads"; import Settings from "./components/Settings"; import { BTN_PRIMARY, Dialog } from "./components/ui"; import { useAppearance, type Theme } from "./hooks/useAppearance"; @@ -172,7 +171,7 @@ export default function App() { // stays, because it is still telling you something. setTimeout( () => setDownloads((d) => d.filter((x) => x.id !== e.payload.id)), - 12000, + 30000, ); }), ]; @@ -248,6 +247,8 @@ export default function App() { unread={unread} chrome={chrome} rail={rail} + downloads={downloads} + onDismissDownload={(id) => setDownloads((d) => d.filter((x) => x.id !== id))} /> {/* The hole an app's native webview is positioned into. It stays empty @@ -321,11 +322,6 @@ export default function App() { )} - setDownloads((d) => d.filter((x) => x.id !== id))} - /> - {settingsOpen && ( void; }) { if (items.length === 0) return null; return ( -
- {items.map((d) => ( -
- - {d.state === "done" ? ( - - - - ) : d.state === "failed" ? ( - - - - ) : ( - /* No total to divide by — the webview reports a start and a - finish and nothing between — so this spins rather than fills. */ - - - - - )} - +
+ {items.map((d) => { + const status = + d.state === "done" + ? "Downloaded" + : d.state === "failed" + ? "Failed" + : d.bytes > 0 + ? `${formatBytes(d.bytes)} so far` + : "Starting…"; - - {d.name} - - {d.state === "done" - ? "Downloaded" - : d.state === "failed" - ? "Download failed" - : d.bytes > 0 - ? `${formatBytes(d.bytes)} so far` - : "Starting…"} - - - - {d.state === "done" && ( - - )} - -
- ))} + + + {!collapsed && ( + <> + + {d.name} + + {status} + + + + {d.state === "done" && ( + + )} + + + )} +
+ ); + })}
); } +function Mark({ state }: { state: Download["state"] }) { + if (state === "done") { + return ( + + + + ); + } + if (state === "failed") { + return ( + + + + ); + } + /* No total to divide by — the webview reports a start and a finish and + nothing between — so this spins rather than fills. */ + return ( + + + + + ); +} + function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 4f19c48..23452b9 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -1,5 +1,6 @@ -import type { Config, Group, WorkApp } from "../types"; +import type { Config, Download, Group, WorkApp } from "../types"; import { Back, Cog, Collapse, Forward, Reload } from "./icons"; +import Downloads from "./Downloads"; import { Favicon, GROUP_LABEL, ICON_CHROME } from "./ui"; interface Props { @@ -23,6 +24,8 @@ interface Props { * the buttons themselves, since that inset is macOS's to choose. */ rail: number; + downloads: Download[]; + onDismissDownload: (id: number) => void; } const PANEL = 240; @@ -30,7 +33,7 @@ const PANEL = 240; export default function Nav({ config, activeId, collapsed, unread, onSelect, onToggleCollapse, onOpenSettings, onToggleGroup, - onBack, onForward, onReload, chrome, rail, + onBack, onForward, onReload, chrome, rail, downloads, onDismissDownload, }: Props) { const groups = [...config.groups].sort((a, b) => a.order - b.order); const inGroup = (id: string | null) => @@ -150,6 +153,7 @@ export default function Nav({ ); })} +
{settingsButton}
@@ -226,6 +230,8 @@ export default function Nav({ )} + +