e19f988c72
The watched live session for the six deferred lifecycle items, each verified against real yt-dlp/Electron behavior on Windows: - R4: cancel now deletes the download's orphaned partials. The engine captures the final output path via a new `--print before_dl:dest|%(filename)s` (empirically %(filepath)s is still 'NA' that early) and the close handler sweeps only unambiguous intermediates (.part / .part-Frag* / .ytdl / <stem>.fNNN.<ext>) via the pure, unit-tested lib/partials.ts — never a bare <stem>.<ext> or another download's files. Live-verified with decoys. - L65: verified-acceptable, no code change — a taskkill /F pause leaves the .part byte-intact and yt-dlp --continue resumes at the exact byte offset. - L139: spawn↔self-update interlock (new ytdlpLock.ts). The updater refuses while any yt-dlp spawn is live; download/terminal IPC handlers hold (await) behind an in-progress exe rewrite instead of failing. - B2: source indexing is cancellable — AbortSignal through the probe walk (kills the in-flight child, live-verified via tasklist), sources:index-cancel IPC, and a Cancel button in the Library add-source row. - B6: app-update download is cancellable — app:update-cancel routes through the existing finish() teardown (abort + destroy + unlink, live-verified on Electron net.request); Cancel button under the update progress bar. The checksum phase is cancelable too. - L143: closing the window mid-download (non-tray mode) now offers a native tray-independent "Quit anyway / Keep downloading" dialog, replacing the passive "use the tray to quit" notification. Peer-review pass caught and fixed: a non-reentrant update lock (concurrent begin clobbered the settle promise), a dead Cancel window during the B6 checksum fetch + a canceller slot-ownership bug (L140 shape), and a persist-after-cancel hole in the index walk. typecheck + 304 tests + eslint + production build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
267 lines
9.4 KiB
TypeScript
267 lines
9.4 KiB
TypeScript
import { DEFAULT_SETTINGS, type Settings, type CommandTemplate } from '@shared/ipc'
|
||
|
||
/**
|
||
* The browser-preview stand-in for the real `window.api` IPC bridge (C1). In the
|
||
* standalone UI preview (browser, no Electron preload) `window.api` isn't
|
||
* injected, so main.tsx assigns this mock to keep the UI interactive during
|
||
* design work. Typed as the real `Api` surface (`Window['api']`) so it can't
|
||
* silently drift from the preload contract — a missing or mistyped method is a
|
||
* compile error. Most methods are inert no-ops because the stores detect preview
|
||
* mode (no window.electron) and drive a fake progress ticker instead.
|
||
*/
|
||
type Api = Window['api']
|
||
|
||
// The preview settings: the canonical defaults, plus preview-only tweaks —
|
||
// placeholder folders, a nightly channel + a recent check to exercise the
|
||
// updater card, auto-download on, and onboarding already dismissed so design
|
||
// work isn't blocked behind the welcome screen.
|
||
const MOCK_SETTINGS: Settings = {
|
||
...DEFAULT_SETTINGS,
|
||
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
||
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
||
ytdlpChannel: 'nightly',
|
||
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
|
||
autoDownloadNew: true,
|
||
hasCompletedOnboarding: true
|
||
}
|
||
|
||
// One mock "installed version" so the sidebar (getAppVersion) and the update card
|
||
// (checkForAppUpdate.currentVersion) agree instead of disagreeing 0.4.0-preview vs
|
||
// 0.4.0 (L171). Kept below the mock "latest" so the updater card demos an available
|
||
// update in the browser preview.
|
||
const MOCK_CURRENT_VERSION = '0.4.0'
|
||
|
||
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
|
||
// sign-in/clear flow be exercised in this browser-only preview.
|
||
let mockCookiesSavedAt: number | null = null
|
||
// Stands in for templates.json — lets the Custom commands card's CRUD and
|
||
// the download bar's template picker be exercised in this browser-only preview.
|
||
let mockTemplates: CommandTemplate[] = [
|
||
{ id: 'tpl1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' }
|
||
]
|
||
|
||
export const mockApi: Api = {
|
||
getAppVersion: async () => MOCK_CURRENT_VERSION,
|
||
checkForAppUpdate: async () => {
|
||
await new Promise((r) => setTimeout(r, 400))
|
||
return {
|
||
ok: true,
|
||
available: true,
|
||
currentVersion: MOCK_CURRENT_VERSION,
|
||
latestVersion: '0.5.0',
|
||
notes:
|
||
'### What’s new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
|
||
htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases',
|
||
downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe',
|
||
assetName: 'AeroFetch-Setup-0.5.0.exe'
|
||
}
|
||
},
|
||
downloadAppUpdate: async () => ({
|
||
ok: false,
|
||
error: 'Downloading updates is disabled in the browser preview.'
|
||
}),
|
||
cancelAppUpdate: async () => {},
|
||
runAppUpdate: async () => ({ ok: true }),
|
||
onAppUpdateProgress: () => () => {},
|
||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||
getFfmpegVersions: async () => ({
|
||
ffmpeg: '7.1-full_build (UI preview mock)',
|
||
ffprobe: '7.1-full_build (UI preview mock)'
|
||
}),
|
||
probe: async (url: string) => {
|
||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
|
||
if (/list=|playlist/i.test(url)) {
|
||
return {
|
||
ok: true,
|
||
kind: 'playlist',
|
||
playlist: {
|
||
title: 'Full Electron Course — All Episodes',
|
||
uploader: 'DevChannel',
|
||
count: 5,
|
||
entries: Array.from({ length: 5 }, (_, i) => ({
|
||
index: i + 1,
|
||
id: `vid${i + 1}`,
|
||
title: `Episode ${i + 1} — ${['Setup', 'Main Process', 'IPC', 'Packaging', 'Release'][i]}`,
|
||
url: `https://youtube.com/watch?v=vid${i + 1}`,
|
||
durationLabel: `${10 + i}:0${i}`,
|
||
uploader: 'DevChannel'
|
||
}))
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
ok: true,
|
||
kind: 'video',
|
||
info: {
|
||
title: 'Building a Desktop App with Electron — Full Course',
|
||
channel: 'DevChannel',
|
||
durationLabel: '1:42:08',
|
||
thumbnail: undefined,
|
||
formats: [
|
||
{ id: '__best__', label: 'Best available', ext: 'mp4', hasAudio: true },
|
||
{
|
||
id: '299',
|
||
label: '1080p60 · mp4 · 248 MB',
|
||
height: 1080,
|
||
ext: 'mp4',
|
||
filesizeLabel: '248 MB',
|
||
hasAudio: false
|
||
},
|
||
{
|
||
id: '136',
|
||
label: '720p · mp4 · 124 MB',
|
||
height: 720,
|
||
ext: 'mp4',
|
||
filesizeLabel: '124 MB',
|
||
hasAudio: false
|
||
},
|
||
{
|
||
id: '135',
|
||
label: '480p · mp4 · 72 MB',
|
||
height: 480,
|
||
ext: 'mp4',
|
||
filesizeLabel: '72 MB',
|
||
hasAudio: false
|
||
},
|
||
{
|
||
id: '134',
|
||
label: '360p · mp4 · 38 MB',
|
||
height: 360,
|
||
ext: 'mp4',
|
||
filesizeLabel: '38 MB',
|
||
hasAudio: false
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
startDownload: async () => ({ ok: true }),
|
||
cancelDownload: async () => {},
|
||
pauseDownload: async () => {},
|
||
chooseFolder: async () => null,
|
||
openPath: async () => '',
|
||
openUrl: async () => {},
|
||
showInFolder: async () => '',
|
||
readClipboard: async () => '',
|
||
logError: () => {},
|
||
getSettings: async () => MOCK_SETTINGS,
|
||
setSettings: async (partial) => Object.assign(MOCK_SETTINGS, partial),
|
||
listHistory: async () => [],
|
||
addHistory: async () => [],
|
||
removeHistory: async () => [],
|
||
removeManyHistory: async () => [],
|
||
clearHistory: async () => [],
|
||
cookiesLogin: async () => {
|
||
await new Promise((r) => setTimeout(r, 600)) // simulate the sign-in window
|
||
mockCookiesSavedAt = Date.now()
|
||
return { ok: true, cookieCount: 14 }
|
||
},
|
||
cookiesStatus: async () =>
|
||
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
|
||
cookiesClear: async () => {
|
||
mockCookiesSavedAt = null
|
||
},
|
||
listTemplates: async () => mockTemplates,
|
||
saveTemplate: async (template) => {
|
||
mockTemplates = [template, ...mockTemplates.filter((t) => t.id !== template.id)]
|
||
return mockTemplates
|
||
},
|
||
removeTemplate: async (id) => {
|
||
mockTemplates = mockTemplates.filter((t) => t.id !== id)
|
||
return mockTemplates
|
||
},
|
||
previewCommand: async (opts) => {
|
||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
||
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
|
||
return {
|
||
ok: true,
|
||
command: `yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
|
||
}
|
||
},
|
||
updateYtdlp: async (channel) => {
|
||
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
|
||
return {
|
||
ok: true,
|
||
output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)`
|
||
}
|
||
},
|
||
// No background updater in the browser preview — hand back an inert unsubscribe.
|
||
onYtdlpAutoUpdateStatus: () => () => {},
|
||
listErrorLog: async () => [],
|
||
clearErrorLog: async () => [],
|
||
listQueue: async () => [],
|
||
saveQueue: async () => {},
|
||
exportBackup: async () => ({
|
||
ok: true,
|
||
path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json'
|
||
}),
|
||
importBackup: async () => ({ ok: true }),
|
||
onDownloadEvent: () => () => {},
|
||
getSystemTheme: async () => ({
|
||
shouldUseDarkColors: false,
|
||
shouldUseHighContrastColors: false
|
||
}),
|
||
onSystemThemeUpdate: () => () => {},
|
||
openHighContrastSettings: async () => {},
|
||
onExternalUrl: () => () => {},
|
||
// Media-manager sources — a seeded channel so the (Phase H) Library view has
|
||
// demo data in this browser-only preview.
|
||
listSources: async () => [
|
||
{
|
||
id: 'src-demo',
|
||
url: 'https://www.youtube.com/@DevChannel',
|
||
kind: 'channel',
|
||
title: 'DevChannel',
|
||
channel: 'DevChannel',
|
||
addedAt: Date.now() - 86_400_000,
|
||
lastIndexedAt: Date.now() - 3_600_000,
|
||
itemCount: 7
|
||
}
|
||
],
|
||
indexSource: async (url) => {
|
||
await new Promise((r) => setTimeout(r, 800)) // simulate the index walk
|
||
return {
|
||
ok: true,
|
||
source: {
|
||
id: 'src-demo',
|
||
url,
|
||
kind: 'channel',
|
||
title: 'DevChannel',
|
||
channel: 'DevChannel',
|
||
addedAt: Date.now(),
|
||
lastIndexedAt: Date.now(),
|
||
itemCount: 7
|
||
},
|
||
itemCount: 7
|
||
}
|
||
},
|
||
reindexSource: async () => ({ ok: true, itemCount: 7, newCount: 0 }),
|
||
cancelIndex: async () => {},
|
||
removeSource: async () => [],
|
||
setMediaItemDownloaded: async () => [],
|
||
setSourceWatched: async () => [],
|
||
syncWatchedSources: async () => ({ ok: true, newItems: [] }),
|
||
getScheduledSync: async () => ({ enabled: false }),
|
||
setScheduledSync: async (enabled: boolean) => ({ enabled }),
|
||
listSourceItems: async (sourceId) =>
|
||
Array.from({ length: 7 }, (_, i) => ({
|
||
id: `${sourceId}:vid${i + 1}`,
|
||
sourceId,
|
||
videoId: `vid${i + 1}`,
|
||
title: `Episode ${i + 1}`,
|
||
url: `https://youtube.com/watch?v=vid${i + 1}`,
|
||
playlistTitle: i < 5 ? 'Full Electron Course' : 'Uploads',
|
||
playlistIndex: i < 5 ? i + 1 : i - 4,
|
||
durationLabel: `${10 + i}:0${i}`,
|
||
downloaded: i < 2
|
||
})),
|
||
onIndexProgress: () => () => {},
|
||
runTerminal: async () => ({ ok: true }),
|
||
cancelTerminal: async () => {},
|
||
onTerminalOutput: () => () => {},
|
||
setTaskbarProgress: async () => {},
|
||
mintPoToken: async () => null
|
||
}
|