Files
AeroFetch/src/renderer/src/mockApi.ts
T
debont80 e19f988c72 feat(audit): Batch 15 live-verify — download-engine lifecycle (R4, L65, L139, B2, B6, L143)
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>
2026-07-01 21:36:22 -04:00

267 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:
'### Whats 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
}