Skip to content

Commit 870ec46

Browse files
committed
feat: streamline decrypt completion flow
1 parent 02fd45b commit 870ec46

9 files changed

Lines changed: 200 additions & 13 deletions

File tree

api/src/jobs/store.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import { scopedLogger } from '#logger.js';
99
const log = scopedLogger('jobs');
1010
import { sendMailToUser } from '#mail.js';
1111
import { sendPushToUser } from '#push.js';
12-
import { getApiKeyById, getEffectiveDevices, getUserPrefs, isBundleWatched, latestActiveShareLinkExpiry, recordDeviceActivity, recordJobHistory, type DeviceRecord } from '#store/state.js';
12+
import { getApiKeyById, getEffectiveDevices, getUserPrefs, isBundleWatched, latestActiveShareLinkExpiry, recordDeviceActivity, recordJobHistory, recordShareLink, type DeviceRecord } from '#store/state.js';
1313
import { uninstallFromPrimaryDevice } from '#appStoreInstall.js';
1414
import { getCachedDeviceHealth } from '#deviceHealthCache.js';
1515
import { runDecrypt } from '#jobs/runner.js';
1616
import { appendJobTimelineEvent, type Job, type JobSource, type TestFlightJobSource } from '#jobs/types.js';
17+
import { buildSignedFileUrlWithToken } from '#util/signedUrl.js';
1718

1819
const jobs = new Map<string, Job>();
1920

@@ -100,6 +101,7 @@ loadDoneJobs();
100101
loadActiveJobs();
101102

102103
const RETRY_BACKOFF_MS = 5_000;
104+
const COMPLETION_SHARE_TTL_MINUTES = 60;
103105

