From 36506718c21fa242e756abbe39f9555dcb14197a Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 2 Sep 2026 11:07:22 +0200 Subject: [PATCH] Make the download card visible, and the events arrive at all Two separate faults, both hiding the same feature. The events never reached the shell. They were emitted from inside WebKit's download delegate, which runs on the main thread, and delivering an event means running JavaScript in a webview - which cannot happen from in there. They are now emitted from a spawned task, like every other event in the app that works. And the card was drawn over the page, 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. It now lives in the nav, which is the shell's own - full detail when the nav is open, just the state icon on the rail. Verified end to end on a Gmail attachment: requested, named, saved, and shown as "Downloaded" with a Show button that reveals it. --- .../specs/2026-09-01-work-app-design.md | 12 +- src-tauri/src/downloads.rs | 18 ++ src-tauri/src/webviews.rs | 6 +- src/App.tsx | 10 +- src/components/Downloads.tsx | 160 ++++++++++-------- src/components/Nav.tsx | 10 +- 6 files changed, 136 insertions(+), 80 deletions(-) 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({ )} + +