From 18678b614f2ab27994e61704b23dc705ff055b48 Mon Sep 17 00:00:00 2001 From: Sloth Date: Sat, 5 Sep 2026 18:38:55 -0700 Subject: [PATCH 1/4] feat(app): add theme selector and SlothUI default theme to the UI --- package-lock.json | 4 +- package.json | 2 +- src/electron/main.mjs | 20 ++++++++- src/electron/preload.cjs | 2 + src/electron/settings.mjs | 13 ++++++ src/types/electron-api.d.ts | 2 + src/ui/App.tsx | 4 +- src/ui/hooks/useThemeMode.test.tsx | 37 ++++++++++++---- src/ui/hooks/useThemeMode.tsx | 23 ++++++++-- src/ui/screens/OptionsScreen.test.tsx | 10 +++++ src/ui/screens/OptionsScreen.tsx | 40 +++++++++++++----- src/ui/theme.ts | 61 +++++++++++++++++++++++++-- testing/mockData/electronAPIMocks.ts | 2 + 13 files changed, 186 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 969640d..1777880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sloth-archiver", - "version": "0.27.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sloth-archiver", - "version": "0.27.1", + "version": "1.0.0", "license": "GPL-3.0-or-later", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index eed54b5..f45697e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sloth-archiver", "private": true, - "version": "0.27.1", + "version": "1.0.0", "description": "A desktop app for downloading and archiving videos, with a built-in library and playlist tracking.", "author": "Sloth ", "license": "GPL-3.0-or-later", diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 5bd18c0..2feb26c 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -11,7 +11,7 @@ import { getSupportedVideoFilters, allVideoFilter } from './utils/constants.mjs' import { getCurrentYtdlpVersion, isNewerVersion, performYtdlpUpdate } from './updater.mjs'; import { resolveLatestRelease, YTDLP_VERIFICATION_ERROR_CODE } from './ytdlpRelease.mjs'; import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile } from './library.mjs'; -import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection } from './settings.mjs'; +import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName } from './settings.mjs'; import { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs'; import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs'; import { createFfmpegRunner } from './ffmpegUtils.mjs'; @@ -494,7 +494,11 @@ ipcMain.handle('settings:setLibrarySort', async (e, { sortField, sortDirection } ipcMain.handle('settings:getThemeMode', async () => { const { themeMode } = readSettings(); - return { themeMode: themeMode === 'dark' ? 'dark' : 'light' }; + // Dark is the default for a fresh install (paired with SlothUI as the + // default theme name, see THEME_NAME_DEFAULT) -- only an explicit + // 'light' choice overrides it, so this checks for 'light' rather than + // defaulting to it. + return { themeMode: themeMode === 'light' ? 'light' : 'dark' }; }); ipcMain.handle('settings:setThemeMode', async (e, mode) => { @@ -504,6 +508,18 @@ ipcMain.handle('settings:setThemeMode', async (e, mode) => { return { success: true, themeMode: settings.themeMode }; }); +ipcMain.handle('settings:getThemeName', async () => { + const { themeName } = readSettings(); + return { themeName: clampThemeName(themeName) }; +}); + +ipcMain.handle('settings:setThemeName', async (e, name) => { + const settings = readSettings(); + settings.themeName = clampThemeName(name); + writeSettings(settings); + return { success: true, themeName: settings.themeName }; +}); + ipcMain.handle('settings:getMaxSimultaneousDownloads', async () => { const { maxSimultaneousDownloads } = readSettings(); return { maxSimultaneousDownloads: clampMaxSimultaneousDownloads(maxSimultaneousDownloads ?? 1) }; diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index 0d569aa..bb4528d 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -44,6 +44,8 @@ contextBridge.exposeInMainWorld('electronAPI', { setLibrarySort: (payload) => ipcRenderer.invoke('settings:setLibrarySort', payload), getThemeMode: () => ipcRenderer.invoke('settings:getThemeMode'), setThemeMode: (mode) => ipcRenderer.invoke('settings:setThemeMode', mode), + getThemeName: () => ipcRenderer.invoke('settings:getThemeName'), + setThemeName: (name) => ipcRenderer.invoke('settings:setThemeName', name), getCustomConvertFormats: () => ipcRenderer.invoke('settings:getCustomConvertFormats'), setCustomConvertFormats: (formats) => ipcRenderer.invoke('settings:setCustomConvertFormats', formats), getMaxSimultaneousDownloads: () => ipcRenderer.invoke('settings:getMaxSimultaneousDownloads'), diff --git a/src/electron/settings.mjs b/src/electron/settings.mjs index b6a410a..338656c 100644 --- a/src/electron/settings.mjs +++ b/src/electron/settings.mjs @@ -47,6 +47,19 @@ export function clampLibrarySortDirection(value) { return value === 'desc' ? 'desc' : 'asc'; } +// The overall theme (color palette/typography), independent of light/dark +// mode -- 'default' is this app's original plain-MUI look, 'slothui' is the +// palette pulled from SlothArchiver-info's landing page (see theme.ts). +export const THEME_NAMES = ['default', 'slothui']; +// SlothUI is the default for a fresh install -- 'default' (plain MUI) is +// still fully supported and one toggle away, just no longer what a new user +// sees before ever touching Options. +export const THEME_NAME_DEFAULT = 'slothui'; + +export function clampThemeName(value) { + return THEME_NAMES.includes(value) ? value : THEME_NAME_DEFAULT; +} + // Bounds for the Library tab's thumbnail-size slider (LibraryBottomBar.tsx). // 160px floor keeps a video card's title/quality-chip row from wrapping // awkwardly; 360px ceiling still fits 2+ columns at typical content widths. diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index 283f2a1..5d4d88b 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -97,6 +97,8 @@ declare global { setLibrarySort: (payload: { sortField: LibrarySortField; sortDirection: LibrarySortDirection }) => Promise<{ success: boolean; sortField: LibrarySortField; sortDirection: LibrarySortDirection }> getThemeMode: () => Promise<{ themeMode: 'light' | 'dark' }> setThemeMode: (mode: 'light' | 'dark') => Promise<{ success: boolean; themeMode: 'light' | 'dark' }> + getThemeName: () => Promise<{ themeName: 'default' | 'slothui' }> + setThemeName: (name: 'default' | 'slothui') => Promise<{ success: boolean; themeName: 'default' | 'slothui' }> getCustomConvertFormats: () => Promise<{ customConvertFormats: string[] }> setCustomConvertFormats: (formats: string[]) => Promise<{ success: boolean; customConvertFormats: string[] }> getMaxSimultaneousDownloads: () => Promise<{ maxSimultaneousDownloads: number }> diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 21b4d3d..c45ea60 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -44,10 +44,10 @@ function useRendererErrorLogging() { // ancestor) can pick the actual MUI theme object -- ThemeModeProvider has to // wrap this, not live inside it. function AppContent() { - const { mode } = useThemeMode(); + const { mode, themeName } = useThemeMode(); return ( - + diff --git a/src/ui/hooks/useThemeMode.test.tsx b/src/ui/hooks/useThemeMode.test.tsx index ef1bf18..528979f 100644 --- a/src/ui/hooks/useThemeMode.test.tsx +++ b/src/ui/hooks/useThemeMode.test.tsx @@ -6,8 +6,14 @@ import { ThemeModeProvider, useThemeMode } from './useThemeMode'; beforeEach(() => { window.electronAPI = { ...window.electronAPI, - getThemeMode: vi.fn().mockResolvedValue({ themeMode: 'dark' }), - setThemeMode: vi.fn().mockResolvedValue({ success: true, themeMode: 'dark' }), + // Deliberately different from the hook's own initial state (dark + + // slothui, see useThemeMode.tsx) so a test actually observes the + // resolve-and-adopt transition rather than the mock coincidentally + // matching the default already in place. + getThemeMode: vi.fn().mockResolvedValue({ themeMode: 'light' }), + setThemeMode: vi.fn().mockResolvedValue({ success: true, themeMode: 'light' }), + getThemeName: vi.fn().mockResolvedValue({ themeName: 'default' }), + setThemeName: vi.fn().mockResolvedValue({ success: true, themeName: 'default' }), }; }); @@ -16,21 +22,34 @@ describe('useThemeMode', () => { expect(() => renderHook(() => useThemeMode())).toThrow(/must be used within a ThemeModeProvider/); }); - it('starts at "light" and adopts the persisted mode once getThemeMode resolves', async () => { + it('starts at "dark"/"slothui" (the fresh-install defaults) and adopts the persisted values once they resolve', async () => { const { result } = renderHook(() => useThemeMode(), { wrapper: ThemeModeProvider }); - expect(result.current.mode).toBe('light'); - await waitFor(() => expect(result.current.mode).toBe('dark')); + expect(result.current.mode).toBe('dark'); + expect(result.current.themeName).toBe('slothui'); + await waitFor(() => expect(result.current.mode).toBe('light')); + await waitFor(() => expect(result.current.themeName).toBe('default')); expect(window.electronAPI.getThemeMode).toHaveBeenCalledTimes(1); + expect(window.electronAPI.getThemeName).toHaveBeenCalledTimes(1); }); it('setMode updates state immediately and persists via electronAPI.setThemeMode', async () => { const { result } = renderHook(() => useThemeMode(), { wrapper: ThemeModeProvider }); - await waitFor(() => expect(result.current.mode).toBe('dark')); + await waitFor(() => expect(result.current.mode).toBe('light')); - act(() => result.current.setMode('light')); + act(() => result.current.setMode('dark')); - expect(result.current.mode).toBe('light'); - expect(window.electronAPI.setThemeMode).toHaveBeenCalledWith('light'); + expect(result.current.mode).toBe('dark'); + expect(window.electronAPI.setThemeMode).toHaveBeenCalledWith('dark'); + }); + + it('setThemeName updates state immediately and persists via electronAPI.setThemeName', async () => { + const { result } = renderHook(() => useThemeMode(), { wrapper: ThemeModeProvider }); + await waitFor(() => expect(result.current.themeName).toBe('default')); + + act(() => result.current.setThemeName('slothui')); + + expect(result.current.themeName).toBe('slothui'); + expect(window.electronAPI.setThemeName).toHaveBeenCalledWith('slothui'); }); }); diff --git a/src/ui/hooks/useThemeMode.tsx b/src/ui/hooks/useThemeMode.tsx index 834687d..fab87af 100644 --- a/src/ui/hooks/useThemeMode.tsx +++ b/src/ui/hooks/useThemeMode.tsx @@ -1,14 +1,24 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; -import type { ThemeMode } from '../theme'; +import type { ThemeMode, ThemeName } from '../theme'; // Single instance at the app root (see ThemeModeProvider below) -- both // App.tsx (which picks the actual MUI theme object) and the Options tab's -// selector need to read/drive the same value, not independent copies. +// selectors need to read/drive the same values, not independent copies. +// Covers both the light/dark mode and the overall theme (name) -- kept in +// one hook/provider since both are read together to pick the final MUI +// theme object (see App.tsx's getTheme(mode, themeName)), not because +// they're otherwise related. function useThemeModeState() { - const [mode, setModeState] = useState('light'); + // Matches the main process's own fresh-install defaults (dark + SlothUI, + // see settings.mjs's THEME_NAME_DEFAULT and main.mjs's getThemeMode + // handler) so the very first paint, before getThemeMode/getThemeName + // resolve, doesn't flash the old light/default look first. + const [mode, setModeState] = useState('dark'); + const [themeName, setThemeNameState] = useState('slothui'); useEffect(() => { window.electronAPI.getThemeMode().then(({ themeMode }) => setModeState(themeMode)); + window.electronAPI.getThemeName().then(({ themeName }) => setThemeNameState(themeName)); }, []); const setMode = (next: ThemeMode) => { @@ -16,7 +26,12 @@ function useThemeModeState() { window.electronAPI.setThemeMode(next); }; - return { mode, setMode }; + const setThemeName = (next: ThemeName) => { + setThemeNameState(next); + window.electronAPI.setThemeName(next); + }; + + return { mode, setMode, themeName, setThemeName }; } type ThemeModeContextValue = ReturnType; diff --git a/src/ui/screens/OptionsScreen.test.tsx b/src/ui/screens/OptionsScreen.test.tsx index 1846d6b..ebd2664 100644 --- a/src/ui/screens/OptionsScreen.test.tsx +++ b/src/ui/screens/OptionsScreen.test.tsx @@ -30,6 +30,8 @@ beforeEach(() => { removeYtdlpUpdateProgressListener: vi.fn(), getThemeMode: vi.fn().mockResolvedValue({ themeMode: 'light' }), setThemeMode: vi.fn().mockResolvedValue({ success: true, themeMode: 'dark' }), + getThemeName: vi.fn().mockResolvedValue({ themeName: 'default' }), + setThemeName: vi.fn().mockResolvedValue({ success: true, themeName: 'slothui' }), getMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ maxSimultaneousDownloads: 1 }), setMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ success: true, maxSimultaneousDownloads: 1 }), getAppVersion: vi.fn().mockResolvedValue('0.0.0'), @@ -61,6 +63,14 @@ describe('OptionsScreen', () => { expect(window.electronAPI.setThemeMode).toHaveBeenCalledWith('dark'); }); + it('switching the theme name dropdown persists the new theme', async () => { + const user = userEvent.setup(); + renderScreen(); + await user.click(screen.getByRole('combobox', { name: 'Theme' })); + await user.click(screen.getByRole('option', { name: 'SlothUI' })); + expect(window.electronAPI.setThemeName).toHaveBeenCalledWith('slothui'); + }); + it('choosing a download folder persists the picked path and updates the display', async () => { const user = userEvent.setup(); (window.electronAPI.pickFolder as ReturnType).mockResolvedValue({ canceled: false, filePaths: ['/new-downloads'] }); diff --git a/src/ui/screens/OptionsScreen.tsx b/src/ui/screens/OptionsScreen.tsx index 898c0c3..3e67932 100644 --- a/src/ui/screens/OptionsScreen.tsx +++ b/src/ui/screens/OptionsScreen.tsx @@ -97,7 +97,7 @@ export default function OptionsScreen() { // (YtdlpUpdateDialog, mounted once at MainPage level) regardless of which // tab triggered it -- this screen only needs to check/kick off the update. const { currentVersion, latestVersion, updateAvailable, checking, checkError, stage, checkForUpdate, startUpdate } = useYtdlpUpdater(); - const { mode: themeMode, setMode: setThemeMode } = useThemeMode(); + const { mode: themeMode, setMode: setThemeMode, themeName, setThemeName } = useThemeMode(); const isUpdating = IN_PROGRESS_STAGES.has(stage); const [cookieLoaded, setCookieLoaded] = useState(false); const [cookieCount, setCookieCount] = useState(0); @@ -269,6 +269,10 @@ export default function OptionsScreen() { setThemeMode(mode); }; + const handleThemeNameChange = (name: 'default' | 'slothui') => { + setThemeName(name); + }; + // Switching to 'browser' mode alone doesn't need a browser picked yet -- // only persisted once cookiesBrowser is also set, by handleSelectBrowser // below. Switching back to 'file' saves immediately. @@ -429,15 +433,31 @@ export default function OptionsScreen() { Choose the app's color theme. Saved between sessions. - - Light - Dark - + + handleThemeNameChange(e.target.value as 'default' | 'slothui')} + sx={{ minWidth: 140 }} + > + Default MUI + SlothUI + + + Light + Dark + + diff --git a/src/ui/theme.ts b/src/ui/theme.ts index b87d487..3e77246 100644 --- a/src/ui/theme.ts +++ b/src/ui/theme.ts @@ -1,10 +1,63 @@ import { createTheme } from '@mui/material/styles'; export type ThemeMode = 'light' | 'dark'; +// 'default' is this app's original plain-MUI look; 'slothui' is the palette +// pulled from SlothArchiver-info's landing page (see the dossier's own +// potentialThemes.md for the full color-by-color derivation and caveats). +export type ThemeName = 'default' | 'slothui'; -const lightTheme = createTheme(); -const darkTheme = createTheme({ palette: { mode: 'dark' } }); +const defaultLightTheme = createTheme(); +const defaultDarkTheme = createTheme({ palette: { mode: 'dark' } }); -export function getTheme(mode: ThemeMode) { - return mode === 'dark' ? darkTheme : lightTheme; +const SLOTHUI_FONT_FAMILY = "'Manrope', system-ui, -apple-system, 'Segoe UI', sans-serif"; +// The landing page's card radius -- its separate pill (999px) button radius +// isn't representable as a single global `shape.borderRadius`, so it's left +// as a future per-component (MuiButton) override rather than approximated +// here. +const SLOTHUI_BORDER_RADIUS = 14; + +// The landing page's button/link hover goes *lighter* on the dark background +// (accent -> accent-strong, `#9ad24a` -> `#b8e368`) but *darker* on the light +// background (`#5f8f22` -> `#4c7519`) -- the opposite of MUI's own +// mode-independent light/dark convention. Modeling that faithfully means +// dark mode's hover state lives in `primary.light` and light mode's in +// `primary.dark`, rather than just mirroring the same pair of colors. +const slothUILightTheme = createTheme({ + palette: { + mode: 'light', + background: { default: '#faf9f4', paper: '#ffffff' }, + divider: '#e2e0d2', + text: { primary: '#1b1d16', secondary: '#4d5142', disabled: '#82866f' }, + primary: { main: '#5f8f22', dark: '#4c7519', contrastText: '#ffffff' }, + // No true error/info color exists in the source palette (see + // potentialThemes.md's caveats) -- left at MUI's own defaults rather + // than guessed at. + success: { main: '#9ad24a' }, + warning: { main: '#b25a34' }, + }, + typography: { fontFamily: SLOTHUI_FONT_FAMILY }, + shape: { borderRadius: SLOTHUI_BORDER_RADIUS }, +}); + +const slothUIDarkTheme = createTheme({ + palette: { + mode: 'dark', + background: { default: '#10120f', paper: '#191c17' }, + divider: '#2a2e24', + text: { primary: '#eef0e6', secondary: '#b7bca9', disabled: '#7d8172' }, + primary: { main: '#9ad24a', light: '#b8e368', contrastText: '#10120f' }, + success: { main: '#9ad24a' }, + warning: { main: '#e08a5e' }, + }, + typography: { fontFamily: SLOTHUI_FONT_FAMILY }, + shape: { borderRadius: SLOTHUI_BORDER_RADIUS }, +}); + +const THEMES: Record>> = { + default: { light: defaultLightTheme, dark: defaultDarkTheme }, + slothui: { light: slothUILightTheme, dark: slothUIDarkTheme }, +}; + +export function getTheme(mode: ThemeMode, themeName: ThemeName = 'default') { + return (THEMES[themeName] ?? THEMES.default)[mode]; } diff --git a/testing/mockData/electronAPIMocks.ts b/testing/mockData/electronAPIMocks.ts index c827d47..5d72b82 100644 --- a/testing/mockData/electronAPIMocks.ts +++ b/testing/mockData/electronAPIMocks.ts @@ -71,6 +71,8 @@ export const electronAPIMock = { setLibrarySort: async () => ({ success: true, sortField: 'title' as const, sortDirection: 'asc' as const }), getThemeMode: async () => ({ themeMode: 'light' as const }), setThemeMode: async () => ({ success: true, themeMode: 'light' as const }), + getThemeName: async () => ({ themeName: 'default' as const }), + setThemeName: async () => ({ success: true, themeName: 'default' as const }), getCustomConvertFormats: async () => ({ customConvertFormats: [] }), setCustomConvertFormats: async () => ({ success: true, customConvertFormats: [] }), getMaxSimultaneousDownloads: async () => ({ maxSimultaneousDownloads: 1 }), From 88bf26a6b3b74da4f535815fad1963d24cd1babe Mon Sep 17 00:00:00 2001 From: Sloth Date: Sat, 5 Sep 2026 19:25:45 -0700 Subject: [PATCH 2/4] feat(app): improve cookie handling and added optional save between sessions checkbox for cookies --- src/electron/main.mjs | 48 +++++++++++++++++++++++++++ src/electron/main.test.mjs | 24 ++++++++++++++ src/electron/preload.cjs | 1 + src/types/electron-api.d.ts | 3 +- src/ui/screens/OptionsScreen.test.tsx | 14 +++++++- src/ui/screens/OptionsScreen.tsx | 39 ++++++++++++++++++---- testing/mockData/electronAPIMocks.ts | 3 +- 7 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 2feb26c..5b91e9a 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -129,6 +129,46 @@ export function assertValidHttpUrl(url, label = 'URL') { const { readSettings, writeSettings } = createSettingsStore(settingsPath); export const cookiesArgs = makeCookiesArgs(readSettings, cookiesPath); +// Whether a saved cookie (the pasted-cookie file, or a cookies-from-browser +// choice) is allowed to outlive the app instance that saved it. Off means +// both get wiped at the start and end of every run -- so an authenticated +// session accidentally left saved can never quietly persist into a later +// launch, per the actual risk this setting exists for (a user getting into +// trouble with a platform's ToS over a cookie they forgot was still active). +// +// No explicit setting yet (a genuinely first run, or an upgrade from before +// this existed) defaults to whichever behavior keeps existing users +// unaffected: true if a cookies.txt is already sitting on disk (they were +// already relying on it persisting), false otherwise -- a fresh install +// starts on the safer, session-only default rather than inheriting the old +// always-persist behavior. +export function getCookiesPersistAcrossSessions() { + const { cookiesPersistAcrossSessions } = readSettings(); + if (typeof cookiesPersistAcrossSessions === 'boolean') return cookiesPersistAcrossSessions; + return fs.existsSync(cookiesPath); +} + +// Called at both startup and shutdown (see app.on('before-quit') below) -- +// covers both "clean up whatever a previous session left behind" and "don't +// leave anything behind for the next one," since either point alone would +// miss the other half (a crash skips shutdown; an upgrade skips startup +// cleanup for a file already written this run). +function clearSessionOnlyCookiesIfNeeded() { + if (getCookiesPersistAcrossSessions()) return; + if (fs.existsSync(cookiesPath)) { + fs.unlinkSync(cookiesPath); + } + const settings = readSettings(); + if (settings.cookiesMode || settings.cookiesBrowser) { + settings.cookiesMode = 'file'; + settings.cookiesBrowser = ''; + writeSettings(settings); + } +} + +clearSessionOnlyCookiesIfNeeded(); +app.on('before-quit', clearSessionOnlyCookiesIfNeeded); + // cookies.txt holds a live, authenticated Google/YouTube session -- fixed up // on every startup (not just at write time, see cookies:save below) so an // existing file from before this app started restricting permissions gets @@ -1041,9 +1081,17 @@ ipcMain.handle('cookies:getConfig', async () => { cookiesMode: cookiesMode === 'browser' ? 'browser' : 'file', cookiesBrowser: cookiesBrowser || '', supportedBrowsers: SUPPORTED_COOKIE_BROWSERS, + cookiesPersistAcrossSessions: getCookiesPersistAcrossSessions(), }; }); +ipcMain.handle('cookies:setPersistAcrossSessions', async (e, persist) => { + const settings = readSettings(); + settings.cookiesPersistAcrossSessions = !!persist; + writeSettings(settings); + return { success: true, cookiesPersistAcrossSessions: settings.cookiesPersistAcrossSessions }; +}); + ipcMain.handle('cookies:setConfig', async (e, { cookiesMode, cookiesBrowser }) => { // An empty cookiesBrowser is allowed through even in 'browser' mode -- // it's the explicit "Clear" state, which cookiesArgs() already treats as diff --git a/src/electron/main.test.mjs b/src/electron/main.test.mjs index 4ed4433..6a1455d 100644 --- a/src/electron/main.test.mjs +++ b/src/electron/main.test.mjs @@ -65,6 +65,7 @@ import { rememberAppPath, makeCookiesArgs, reapStaleCookieCopies, + getCookiesPersistAcrossSessions, } from './main.mjs'; fs.mkdirSync(electronMocks.mockUserDataDir, { recursive: true }); @@ -475,6 +476,29 @@ describe('cookiesArgs', () => { }); }); +describe('getCookiesPersistAcrossSessions', () => { + it('respects an explicit true/false setting regardless of whether a cookie file exists', () => { + resetSettingsAndCookies(); + fs.writeFileSync(settingsPath, JSON.stringify({ cookiesPersistAcrossSessions: true })); + expect(getCookiesPersistAcrossSessions()).toBe(true); + + fs.writeFileSync(cookiesPath, '# Netscape HTTP Cookie File\n'); + fs.writeFileSync(settingsPath, JSON.stringify({ cookiesPersistAcrossSessions: false })); + expect(getCookiesPersistAcrossSessions()).toBe(false); + }); + + it('defaults to true (grandfathered in) when no setting exists yet but a cookie file is already saved', () => { + resetSettingsAndCookies(); + fs.writeFileSync(cookiesPath, '# Netscape HTTP Cookie File\n'); + expect(getCookiesPersistAcrossSessions()).toBe(true); + }); + + it('defaults to false (the safer default) when no setting exists yet and no cookie file is saved', () => { + resetSettingsAndCookies(); + expect(getCookiesPersistAcrossSessions()).toBe(false); + }); +}); + describe('makeCookiesArgs (with an injected tmpDir)', () => { it('never touches the canonical cookiesPath, only reads it', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-cookies-')); diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index bb4528d..29f7b36 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -18,6 +18,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getCookieStatus: () => ipcRenderer.invoke('cookies:status'), getCookiesConfig: () => ipcRenderer.invoke('cookies:getConfig'), setCookiesConfig: (payload) => ipcRenderer.invoke('cookies:setConfig', payload), + setCookiesPersistAcrossSessions: (persist) => ipcRenderer.invoke('cookies:setPersistAcrossSessions', persist), getDownloadDir: () => ipcRenderer.invoke('settings:getDownloadDir'), setDownloadDir: (dir) => ipcRenderer.invoke('settings:setDownloadDir', dir), checkFileExists: (filePath) => ipcRenderer.invoke('system:pathExists', filePath), diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index 5d4d88b..b3f34a2 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -69,8 +69,9 @@ declare global { saveCookie: (cookieText: string) => Promise<{ success: boolean; cookieCount: number; skipped: number }> deleteCookie: () => Promise<{ success: boolean }> getCookieStatus: () => Promise<{ loaded: boolean; cookieCount: number; path?: string; savedAtEpoch?: number }> - getCookiesConfig: () => Promise<{ cookiesMode: 'file' | 'browser'; cookiesBrowser: string; supportedBrowsers: string[] }> + getCookiesConfig: () => Promise<{ cookiesMode: 'file' | 'browser'; cookiesBrowser: string; supportedBrowsers: string[]; cookiesPersistAcrossSessions: boolean }> setCookiesConfig: (payload: { cookiesMode: 'file' | 'browser'; cookiesBrowser: string }) => Promise<{ success: boolean; cookiesMode: 'file' | 'browser'; cookiesBrowser: string }> + setCookiesPersistAcrossSessions: (persist: boolean) => Promise<{ success: boolean; cookiesPersistAcrossSessions: boolean }> getDownloadDir: () => Promise<{ downloadDir: string }> setDownloadDir: (dir: string) => Promise<{ success: boolean; downloadDir: string }> checkFileExists: (filePath: string) => Promise diff --git a/src/ui/screens/OptionsScreen.test.tsx b/src/ui/screens/OptionsScreen.test.tsx index ebd2664..19dc2fc 100644 --- a/src/ui/screens/OptionsScreen.test.tsx +++ b/src/ui/screens/OptionsScreen.test.tsx @@ -10,7 +10,7 @@ beforeEach(() => { window.electronAPI = { ...window.electronAPI, getCookieStatus: vi.fn().mockResolvedValue({ loaded: false, cookieCount: 0 }), - getCookiesConfig: vi.fn().mockResolvedValue({ cookiesMode: 'file', cookiesBrowser: '', supportedBrowsers: ['firefox', 'chrome'] }), + getCookiesConfig: vi.fn().mockResolvedValue({ cookiesMode: 'file', cookiesBrowser: '', supportedBrowsers: ['firefox', 'chrome'], cookiesPersistAcrossSessions: false }), getDownloadDir: vi.fn().mockResolvedValue({ downloadDir: '' }), getLibraryDir: vi.fn().mockResolvedValue({ libraryDir: '' }), getErrorLogInfo: vi.fn().mockResolvedValue({ exists: false, path: '/log' }), @@ -22,6 +22,7 @@ beforeEach(() => { saveCookie: vi.fn(), deleteCookie: vi.fn().mockResolvedValue({ success: true }), setCookiesConfig: vi.fn().mockResolvedValue({ success: true, cookiesMode: 'file', cookiesBrowser: '' }), + setCookiesPersistAcrossSessions: vi.fn().mockResolvedValue({ success: true, cookiesPersistAcrossSessions: true }), openErrorLog: vi.fn(), checkForYtdlpUpdate: vi.fn().mockResolvedValue({ current: '2026.7.4', latest: '2026.7.4', updateAvailable: false }), startYtdlpUpdate: vi.fn(), @@ -71,6 +72,17 @@ describe('OptionsScreen', () => { expect(window.electronAPI.setThemeName).toHaveBeenCalledWith('slothui'); }); + it('defaults the "Save across sessions" checkbox off, and persists it when checked', async () => { + const user = userEvent.setup(); + renderScreen(); + const checkbox = await screen.findByRole('checkbox', { name: 'Save across sessions' }); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + + expect(window.electronAPI.setCookiesPersistAcrossSessions).toHaveBeenCalledWith(true); + }); + it('choosing a download folder persists the picked path and updates the display', async () => { const user = userEvent.setup(); (window.electronAPI.pickFolder as ReturnType).mockResolvedValue({ canceled: false, filePaths: ['/new-downloads'] }); diff --git a/src/ui/screens/OptionsScreen.tsx b/src/ui/screens/OptionsScreen.tsx index 3e67932..0a9cf06 100644 --- a/src/ui/screens/OptionsScreen.tsx +++ b/src/ui/screens/OptionsScreen.tsx @@ -1,16 +1,18 @@ -import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ChangeEvent, type MouseEvent, type ReactNode } from 'react'; import { Alert, Autocomplete, Box, Button, Card, + Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, + FormControlLabel, Grid, IconButton, Link, @@ -120,6 +122,7 @@ export default function OptionsScreen() { // IPC bridge method (e.g. setDownloadDirState vs. electronAPI.setDownloadDir). const [cookiesMode, setCookiesModeState] = useState<'file' | 'browser'>('file'); const [cookiesBrowser, setCookiesBrowserState] = useState(''); + const [cookiesPersistAcrossSessions, setCookiesPersistAcrossSessionsState] = useState(false); const [supportedBrowsers, setSupportedBrowsers] = useState([]); const [browserSavedMessage, setBrowserSavedMessage] = useState(''); const [downloadDir, setDownloadDirState] = useState(''); @@ -142,6 +145,13 @@ export default function OptionsScreen() { setCookiesModeState(config.cookiesMode); setCookiesBrowserState(config.cookiesBrowser); setSupportedBrowsers(config.supportedBrowsers); + setCookiesPersistAcrossSessionsState(config.cookiesPersistAcrossSessions); + }; + + const handlePersistAcrossSessionsChange = async (e: ChangeEvent) => { + const persist = e.target.checked; + setCookiesPersistAcrossSessionsState(persist); + await window.electronAPI.setCookiesPersistAcrossSessions(persist); }; const refreshDownloadDir = async () => { @@ -520,19 +530,36 @@ export default function OptionsScreen() { you're not a bot" error at you. - This authenticates as your real account, so using it at a high volume can get that - account flagged or suspended -- this applies whether you paste a cookie or pull - live from a browser below. Use it in moderation, and consider a throwaway account's - cookies instead of your main one if you expect to be downloading a lot. This matches + This is meant for videos that actually need it -- age-restricted content or + members-only uploads -- not as something to leave on for every download. It + authenticates as your real account, so high-volume use, whether from a pasted + cookie or pulled live from a browser below, risks that account getting flagged or + suspended. For more information, see {' '} - yt-dlp's own recommended cookie usage + yt-dlp's own cookie usage guide . + + } + label="Save across sessions" + /> + + {cookiesPersistAcrossSessions + ? 'The saved cookie (or browser selection) will still be here the next time you open the app.' + : "Off by default -- the saved cookie (and browser selection) is cleared as soon as the app closes, so it can't be accidentally left active for next time."} + ({ success: true, cookieCount: 0, skipped: 0 }), deleteCookie: async () => ({ success: true }), getCookieStatus: async () => ({ loaded: false, cookieCount: 0 }), - getCookiesConfig: async () => ({ cookiesMode: 'file' as const, cookiesBrowser: '', supportedBrowsers: [] }), + getCookiesConfig: async () => ({ cookiesMode: 'file' as const, cookiesBrowser: '', supportedBrowsers: [], cookiesPersistAcrossSessions: false }), setCookiesConfig: async () => ({ success: true, cookiesMode: 'file' as const, cookiesBrowser: '' }), + setCookiesPersistAcrossSessions: async () => ({ success: true, cookiesPersistAcrossSessions: false }), getDownloadDir: async () => ({ downloadDir: '' }), setDownloadDir: async () => ({ success: true, downloadDir: '' }), checkFileExists: async () => false, From 52b0bfe5353a4fd825d85aa9c8f53c2a786336d7 Mon Sep 17 00:00:00 2001 From: Sloth Date: Sat, 5 Sep 2026 19:51:14 -0700 Subject: [PATCH 3/4] doc: update changelog and version docs --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 12 ++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abdbb20..762b99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ This is the first documented entry — a snapshot of what SlothArchiver could do as of this release, not a history of every change that got it here. Future releases will log what actually changed from the previous one. +## [1.0.0] — 2026-09-05 +- **First public release** 🎉🎉🥳🎉🎉 +- **Added a theme selector.** Options now has a "Theme" dropdown alongside + the existing Light/Dark toggle: the original plain look ("Default MUI") + or a new "SlothUI" theme matching the color scheme from SlothArchiver's + own website. SlothUI (dark) is now the default look for a fresh install; + existing installs keep whatever they already had. +- **Cookie handling is clearer and safer.** The warning explains what + cookies are actually for (age-restricted or members-only content, not + something to leave on for every download) and links to yt-dlp's own + cookie usage guide. A new "Save across sessions" checkbox, off by + default, controls whether a saved cookie (or cookies-from-browser choice) + is kept the next time the app opens or cleared as soon as it closes -- + so an authenticated session can't accidentally outlive the run that + created it. Existing saved cookies are unaffected by this change. +- **Fixed a bug in the bulk-add queue where a retried download could show + a generic "Download failed" message** instead of the actual reason, + once an earlier failure on the same item was still being displayed. + ## [0.27.1] — 2026-09-04 - **Fixed a bug where downloading a very long video could get stuck looping forever.** The step that joins the downloaded video and audio together diff --git a/README.md b/README.md index 4493ffc..bba24fe 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,17 @@ itself. ## Download - | Platform | Link | |---|---| -| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver.Setup.0.27.1.exe) | -| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver.0.27.1.exe) | -| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1-arm64.dmg) | -| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1.dmg) | -| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1.AppImage) | +| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver.Setup.1.0.0.exe) | +| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver.1.0.0.exe) | +| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0-arm64.dmg) | +| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0.dmg) | +| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0.AppImage) | All builds are unsigned, so your OS will show a first-run security warning — see [Installing](#installing) below. From 84d9e4c0724d112b33f9c9de0d937ed425d6082a Mon Sep 17 00:00:00 2001 From: Sloth Date: Sat, 5 Sep 2026 22:57:52 -0700 Subject: [PATCH 4/4] chore: fix ts errors --- testing/mockData/electronAPIMocks.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/testing/mockData/electronAPIMocks.ts b/testing/mockData/electronAPIMocks.ts index c97a40d..f535b23 100644 --- a/testing/mockData/electronAPIMocks.ts +++ b/testing/mockData/electronAPIMocks.ts @@ -100,6 +100,11 @@ export const electronAPIMock = { deleteLibraryEntry: async () => ({ success: true, videoDeleted: true }), deleteLibraryEntries: async () => ({ success: true, results: [] }), deleteLocalFiles: async () => ({ success: true, results: [] }), + listVideoTags: async () => ({ tags: {} }), + setVideoTag: async () => ({ success: true, tags: {} }), + tagVideos: async () => ({ success: true, tags: {} }), + checkAndRepairEpochFiles: async () => ({ success: true }), + moveLibraryEntries: async () => ({ success: true, results: [] }), onLibraryBackgroundUpdate: noop, removeLibraryBackgroundUpdateListener: noop, saveExportedFile: async () => ({ canceled: true }),