104106
function sleep(ms: number): Promise<void> {
105107
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -468,6 +470,19 @@ async function runOneJob(device: DeviceRecord, job: Job): Promise<void> {
468470
recordJobHistory(toHistoryEntry(job));
469471
emitJobsChanged();
470472

473+
const completionShare = job.status === 'done'
474+
? buildSignedFileUrlWithToken(job.id, COMPLETION_SHARE_TTL_MINUTES)
475+
: undefined;
476+
if (completionShare) {
477+
recordShareLink(
478+
job.id,
479+
job.bundleId,
480+
completionShare.token,
481+
job.queuedBy ?? 'system',
482+
completionShare.expiresAtMs,
483+
);
484+
}
485+
471486
if (job.queuedBy) {
472487
const prefs = getUserPrefs(job.queuedBy);
473488
const label = job.versionLabel ? `${job.bundleId} (${job.versionLabel})` : job.bundleId;
@@ -479,13 +494,18 @@ async function runOneJob(device: DeviceRecord, job: Job): Promise<void> {
479494
void sendPushToUser(job.queuedBy, {
480495
title,
481496
body,
482-
url: `/?job=${encodeURIComponent(job.id)}`,
483-
actions: [{ action: 'open-job', title: 'Open job' }],
497+
url: completionShare?.url ?? `/?job=${encodeURIComponent(job.id)}`,
498+
actions: completionShare
499+
? [{ action: 'download', title: 'Download' }]
500+
: [{ action: 'open-job', title: 'Open job' }],
484501
});
485502
}
486503

487504
const shouldMail = job.status === 'done' ? (prefs.emailOnSuccess ?? false) : (prefs.emailOnFailure ?? false);
488-
if (shouldMail) void sendMailToUser(job.queuedBy, { subject: title, text: body });
505+
if (shouldMail) void sendMailToUser(job.queuedBy, {
506+
subject: title,
507+
text: completionShare ? `${body}\n\nDownload: ${completionShare.url}` : body,
508+
});
489509
}
490510

491511
settle(job);

api/web/public/sw.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ self.addEventListener('push', (event) => {
5959

6060
self.addEventListener('notificationclick', (event) => {
6161
event.notification.close();
62+
if (event.action === 'download') {
63+
event.waitUntil(self.clients.openWindow(event.notification.data?.url ?? '/'));
64+
return;
65+
}
6266
event.waitUntil(
6367
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
6468
for (const client of clients) {

api/web/src/app.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,24 @@
109109
border-bottom-right-radius: 0.7rem;
110110
}
111111

112+
.responsive-table tbody tr.job-highlight > td,
113+
.responsive-table tbody tr.queue-drag-over > td {
114+
background: color-mix(in srgb, var(--color-panel-muted) 82%, var(--color-accent) 18%);
115+
border-color: color-mix(in srgb, var(--color-border) 42%, var(--color-accent) 58%);
116+
}
117+
118+
.responsive-table tbody tr.job-highlight > td:first-child,
119+
.responsive-table tbody tr.queue-drag-over > td:first-child {
120+
border-top-left-radius: 0.7rem;
121+
border-bottom-left-radius: 0.7rem;
122+
}
123+
124+
.responsive-table tbody tr.job-highlight > td:last-child,
125+
.responsive-table tbody tr.queue-drag-over > td:last-child {
126+
border-top-right-radius: 0.7rem;
127+
border-bottom-right-radius: 0.7rem;
128+
}
129+
112130
code {
113131
@apply bg-panel-muted rounded px-1.5 py-0.5 font-mono text-xs;
114132
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<script lang="ts">
2+
import CopyButton from '#components/CopyButton.svelte';
3+
import { fetchJobStatus, fetchShareLinks } from '#lib/api';
4+
import { appDisplayName } from '#lib/appCatalog.svelte';
5+
import Button from '#lib/components/ui/Button.svelte';
6+
import Dialog from '#lib/components/ui/Dialog.svelte';
7+
import { myDecryptsState, updateDecrypt, type TrackedDecrypt } from '#lib/decrypts.svelte';
8+
import { fmtUntil } from '#lib/format';
9+
import { notifyJobFinished } from '#lib/notifications';
10+
import { playChime, vibrateCompletion } from '#lib/sound';
11+
import { showToast, soundEnabledState } from '#lib/ui.svelte';
12+
13+
interface CompletedDecrypt {
14+
label: string;
15+
url: string;
16+
expiresAt: number;
17+
}
18+
19+
let pollTimer: ReturnType<typeof setTimeout> | undefined;
20+
let completed = $state<CompletedDecrypt[]>([]);
21+
const current = $derived(completed[0]);
22+
23+
function decryptLabel(d: TrackedDecrypt): string {
24+
const name = appDisplayName(d.bundleId, d.trackName);
25+
return d.versionLabel ? `${name} (${d.versionLabel})` : name;
26+
}
27+
28+
async function presentCompletion(d: TrackedDecrypt): Promise<void> {
29+
const links = await fetchShareLinks(d.id);
30+
const link = links.links.find((entry) => entry.url);
31+
const label = decryptLabel(d);
32+
33+
if (!link?.url) {
34+
showToast(`${label} finished, but its share link is unavailable.`, 'error', { track: true });
35+
notifyJobFinished('Decrypt finished', `${label} is ready to download.`);
36+
return;
37+
}
38+
39+
const url = link.url;
40+
completed = [...completed, { label, url, expiresAt: link.expiresAt }];
41+
notifyJobFinished('Decrypt finished', `${label} is ready to download.`, url);
42+
showToast(`${label} is ready to download.`, 'success', {
43+
track: true,
44+
downloadUrl: url,
45+
action: {
46+
label: 'Download',
47+
onClick: () => window.location.assign(url),
48+
},
49+
});
50+
}
51+
52+
async function poll(): Promise<void> {
53+
clearTimeout(pollTimer);
54+
if (document.hidden) return;
55+
const pending = myDecryptsState.items.filter(
56+
(d) => d.status !== 'done' && d.status !== 'failed',
57+
);
58+
if (pending.length === 0) return;
59+
60+
for (const d of pending) {
61+
try {
62+
const data = await fetchJobStatus(d.id);
63+
const finished = d.status !== data.status && (data.status === 'done' || data.status === 'failed');
64+
updateDecrypt(d.id, {
65+
status: data.status,
66+
progress: data.progress,
67+
queue: data.queue,
68+
error: data.error,
69+
fileExpiresAt: data.fileExpiresAt,
70+
});
71+
if (!finished) continue;
72+
if (soundEnabledState.value) {
73+
playChime();
74+
vibrateCompletion(data.status === 'done');
75+
}
76+
if (data.status === 'done') await presentCompletion(d);
77+
else {
78+
const label = decryptLabel(d);
79+
const message = `${label} failed: ${data.error ?? 'unknown error'}`;
80+
notifyJobFinished('Decrypt failed', message);
81+
showToast(message, 'error', { track: true });
82+
}
83+
} catch {}
84+
}
85+
86+
pollTimer = setTimeout(poll, 2500);
87+
}
88+
89+
function onVisibilityChange(): void {
90+
if (!document.hidden) void poll();
91+
}
92+
93+
$effect(() => {
94+
void poll();
95+
document.addEventListener('visibilitychange', onVisibilityChange);
96+
return () => {
97+
clearTimeout(pollTimer);
98+
document.removeEventListener('visibilitychange', onVisibilityChange);
99+
};
100+
});
101+
102+
function onOpenChange(open: boolean): void {
103+
if (!open) completed = completed.slice(1);
104+
}
105+
</script>
106+
107+
<Dialog open={Boolean(current)} {onOpenChange} class="max-w-md">
108+
{#if current}
109+
<div class="mb-1 text-sm font-medium">Decrypt ready</div>
110+
<div class="mb-3 text-xs text-muted">
111+
{current.label} has a registered share link with unlimited downloads.
112+
</div>
113+
<div class="bg-panel-muted mb-4 flex items-center gap-2 rounded-lg p-2">
114+
<code class="min-w-0 flex-1 truncate" title={current.url}>{current.url}</code>
115+
<CopyButton text={current.url} label="Copy" />
116+
</div>
117+
<div class="mb-4 text-xs text-muted">Expires {fmtUntil(current.expiresAt)}. It is available in Share links.</div>
118+
<div class="flex gap-2">
119+
<a href={current.url} class="bg-accent text-accent-contrast hover:opacity-90 inline-flex h-8 flex-1 items-center justify-center rounded-md px-3 text-xs font-medium">Download</a>
120+
<Button variant="secondary" class="flex-1" onclick={() => onOpenChange(false)}>Close</Button>
121+
</div>
122+
{/if}
123+
</Dialog>

api/web/src/components/NotificationBell.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@
5252
{/if}
5353
<div class="min-w-0 flex-1">
5454
<div class="text-text">{t.message}</div>
55-
<div class="text-muted mt-0.5"><RelativeTime ms={t.ts} /></div>
55+
<div class="mt-0.5 flex items-center justify-between gap-2">
56+
<span class="text-muted"><RelativeTime ms={t.ts} /></span>
57+
{#if t.downloadUrl}
58+
<a href={t.downloadUrl} class="text-accent hover:text-text font-medium">Download</a>
59+
{/if}
60+
</div>
5661
</div>
5762
</div>
5863
{/each}

api/web/src/lib/notifications.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ export function requestNotificationPermission(): void {
33
void Notification.requestPermission();
44
}
55

6-
export function notifyJobFinished(title: string, body: string): void {
6+
export function notifyJobFinished(title: string, body: string, downloadUrl?: string): void {
77
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
88
if (!document.hidden) return;
99
const n = new Notification(title, { body, icon: '/favicon.svg' });
1010
n.onclick = () => {
1111
window.focus();
12+
if (downloadUrl) window.location.assign(downloadUrl);
1213
n.close();
1314
};
1415
}

api/web/src/lib/ui.svelte.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export interface ToastHistoryEntry {
110110
message: string;
111111
type: 'success' | 'error';
112112
ts: number;
113+
downloadUrl?: string;
113114
}
114115

115116
const MAX_TOAST_HISTORY = 20;
@@ -141,7 +142,7 @@ export function clearToastHistory(): void {
141142
export function showToast(
142143
message: string,
143144
type: 'success' | 'error' = 'success',
144-
options?: { track?: boolean; action?: { label: string; onClick: () => void }; id?: string },
145+
options?: { track?: boolean; action?: { label: string; onClick: () => void }; id?: string; downloadUrl?: string },
145146
): void {
146147
const toastOptions = options?.action || options?.id ? { action: options.action, id: options.id } : undefined;
147148
if (type === 'error') toast.error(message, toastOptions);
@@ -150,7 +151,7 @@ export function showToast(
150151
const track = options?.track ?? type === 'error';
151152
if (!track) return;
152153

153-
toastHistoryState.items = [{ id: crypto.randomUUID(), message, type, ts: Date.now() }, ...toastHistoryState.items].slice(0, MAX_TOAST_HISTORY);
154+
toastHistoryState.items = [{ id: crypto.randomUUID(), message, type, ts: Date.now(), downloadUrl: options?.downloadUrl }, ...toastHistoryState.items].slice(0, MAX_TOAST_HISTORY);
154155
persistToastHistory();
155156
}
156157

api/web/src/tabs/Home.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<script lang="ts">
22
import DonationNudge from '#components/DonationNudge.svelte';
3+
import DecryptCompletion from '#components/DecryptCompletion.svelte';
34
import OnboardingBanner from '#components/OnboardingBanner.svelte';
45
import { batchDecryptJumpState, focusSearchJumpState } from '#lib/ui.svelte';
56
import ActiveJobsPanel from '#tabs/home/ActiveJobsPanel.svelte';
67
import DecryptPanel from '#tabs/home/DecryptPanel.svelte';
78
import JobHistoryPanel from '#tabs/home/JobHistoryPanel.svelte';
8-
import MyRequestsPanel from '#tabs/home/MyRequestsPanel.svelte';
99
1010
let decryptPanel: DecryptPanel | undefined = $state();
1111
@@ -36,7 +36,7 @@
3636
<OnboardingBanner />
3737
<DecryptPanel bind:this={decryptPanel} />
3838
<DonationNudge />
39-
<MyRequestsPanel />
39+
<DecryptCompletion />
4040
<ActiveJobsPanel />
4141
<JobHistoryPanel />
4242
</div>

api/web/src/tabs/home/ActiveJobsPanel.svelte

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
confirmDialog,
3030
requestFocusSearch,
3131
} from "#lib/ui.svelte";
32+
import { highlightJobIdState } from "#lib/decrypts.svelte";
3233
3334
const jobs = $derived(liveState.overview?.activeJobs ?? []);
3435
const loaded = $derived(liveState.overviewLoaded);
@@ -39,6 +40,7 @@
3940
let cancelling = $state<Set<string>>(new Set());
4041
let prioritizing = $state<Set<string>>(new Set());
4142
let selected = $state<Set<string>>(new Set());
43+
let highlightedId = $state<string | null>(null);
4244
let bulkCancelling = $state(false);
4345
let bulkPrioritizing = $state(false);
4446
$effect(() => {
@@ -48,6 +50,19 @@
4850
}
4951
});
5052
53+
$effect(() => {
54+
const id = highlightJobIdState.id;
55+
if (!id || !jobs.some((job) => job.id === id)) return;
56+
highlightedId = id;
57+
const row = document.querySelector(`[data-job-id="${CSS.escape(id)}"]`);
58+
row?.scrollIntoView({ behavior: "smooth", block: "center" });
59+
const timer = setTimeout(() => {
60+
highlightedId = null;
61+
if (highlightJobIdState.id === id) highlightJobIdState.id = null;
62+
}, 2000);
63+
return () => clearTimeout(timer);
64+
});
65+
5166
$effect(() => {
5267
void ensureAppCatalog(jobs.map((job) => job.bundleId));
5368
});
@@ -264,9 +279,9 @@
264279
{:else}
265280
{#each jobs as j (j.id)}
266281
<tr
267-
class={dragOverId === j.id
268-
? "bg-panel-muted/80 rounded-lg"
269-
: ""}
282+
data-job-id={j.id}
283+
class:job-highlight={j.id === highlightedId}
284+
class:queue-drag-over={dragOverId === j.id}
270285
draggable={canCancel && j.status === "queued"}
271286
ondragstart={() => onRowDragStart(j)}
272287
ondragover={(e) => onRowDragOver(e, j)}

0 commit comments

Comments
 (0)