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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<P: Serialize + Clone + Send + 'static>(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-7
@@ -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() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<Downloads
|
||||
items={downloads}
|
||||
onDismiss={(id) => setDownloads((d) => d.filter((x) => x.id !== id))}
|
||||
/>
|
||||
|
||||
{settingsOpen && (
|
||||
<Settings
|
||||
config={config}
|
||||
|
||||
@@ -1,61 +1,55 @@
|
||||
import type { Download } from "../types";
|
||||
import * as api from "../api";
|
||||
import type { Download } from "../types";
|
||||
|
||||
/**
|
||||
* What is being downloaded, and what just was.
|
||||
* What is being downloaded, and what just was — in the nav.
|
||||
*
|
||||
* WebKit saves the file on its own and says nothing about it, so without this
|
||||
* a download is indistinguishable from a click that did nothing.
|
||||
* Not floating over the page, which is where this started 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, and the only place a message like this is guaranteed to be
|
||||
* visible.
|
||||
*/
|
||||
export default function Downloads({
|
||||
items,
|
||||
collapsed,
|
||||
onDismiss,
|
||||
}: {
|
||||
items: Download[];
|
||||
collapsed: boolean;
|
||||
onDismiss: (id: number) => void;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[80] flex w-[320px] flex-col gap-2">
|
||||
{items.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-xl border border-slate-200
|
||||
bg-white/95 px-3 py-2.5 shadow-xl backdrop-blur
|
||||
dark:border-slate-800 dark:bg-slate-900/95"
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-slate-100 dark:bg-slate-800">
|
||||
{d.state === "done" ? (
|
||||
<svg viewBox="0 0 24 24" className="size-4 text-emerald-500" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : d.state === "failed" ? (
|
||||
<svg viewBox="0 0 24 24" className="size-4 text-red-500" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
) : (
|
||||
/* No total to divide by — the webview reports a start and a
|
||||
finish and nothing between — so this spins rather than fills. */
|
||||
<svg viewBox="0 0 24 24" className="size-4 animate-spin text-sky-500" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-medium">{d.name}</span>
|
||||
<span className="block text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{d.state === "done"
|
||||
<div className="flex flex-col gap-1 border-t border-slate-200 px-2 py-2 dark:border-slate-800">
|
||||
{items.map((d) => {
|
||||
const status =
|
||||
d.state === "done"
|
||||
? "Downloaded"
|
||||
: d.state === "failed"
|
||||
? "Download failed"
|
||||
? "Failed"
|
||||
: d.bytes > 0
|
||||
? `${formatBytes(d.bytes)} so far`
|
||||
: "Starting…"}
|
||||
: "Starting…";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
title={`${d.name} — ${status}`}
|
||||
className={
|
||||
"flex items-center gap-2 rounded-lg px-1.5 py-1 " +
|
||||
(collapsed ? "justify-center" : "")
|
||||
}
|
||||
>
|
||||
<Mark state={d.state} />
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] leading-tight">{d.name}</span>
|
||||
<span className="block text-[10px] text-slate-500 dark:text-slate-400">
|
||||
{status}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -63,7 +57,7 @@ export default function Downloads({
|
||||
<button
|
||||
onClick={() => api.reveal(d.path)}
|
||||
title="Show in Finder"
|
||||
className="shrink-0 cursor-pointer rounded-lg px-2 py-1 text-[11px] font-medium
|
||||
className="shrink-0 cursor-pointer rounded px-1 text-[10px] font-medium
|
||||
text-sky-600 hover:bg-sky-500/10 dark:text-sky-400"
|
||||
>
|
||||
Show
|
||||
@@ -73,20 +67,50 @@ export default function Downloads({
|
||||
onClick={() => onDismiss(d.id)}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 cursor-pointer rounded-lg p-1 text-slate-400 hover:text-slate-700
|
||||
dark:hover:text-slate-200"
|
||||
className="shrink-0 cursor-pointer rounded p-0.5 text-slate-400
|
||||
hover:text-slate-700 dark:hover:text-slate-200"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round">
|
||||
<svg viewBox="0 0 24 24" className="size-3" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.4" strokeLinecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mark({ state }: { state: Download["state"] }) {
|
||||
if (state === "done") {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4 shrink-0 text-emerald-500" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (state === "failed") {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4 shrink-0 text-red-500" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
/* No total to divide by — the webview reports a start and a finish and
|
||||
nothing between — so this spins rather than fills. */
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4 shrink-0 animate-spin text-sky-500" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
|
||||
@@ -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({
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<Downloads items={downloads} collapsed onDismiss={onDismissDownload} />
|
||||
<div className="flex w-full flex-col items-center border-t border-slate-200 py-2 dark:border-slate-800">
|
||||
{settingsButton}
|
||||
</div>
|
||||
@@ -226,6 +230,8 @@ export default function Nav({
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<Downloads items={downloads} collapsed={false} onDismiss={onDismissDownload} />
|
||||
|
||||
<footer className="flex items-center gap-1 border-t border-slate-200 px-1.5 py-2 dark:border-slate-800">
|
||||
{settingsButton}
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user