f167c02946
Closing the window no longer kills in-progress downloads, the library gains the same copied-link suggestion the downloads tab has, and the in-app updater can now authenticate to a sign-in-required Gitea. Background / tray: - The window's close handler now hides to the tray (instead of quitting) whenever a download is in flight, even if "Keep running in the tray" is off — quitting was killing the spawned yt-dlp processes. A one-time notification explains the app is still running. (download.ts exposes hasActiveDownloads().) - tray.ts now falls back to an embedded icon when no build/icon.ico ships, so the tray actually appears — previously an empty icon meant the tray was skipped and a minimized window could be stranded with no way back. - New "Start with Windows" setting (launchAtStartup) wired to app.setLoginItemSettings, synced at startup and on toggle. Useful with auto-download so watched channels stay current in the background. - The "Keep running in the tray" hint now explains it also enables background auto-download of new uploads. Library clipboard detection: - Extracted the downloads tab's clipboard watcher into a shared useClipboardLink hook and used it in the library's add-source field, so a copied channel/playlist link is offered there too. Updater fix (works on a private / sign-in-required instance): - Added an optional updateToken setting. When set, the updater sends it as a Gitea Authorization header on the release check, the checksum fetch, and the installer download — so "Check for updates" works where anonymous access is blocked (the previous "could not reach the update server" case). Blank = anonymous, unchanged. No token is ever shipped; it's only ever sent to the host-pinned update host. Settings gains a masked "Update access token" field. Typecheck clean; 187 tests pass; electron-vite build clean. New UI verified in the browser preview (tray/startup toggles, token field, library copied-link banner). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
241 lines
9.4 KiB
TypeScript
241 lines
9.4 KiB
TypeScript
import './assets/base.css'
|
||
import React from 'react'
|
||
import ReactDOM from 'react-dom/client'
|
||
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc'
|
||
import App from './App'
|
||
|
||
// In the standalone UI preview (browser, no Electron preload) window.api isn't
|
||
// injected. Stub the full surface so the UI renders and stays interactive during
|
||
// design work. The store detects preview mode (no window.electron) and drives a
|
||
// fake progress ticker instead of calling these, so most are inert no-ops.
|
||
// In the real Electron app this branch is skipped — preload provides window.api.
|
||
if (import.meta.env.DEV && !window.api) {
|
||
const MOCK_SETTINGS: Settings = {
|
||
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
||
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
||
defaultKind: 'video',
|
||
defaultVideoQuality: 'Best available',
|
||
defaultAudioQuality: 'Best (MP3)',
|
||
maxConcurrent: 2,
|
||
filenameTemplate: '%(title)s.%(ext)s',
|
||
theme: 'light',
|
||
accentColor: 'teal',
|
||
clipboardWatch: true,
|
||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||
proxy: '',
|
||
rateLimit: '',
|
||
useAria2c: false,
|
||
cookieSource: 'none',
|
||
cookiesBrowser: 'chrome',
|
||
youtubePlayerClient: '',
|
||
youtubePoToken: '',
|
||
restrictFilenames: false,
|
||
downloadArchive: false,
|
||
autoUpdateYtdlp: true,
|
||
ytdlpChannel: 'nightly',
|
||
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
|
||
customCommandEnabled: false,
|
||
defaultTemplateId: null,
|
||
notifyOnComplete: true,
|
||
autoDownloadNew: true,
|
||
hasCompletedOnboarding: true,
|
||
minimizeToTray: false,
|
||
launchAtStartup: false,
|
||
updateToken: ''
|
||
}
|
||
// 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' }
|
||
]
|
||
|
||
window.api = {
|
||
getAppVersion: async () => '0.4.0-preview',
|
||
checkForAppUpdate: async () => {
|
||
await new Promise((r) => setTimeout(r, 400))
|
||
return {
|
||
ok: true,
|
||
available: true,
|
||
currentVersion: '0.4.0',
|
||
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.'
|
||
}),
|
||
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 () => {},
|
||
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
||
chooseFolder: async () => null,
|
||
openPath: async () => '',
|
||
showInFolder: async () => {},
|
||
readClipboard: async () => '',
|
||
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 () => [],
|
||
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 }),
|
||
removeSource: async () => [],
|
||
markSourceItemDownloaded: async () => [],
|
||
setSourceWatched: async () => [],
|
||
syncSources: 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: () => {}
|
||
}
|
||
}
|
||
|
||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||
<React.StrictMode>
|
||
<App />
|
||
</React.StrictMode>
|
||
)
|