diff --git a/README.md b/README.md index ed68077f..33f6c139 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A visual theming application for [Omarchy](https://omarchy.org). Extract colors ### Wallpaper Tools - Search and download wallpapers from wallhaven.cc directly in the app +- Export favorite wallpapers as a ZIP archive with source metadata - Full wallpaper editor with blur, exposure, sharpen, vignette, grain, and color toning - 12 one-click image presets: Cinematic, Vintage, Film, Dramatic, and more @@ -131,6 +132,7 @@ From `frontend/`, run `npm ci`, `npm run check`, `npm test`, and `npm run build` | [Base16 Schemes](docs/base16.md) | Import community color schemes | | [Wallpaper Editor](docs/wallpaper-editor.md) | Image filters and presets | | [Wallhaven](docs/wallhaven.md) | Browse online wallpapers | +| [Favorites](docs/favorites.md) | Save wallpapers and export a collection | | [Blueprints](docs/blueprints.md) | Save and restore themes | | [Custom Templates](docs/custom-templates.md) | Add support for your apps | | [Custom Apps](docs/custom-apps.md) | Per-app template system | diff --git a/app.go b/app.go index a4b2301c..ba2f5015 100644 --- a/app.go +++ b/app.go @@ -15,6 +15,7 @@ import ( "aether/internal/blueprint" "aether/internal/color" "aether/internal/extraction" + "aether/internal/favexport" "aether/internal/favorites" "aether/internal/icontheme" "aether/internal/omarchy" @@ -38,6 +39,7 @@ type App struct { writer *theme.Writer blueprints *blueprint.Service favorites *favorites.Service + favExport *favexport.Exporter wallhaven *wallhaven.Client batch *batch.Processor iconThemes *icontheme.Catalog @@ -90,13 +92,15 @@ func (a *App) StartUpgrade() error { // NewApp creates a new App instance. func NewApp() *App { + wh := wallhaven.NewClient() return &App{ state: newSeededState(), history: theme.NewHistoryManager(), writer: theme.NewWriter(EmbeddedTemplates, "templates"), blueprints: blueprint.NewService(), favorites: favorites.NewService(), - wallhaven: wallhaven.NewClient(), + favExport: favexport.New(wh), + wallhaven: wh, batch: batch.NewProcessor(), iconThemes: icontheme.NewCatalog(), themeWatcher: theme.NewThemeWatcher(), @@ -726,6 +730,85 @@ func (a *App) IsFavorite(path string) bool { return a.favorites.IsFavorite(path) } +// ExportFavoritesRequest is the payload from the frontend for zipping favorites. +type ExportFavoritesRequest struct { + Paths []string `json:"paths"` // favorite paths, in display order +} + +// ExportFavorites archives the given favorites into a .zip in a user-chosen +// directory. Wallhaven favorites are remote URLs, so anything not already +// downloaded is fetched first — which makes this slow enough that the work runs +// in the background and reports through favorites-export-* events. Returns the +// path the archive is being written to. +func (a *App) ExportFavorites(req ExportFavoritesRequest) (string, error) { + if a.favExport.IsRunning() { + return "", fmt.Errorf("an export is already running") + } + items := a.favoriteItems(req.Paths) + if len(items) == 0 { + return "", fmt.Errorf("no favorites to export") + } + + dir, err := wailsrt.OpenDirectoryDialog(a.ctx, wailsrt.OpenDialogOptions{ + Title: "Choose Export Directory", + CanCreateDirectories: true, + }) + if err != nil { + return "", fmt.Errorf("choose export directory: %w", err) + } + if dir == "" { + return "", fmt.Errorf("export cancelled") + } + + return a.favExport.Start(a.ctx, items, dir) +} + +// CancelFavoritesExport stops a running favorites export. +func (a *App) CancelFavoritesExport() { a.favExport.Cancel() } + +// IsFavoritesExportRunning reports whether an export is in flight. The frontend +// uses this to recover its progress state after a reload. +func (a *App) IsFavoritesExportRunning() bool { return a.favExport.IsRunning() } + +// favoriteItems resolves frontend-supplied paths against the favorites store. +// Only the path crosses the boundary — names and metadata are read back from +// the service so the archive cannot be steered by the caller. +func (a *App) favoriteItems(paths []string) []favexport.Item { + known := make(map[string]favorites.Favorite) + for _, fav := range a.favorites.GetAll() { + known[fav.Path] = fav + } + + items := make([]favexport.Item, 0, len(paths)) + seen := make(map[string]bool, len(paths)) + for _, path := range paths { + fav, ok := known[path] + if !ok || seen[path] { + continue + } + seen[path] = true + + item := favexport.Item{Path: fav.Path, Meta: map[string]interface{}{}} + if fav.Type != "" { + item.Meta["type"] = fav.Type + } + for k, v := range fav.Data { + if v == nil { + continue + } + item.Meta[k] = v + } + // The tile label is the local name, falling back to the wallhaven id. + if name, ok := fav.Data["name"].(string); ok { + item.Name = name + } else if id, ok := fav.Data["id"].(string); ok { + item.Name = id + } + items = append(items, item) + } + return items +} + // --------------------------------------------------------------------------- // App Settings (template toggles, neovim config) // --------------------------------------------------------------------------- diff --git a/docs/favorites.md b/docs/favorites.md new file mode 100644 index 00000000..464e3e49 --- /dev/null +++ b/docs/favorites.md @@ -0,0 +1,27 @@ +# Favorites + +Use the heart control in Local or Wallhaven to save a wallpaper in Favorites. +The favorite state stays in sync across the three views. + +## Export a collection + +1. Open Favorites. +2. Select a label to filter the collection, if needed. +3. Click `Export .zip`. +4. Choose an export folder. +5. Review the export result. + +The export includes the wallpapers in the current filtered list. +Aether downloads remote wallpapers before it creates the archive. +The progress panel remains available when you change tabs. +Use `Cancel` to stop an active export. + +The archive contains the wallpaper files and a `favorites.json` source manifest. +The manifest records each archive filename, original source, and available wallpaper metadata. +Duplicate filenames receive a numeric suffix. + +The result lists files that Aether cannot find or download. +Use `Open folder` to locate the completed archive. +Use `Dismiss` to close the result. + +To use the collection on another machine, extract the archive and select its images from Local. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6a782da3..604b102e 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -12,6 +12,8 @@ import OmarchyThemes from '$lib/components/blueprints/OmarchyThemes.svelte'; import SettingsView from '$lib/components/settings/SettingsView.svelte'; import AboutView from '$lib/components/layout/AboutView.svelte'; + import ExportProgress from '$lib/components/favorites/ExportProgress.svelte'; + import {initExportEvents} from '$lib/stores/favoritesExport.svelte'; import { getActiveTab, setActiveTab, @@ -358,6 +360,12 @@ else if (getKeymapOpen()) setKeymapOpen(false); }); + // Favorites export progress. Wired here rather than in FavoritesView + // so an export keeps reporting after the user switches tabs. + initExportEvents().catch(error => + console.error('Favorites export events unavailable:', error) + ); + // Listen for events from Go (async () => { try { @@ -571,6 +579,7 @@ {/if} + setKeymapOpen(false)} /> + import { + getExportState, + getExportResult, + dismissExportResult, + openExportFolder, + cancelExport, + } from '$lib/stores/favoritesExport.svelte'; + + let state = $derived(getExportState()); + let result = $derived(getExportResult()); + let percent = $derived( + state.total > 0 + ? Math.min(100, Math.round((state.index / state.total) * 100)) + : 0 + ); + let label = $derived( + state.phase === 'archive' ? 'Archiving' : 'Downloading' + ); + + +{#if state.active} + +
+
+ + {label} + {#if state.total > 0}{state.index}/{state.total}{/if} + + {#if state.name} + {state.name} + {:else} + + {/if} + +
+
+
+
+
+{:else if result} +
+
+

+ Exported {result.exported} of {result.total} favorites +

+ + +
+ {#if result.skipped?.length} +
+ {result.skipped.length} skipped files +
    + {#each result.skipped as item} +
  • + {item.path}: {item.reason} +
  • + {/each} +
+
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/favorites/FavoritesView.svelte b/frontend/src/lib/components/favorites/FavoritesView.svelte index 09c19d94..f221d6d8 100644 --- a/frontend/src/lib/components/favorites/FavoritesView.svelte +++ b/frontend/src/lib/components/favorites/FavoritesView.svelte @@ -15,6 +15,10 @@ getCachedFullImage, } from '$lib/stores/imagecache.svelte'; import {getLabels, getAssignments} from '$lib/stores/tags.svelte'; + import { + getExportBusy, + startExport, + } from '$lib/stores/favoritesExport.svelte'; import WallpaperTile from '$lib/components/shared/WallpaperTile.svelte'; import ImagePreview from '$lib/components/shared/ImagePreview.svelte'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; @@ -193,7 +197,15 @@ {/each} {/if} - startExport(filtered.map(f => f.path))} + title="Export the listed favorites as a .zip archive" + >Export .zip ({filtered.length}) + + {filtered.length}{filterTag ? `/${favorites.length}` : ''} diff --git a/frontend/src/lib/stores/favoritesExport.svelte.ts b/frontend/src/lib/stores/favoritesExport.svelte.ts new file mode 100644 index 00000000..4a9e3a83 --- /dev/null +++ b/frontend/src/lib/stores/favoritesExport.svelte.ts @@ -0,0 +1,178 @@ +import {showToast} from '$lib/stores/ui.svelte'; +import type {main} from '../../../wailsjs/go/models'; + +export type ExportPhase = 'download' | 'archive'; +export type ExportState = { + active: boolean; + phase: ExportPhase; + index: number; + total: number; + name: string; + zipPath: string; +}; + +export type ExportResult = { + zipPath: string; + total: number; + exported: number; + skipped: {path: string; reason: string}[] | null; +}; + +const IDLE: ExportState = { + active: false, + phase: 'download', + index: 0, + total: 0, + name: '', + zipPath: '', +}; + +let state = $state({...IDLE}); +let starting = $state(false); +let result = $state(null); +let eventsInitialization: Promise | null = null; +let runSeq = 0; +let acceptsProgress = true; + +export function getExportState(): ExportState { + return state; +} + +export function getExportBusy(): boolean { + return starting || state.active; +} + +export function getExportResult(): ExportResult | null { + return result; +} + +export function dismissExportResult(): void { + result = null; +} + +function folderURL(zipPath: string): string { + const dir = zipPath.slice(0, zipPath.lastIndexOf('/')); + return 'file://' + dir.split('/').map(encodeURIComponent).join('/'); +} + +export async function openExportFolder(): Promise { + if (!result) return; + const url = folderURL(result.zipPath); + try { + const {BrowserOpenURL} = await import( + '../../../wailsjs/runtime/runtime' + ); + BrowserOpenURL(url); + } catch { + showToast('Could not open the export folder'); + } +} + +export async function startExport(paths: string[]): Promise { + if (getExportBusy() || paths.length === 0) return; + const selected = [...paths]; + const seq = ++runSeq; + acceptsProgress = true; + starting = true; + result = null; + try { + await initExportEvents(); + const {ExportFavorites} = await import('../../../wailsjs/go/main/App'); + const zipPath = await ExportFavorites({ + paths: selected, + } as unknown as main.ExportFavoritesRequest); + if (runSeq !== seq) return; + state = state.active + ? {...state, zipPath} + : {...IDLE, active: true, total: selected.length, zipPath}; + } catch (error: unknown) { + if (!state.active) acceptsProgress = false; + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('cancelled')) + showToast(message || 'Export failed'); + } finally { + starting = false; + } +} + +export async function cancelExport(): Promise { + if (!state.active) return; + try { + const {CancelFavoritesExport} = await import( + '../../../wailsjs/go/main/App' + ); + await CancelFavoritesExport(); + } catch { + showToast('Could not cancel the export. Try again.'); + } +} + +// Install listeners before a fast export can emit its completion event. +export function initExportEvents(): Promise { + if (!eventsInitialization) { + eventsInitialization = subscribeExportEvents().catch(error => { + eventsInitialization = null; + throw error; + }); + } + return eventsInitialization; +} + +async function subscribeExportEvents(): Promise { + const {EventsOn, BrowserOpenURL} = await import( + '../../../wailsjs/runtime/runtime' + ); + EventsOn( + 'favorites-export-progress', + (progress: { + phase: ExportPhase; + index: number; + total: number; + name: string; + }) => { + if (!acceptsProgress) return; + state = {...state, active: true, ...progress}; + } + ); + EventsOn('favorites-export-completed', (completed: ExportResult) => { + runSeq++; + acceptsProgress = false; + state = {...IDLE}; + result = {...completed, skipped: completed.skipped ?? []}; + showToast( + `Exported ${completed.exported} of ${completed.total} favorites`, + { + duration: 8000, + action: { + label: 'Open folder', + run: () => BrowserOpenURL(folderURL(completed.zipPath)), + }, + } + ); + }); + EventsOn('favorites-export-failed', (failure: {error: string}) => { + runSeq++; + acceptsProgress = false; + state = {...IDLE}; + showToast(failure?.error || 'Export failed'); + }); + EventsOn('favorites-export-cancelled', () => { + runSeq++; + acceptsProgress = false; + state = {...IDLE}; + showToast('Export cancelled'); + }); + void recoverExportState(runSeq); +} + +async function recoverExportState(sequence: number): Promise { + try { + const {IsFavoritesExportRunning} = await import( + '../../../wailsjs/go/main/App' + ); + const running = await IsFavoritesExportRunning(); + if (sequence === runSeq && running) state = {...state, active: true}; + } catch { + // A later progress event can still recover an active export. + } +} diff --git a/frontend/tests/favorites-export.test.ts b/frontend/tests/favorites-export.test.ts new file mode 100644 index 00000000..e6685b9a --- /dev/null +++ b/frontend/tests/favorites-export.test.ts @@ -0,0 +1,142 @@ +import {beforeEach, expect, test, vi} from 'vitest'; +import {deferred, settle} from './setup'; + +const events = vi.hoisted(() => new Map void>()); +const api = vi.hoisted(() => ({ + ExportFavorites: vi.fn(), + IsFavoritesExportRunning: vi.fn(), + CancelFavoritesExport: vi.fn(), +})); +vi.mock('../wailsjs/runtime/runtime', () => ({ + EventsOn: vi.fn((name: string, callback: (data?: unknown) => void) => + events.set(name, callback) + ), + BrowserOpenURL: vi.fn(), +})); + +let exports: typeof import('../src/lib/stores/favoritesExport.svelte'); +const complete = { + zipPath: '/exports/favorites.zip', + exported: 1, + total: 2, + skipped: [{path: '/missing.png', reason: 'file not found'}], +}; + +beforeEach(async () => { + vi.resetModules(); + events.clear(); + vi.stubGlobal('go', {main: {App: api}}); + vi.mocked(api.ExportFavorites) + .mockReset() + .mockResolvedValue('/exports/favorites.zip'); + vi.mocked(api.IsFavoritesExportRunning) + .mockReset() + .mockResolvedValue(false); + vi.mocked(api.CancelFavoritesExport) + .mockReset() + .mockResolvedValue(undefined); + exports = await import('../src/lib/stores/favoritesExport.svelte'); + expect(exports.getExportBusy()).toBe(false); + expect(exports.getExportResult()).toBeNull(); +}); + +function emit(name: string, data?: unknown) { + const handler = events.get('favorites-export-' + name); + if (!handler) throw new Error('Export event listener is missing'); + handler(data); +} + +test('a delayed recovery response cannot reactivate a completed export', async () => { + const pending = deferred(); + vi.mocked(api.IsFavoritesExportRunning).mockReturnValue(pending.promise); + await exports.initExportEvents(); + await settle(); + expect(api.IsFavoritesExportRunning).toHaveBeenCalledTimes(1); + emit('completed', complete); + pending.resolve(true); + await settle(); + expect(exports.getExportState().active).toBe(false); + expect(exports.getExportResult()?.skipped).toEqual(complete.skipped); +}); + +test('start reserves the operation and captures the selected paths', async () => { + const pending = deferred(); + vi.mocked(api.ExportFavorites).mockReturnValue(pending.promise); + const paths = ['/one.png']; + const first = exports.startExport(paths); + paths.push('/later.png'); + await exports.startExport(['/two.png']); + await settle(); + expect(api.ExportFavorites).toHaveBeenCalledExactlyOnceWith({ + paths: ['/one.png'], + }); + expect(exports.getExportBusy()).toBe(true); + pending.resolve('/exports/favorites.zip'); + await first; + expect(exports.getExportState().total).toBe(1); +}); + +test('completion before the start response leaves the export complete', async () => { + const pending = deferred(); + vi.mocked(api.ExportFavorites).mockReturnValue(pending.promise); + const operation = exports.startExport(['/one.png']); + await settle(); + emit('completed', complete); + pending.resolve(complete.zipPath); + await operation; + emit('progress', {phase: 'archive', index: 1, total: 1, name: 'one.png'}); + expect(exports.getExportBusy()).toBe(false); + expect(exports.getExportResult()).toEqual(complete); +}); + +test('cancel waits for the terminal event and permits the next export', async () => { + await exports.startExport(['/one.png']); + await exports.cancelExport(); + expect(api.CancelFavoritesExport).toHaveBeenCalledTimes(1); + expect(exports.getExportState().active).toBe(true); + emit('cancelled'); + await exports.startExport(['/two.png']); + expect(api.ExportFavorites).toHaveBeenCalledTimes(2); +}); + +test('a dismissed directory dialog releases the start guard', async () => { + vi.mocked(api.ExportFavorites).mockRejectedValueOnce( + new Error('export cancelled') + ); + await exports.startExport(['/one.png']); + expect(exports.getExportBusy()).toBe(false); + await exports.startExport(['/two.png']); + expect(api.ExportFavorites).toHaveBeenCalledTimes(2); +}); + +test('the completion panel retains skipped-file details until dismissal', async () => { + const {mount, unmount, flushSync} = await import('svelte'); + const {default: ExportProgress} = await import( + '../src/lib/components/favorites/ExportProgress.svelte' + ); + await exports.initExportEvents(); + const target = document.createElement('div'); + document.body.append(target); + const view = mount(ExportProgress, {target}); + try { + emit('completed', complete); + flushSync(); + expect(target.querySelector('[role="status"]')?.textContent).toContain( + 'Exported 1 of 2' + ); + expect(target.querySelector('details')?.textContent).toContain( + '/missing.png' + ); + expect(target.querySelector('details')?.textContent).toContain( + 'file not found' + ); + [...target.querySelectorAll('button')] + .find(button => button.textContent === 'Dismiss')! + .click(); + flushSync(); + expect(exports.getExportResult()).toBeNull(); + } finally { + await unmount(view); + target.remove(); + } +}); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 19ae1ccb..4013a37a 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -29,6 +29,8 @@ export function CancelBatchProcessing(): Promise; export function CancelExternalImport(arg1: string): Promise; +export function CancelFavoritesExport(): Promise; + export function ChooseWallpaperFolder(): Promise; export function ClearTheme(): Promise; @@ -49,6 +51,10 @@ export function DeleteBlueprint(arg1: string): Promise; export function DownloadWallpaper(arg1: string): Promise; +export function ExportFavorites( + arg1: main.ExportFavoritesRequest +): Promise; + export function ExportTheme(arg1: main.ExportThemeRequest): Promise; export function ExtractColors( @@ -105,6 +111,8 @@ export function ImportFileDialog(arg1: string): Promise; export function IsFavorite(arg1: string): Promise; +export function IsFavoritesExportRunning(): Promise; + export function IsMacOS(): Promise; export function IsOmarchyInstalled(): Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index f44f8449..132f4ce8 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -30,6 +30,10 @@ export function CancelExternalImport(arg1) { return window['go']['main']['App']['CancelExternalImport'](arg1); } +export function CancelFavoritesExport() { + return window['go']['main']['App']['CancelFavoritesExport'](); +} + export function ChooseWallpaperFolder() { return window['go']['main']['App']['ChooseWallpaperFolder'](); } @@ -62,6 +66,10 @@ export function DownloadWallpaper(arg1) { return window['go']['main']['App']['DownloadWallpaper'](arg1); } +export function ExportFavorites(arg1) { + return window['go']['main']['App']['ExportFavorites'](arg1); +} + export function ExportTheme(arg1) { return window['go']['main']['App']['ExportTheme'](arg1); } @@ -158,6 +166,10 @@ export function IsFavorite(arg1) { return window['go']['main']['App']['IsFavorite'](arg1); } +export function IsFavoritesExportRunning() { + return window['go']['main']['App']['IsFavoritesExportRunning'](); +} + export function IsMacOS() { return window['go']['main']['App']['IsMacOS'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 25bcf655..f6efa4ef 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -271,6 +271,18 @@ export namespace main { return a; } } + export class ExportFavoritesRequest { + paths: string[]; + + static createFrom(source: any = {}) { + return new ExportFavoritesRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.paths = source['paths']; + } + } export class ExportThemeRequest { name: string; includedApps: string[]; diff --git a/internal/favexport/exporter.go b/internal/favexport/exporter.go new file mode 100644 index 00000000..8dc417cc --- /dev/null +++ b/internal/favexport/exporter.go @@ -0,0 +1,490 @@ +// Package favexport bundles favorited wallpapers into a .zip archive. +// +// Favorites are not necessarily files: wallhaven entries store a remote URL as +// their path, so exporting has to fetch anything that is not on disk yet before +// it can archive it. That makes the operation slow enough to need progress +// reporting and cancellation, so it runs in a goroutine and emits Wails events +// the same way internal/batch does. +package favexport + +import ( + "archive/zip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "aether/internal/platform" + + wailsrt "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// maxConcurrentDownloads bounds the fetch phase. Downloads dominate the wall +// clock, but hammering wallhaven with one request per favorite is rude. +const maxConcurrentDownloads = 4 + +// manifestName is the metadata file written alongside the images so an archive +// records where each wallpaper came from. +const manifestName = "favorites.json" + +// Item is one wallpaper to export. +type Item struct { + Path string // local file path or http(s) URL + Name string // preferred base name in the zip ("" derives from Path) + Meta map[string]interface{} // type/id/resolution, copied into the manifest +} + +// Skip records a favorite that could not be included. +type Skip struct { + Path string `json:"path"` + Reason string `json:"reason"` +} + +// Result is the payload of the favorites-export-completed event. +type Result struct { + ZipPath string `json:"zipPath"` + Total int `json:"total"` + Exported int `json:"exported"` + Skipped []Skip `json:"skipped"` +} + +// Downloader fetches a remote wallpaper and returns its local path. +// *wallhaven.Client satisfies this. +type Downloader interface { + DownloadContext(ctx context.Context, url string) (string, error) +} + +// Exporter runs at most one export at a time. +type Exporter struct { + mu sync.Mutex + cancel context.CancelFunc + running bool + dl Downloader +} + +// New creates an exporter that resolves remote favorites through dl. +func New(dl Downloader) *Exporter { + return &Exporter{dl: dl} +} + +// Start validates the request and starts an export job. +// It returns the destination path. Events report progress and completion. +func (e *Exporter) Start(appCtx context.Context, items []Item, destDir string) (string, error) { + if len(items) == 0 { + return "", fmt.Errorf("no favorites to export") + } + if err := platform.EnsureDir(destDir); err != nil { + return "", fmt.Errorf("prepare export directory: %w", err) + } + + e.mu.Lock() + if e.running { + e.mu.Unlock() + return "", fmt.Errorf("an export is already running") + } + + zipPath, err := uniquePath(destDir, archiveBaseName(), ".zip") + if err != nil { + e.mu.Unlock() + return "", err + } + + // Derive from appCtx so app shutdown cancels an in-flight export. + ctx, cancel := context.WithCancel(appCtx) + e.cancel = cancel + e.running = true + e.mu.Unlock() + + go func() { + defer func() { + e.mu.Lock() + e.running = false + e.cancel = nil + e.mu.Unlock() + cancel() + }() + e.run(appCtx, ctx, items, zipPath) + }() + + return zipPath, nil +} + +// Cancel stops a running export. It is a no-op when nothing is running. +func (e *Exporter) Cancel() { + e.mu.Lock() + defer e.mu.Unlock() + if e.cancel != nil { + e.cancel() + } +} + +// IsRunning reports whether an export is in flight. +func (e *Exporter) IsRunning() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.running +} + +// resolved pairs an item with the local file backing it, or the reason it has none. +type resolved struct { + item Item + local string + skip string +} + +// run performs the export. emitCtx is the app context used for events (it must +// stay alive after cancellation so the cancelled event still reaches the UI); +// ctx is the cancellable one that governs the work itself. +func (e *Exporter) run(emitCtx, ctx context.Context, items []Item, zipPath string) { + partPath := zipPath + ".part" + + results, cancelled := e.resolveAll(emitCtx, ctx, items) + if cancelled { + emit(emitCtx, "favorites-export-cancelled", nil) + return + } + + result, err := writeArchive(emitCtx, ctx, results, partPath, zipPath) + switch { + case errors.Is(err, context.Canceled): + emit(emitCtx, "favorites-export-cancelled", nil) + case err != nil: + emit(emitCtx, "favorites-export-failed", map[string]interface{}{"error": err.Error()}) + default: + emit(emitCtx, "favorites-export-completed", result) + } +} + +// resolveAll turns every item into a local file path, downloading remote ones +// with bounded concurrency. Results keep the input order. +func (e *Exporter) resolveAll(emitCtx, ctx context.Context, items []Item) ([]resolved, bool) { + results := make([]resolved, len(items)) + sem := make(chan struct{}, maxConcurrentDownloads) + + var ( + wg sync.WaitGroup + done int + mu sync.Mutex + ) + + for i, item := range items { + select { + case <-ctx.Done(): + wg.Wait() + return nil, true + case sem <- struct{}{}: + } + + wg.Add(1) + go func(i int, item Item) { + defer wg.Done() + defer func() { <-sem }() + + results[i] = e.resolve(ctx, item) + + mu.Lock() + done++ + progress := done + mu.Unlock() + + emitProgress(emitCtx, "download", progress, len(items), entryLabel(item)) + }(i, item) + } + + wg.Wait() + + if ctx.Err() != nil { + return nil, true + } + return results, false +} + +// resolve maps a single item to a local file, fetching it when remote. +func (e *Exporter) resolve(ctx context.Context, item Item) resolved { + if isRemote(item.Path) { + if e.dl == nil { + return resolved{item: item, skip: "no downloader available"} + } + local, err := e.dl.DownloadContext(ctx, item.Path) + if err != nil { + if ctx.Err() != nil { + return resolved{item: item, skip: "cancelled"} + } + return resolved{item: item, skip: "download failed: " + err.Error()} + } + return resolved{item: item, local: local} + } + + info, err := os.Stat(item.Path) + if err != nil { + return resolved{item: item, skip: "file not found"} + } + if !info.Mode().IsRegular() { + return resolved{item: item, skip: "source is not a regular file"} + } + return resolved{item: item, local: item.Path} +} + +// manifestEntry describes one archived wallpaper. +type manifestEntry struct { + File string `json:"file"` + Source string `json:"source"` + Meta map[string]interface{} `json:"meta,omitempty"` +} + +// writeArchive streams the resolved files into a zip, writing to partPath and +// renaming to zipPath only once the archive is complete. +func writeArchive(emitCtx, ctx context.Context, results []resolved, partPath, zipPath string) (Result, error) { + out, err := os.OpenFile(partPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return Result{}, fmt.Errorf("create archive: %w", err) + } + // Closed explicitly below; the deferred close covers the error paths and a + // second Close on an already-closed file is harmless here. + defer func() { + _ = out.Close() + _ = os.Remove(partPath) + }() + + zw := zip.NewWriter(out) + defer zw.Close() + used := map[string]bool{manifestName: true} + manifest := make([]manifestEntry, 0, len(results)) + result := Result{ZipPath: zipPath, Total: len(results)} + + for i, r := range results { + if err := ctx.Err(); err != nil { + return Result{}, err + } + + if r.skip != "" { + result.Skipped = append(result.Skipped, Skip{Path: r.item.Path, Reason: r.skip}) + continue + } + + name := uniqueEntryName(entryLabel(r.item), used) + emitProgress(emitCtx, "archive", i+1, len(results), name) + + if err := addFile(ctx, zw, name, r.local); err != nil { + return Result{}, fmt.Errorf("archive %s: %w", name, err) + } + + result.Exported++ + manifest = append(manifest, manifestEntry{ + File: name, + Source: r.item.Path, + Meta: r.item.Meta, + }) + } + + if result.Exported == 0 { + _ = zw.Close() + return Result{}, fmt.Errorf("no favorites could be exported") + } + + if err := ctx.Err(); err != nil { + return Result{}, err + } + if err := addManifest(zw, manifest); err != nil { + _ = zw.Close() + return Result{}, err + } + if err := zw.Close(); err != nil { + return Result{}, fmt.Errorf("finalize archive: %w", err) + } + if err := out.Close(); err != nil { + return Result{}, fmt.Errorf("finalize archive: %w", err) + } + if err := ctx.Err(); err != nil { + return Result{}, err + } + if _, err := os.Lstat(zipPath); err == nil { + return Result{}, fmt.Errorf("export destination already exists: %s", zipPath) + } else if !os.IsNotExist(err) { + return Result{}, fmt.Errorf("inspect export destination: %w", err) + } + if err := os.Rename(partPath, zipPath); err != nil { + return Result{}, fmt.Errorf("finalize archive: %w", err) + } + + return result, nil +} + +// addFile copies one wallpaper into the archive. Images are stored, not +// deflated — they are already compressed, so deflate only burns CPU. +func addFile(ctx context.Context, zw *zip.Writer, name, srcPath string) error { + src, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("read source: %w", err) + } + defer src.Close() + + header := &zip.FileHeader{Name: name, Method: zip.Store} + // Without an explicit mtime every entry reports 1980-01-01, which archive + // tools surface as a corrupt-looking date. + info, err := src.Stat() + if err != nil { + return fmt.Errorf("inspect source: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("source is not a regular file") + } + header.Modified = info.ModTime() + + w, err := zw.CreateHeader(header) + if err != nil { + return fmt.Errorf("create archive entry: %w", err) + } + if _, err := io.Copy(w, &cancelableReader{ctx: ctx, source: src}); err != nil { + return fmt.Errorf("copy source: %w", err) + } + return nil +} + +type cancelableReader struct { + ctx context.Context + source io.Reader +} + +func (r *cancelableReader) Read(buf []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.source.Read(buf) +} + +// addManifest writes the metadata sidecar. Unlike the images, JSON compresses. +func addManifest(zw *zip.Writer, entries []manifestEntry) error { + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return fmt.Errorf("build manifest: %w", err) + } + w, err := zw.CreateHeader(&zip.FileHeader{ + Name: manifestName, + Method: zip.Deflate, + Modified: time.Now(), + }) + if err != nil { + return fmt.Errorf("build manifest: %w", err) + } + if _, err := w.Write(data); err != nil { + return fmt.Errorf("build manifest: %w", err) + } + return nil +} + +// --- helpers --- + +func isRemote(path string) bool { + return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") +} + +// entryLabel is the preferred file name for an item inside the archive. +func entryLabel(item Item) string { + source := item.Path + if isRemote(source) { + if parsed, err := url.Parse(source); err == nil { + source = parsed.Path + } + } + name := sanitizeName(item.Name) + if name == "" { + name = sanitizeName(filepath.Base(source)) + } + if name == "" { + name = "wallpaper" + } + // A wallhaven id is used as the display name in the UI and carries no + // extension; borrow the one from the URL so the file stays openable. + // sanitizeName trims dots, so the separator is re-added by hand. + if filepath.Ext(name) == "" { + if ext := sanitizeName(filepath.Ext(source)); ext != "" { + name += "." + ext + } + } + return name +} + +// sanitizeName strips path separators and anything else that would let a +// favorite path escape the archive root or produce an unusable file name. +func sanitizeName(name string) string { + name = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|', 0: + return -1 + } + if r < 32 { + return -1 + } + return r + }, name) + return strings.Trim(strings.TrimSpace(name), ".") +} + +// uniqueEntryName suffixes duplicates so two favorites with the same base name +// do not overwrite each other inside the archive. +func uniqueEntryName(name string, used map[string]bool) string { + if !used[name] { + used[name] = true + return name + } + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%d%s", stem, i, ext) + if !used[candidate] { + used[candidate] = true + return candidate + } + } +} + +// archiveBaseName is the date-stamped stem for a favorites archive. +func archiveBaseName() string { + return "aether-favorites-" + time.Now().Format("2006-01-02") +} + +// uniquePath selects an unused name. Archive publication checks the destination again. +func uniquePath(dir, base, ext string) (string, error) { + for i := 1; i < 1000; i++ { + name := base + ext + if i > 1 { + name = fmt.Sprintf("%s-%d%s", base, i, ext) + } + path := filepath.Join(dir, name) + if !platform.FileExists(path) && !platform.FileExists(path+".part") { + return path, nil + } + } + return "", fmt.Errorf("could not find an unused file name in %s", dir) +} + +// emit publishes a Wails event, tolerating a context that carries no event +// manager. Wails' getEvents log.Fatalf's in that case, which would take the +// process down when the exporter runs outside the GUI (tests, CLI). +func emit(ctx context.Context, event string, payload interface{}) { + if ctx == nil || ctx.Value("events") == nil { + return + } + if payload == nil { + wailsrt.EventsEmit(ctx, event) + return + } + wailsrt.EventsEmit(ctx, event, payload) +} + +func emitProgress(ctx context.Context, phase string, index, total int, name string) { + emit(ctx, "favorites-export-progress", map[string]interface{}{ + "phase": phase, + "index": index, + "total": total, + "name": name, + }) +} diff --git a/internal/favexport/exporter_test.go b/internal/favexport/exporter_test.go new file mode 100644 index 00000000..39f173eb --- /dev/null +++ b/internal/favexport/exporter_test.go @@ -0,0 +1,374 @@ +package favexport + +import ( + "archive/zip" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sort" + "testing" + "time" +) + +// stubDownloader stands in for the wallhaven client. Remote URLs map to files +// that already exist on disk, so the tests never touch the network. +type stubDownloader struct { + files map[string]string // url -> local path + block chan struct{} // when non-nil, downloads wait on it or on ctx + calls int + failAll bool +} + +func (s *stubDownloader) DownloadContext(ctx context.Context, url string) (string, error) { + s.calls++ + if s.block != nil { + select { + case <-s.block: + case <-ctx.Done(): + return "", ctx.Err() + } + } + if s.failAll { + return "", context.DeadlineExceeded + } + local, ok := s.files[url] + if !ok { + return "", os.ErrNotExist + } + return local, nil +} + +func writeFixture(t *testing.T, dir, name, content string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} + +// runExport starts an export and waits for it to settle. +func runExport(t *testing.T, e *Exporter, items []Item, destDir string) string { + t.Helper() + zipPath, err := e.Start(context.Background(), items, destDir) + if err != nil { + t.Fatalf("Start: %v", err) + } + waitIdle(t, e) + return zipPath +} + +func waitIdle(t *testing.T, e *Exporter) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for e.IsRunning() { + if time.Now().After(deadline) { + t.Fatal("export did not finish within 5s") + } + time.Sleep(2 * time.Millisecond) + } +} + +// zipEntries lists the archive's entry names, sorted. +func zipEntries(t *testing.T, zipPath string) []string { + t.Helper() + r, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open %s: %v", zipPath, err) + } + defer r.Close() + + names := make([]string, 0, len(r.File)) + for _, f := range r.File { + names = append(names, f.Name) + } + sort.Strings(names) + return names +} + +func readZipEntry(t *testing.T, zipPath, name string) []byte { + t.Helper() + r, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open %s: %v", zipPath, err) + } + defer r.Close() + + for _, f := range r.File { + if f.Name != name { + continue + } + rc, err := f.Open() + if err != nil { + t.Fatalf("open entry %s: %v", name, err) + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read entry %s: %v", name, err) + } + return data + } + t.Fatalf("entry %s not found in %s", name, zipPath) + return nil +} + +func assertNoLeftovers(t *testing.T, zipPath string) { + t.Helper() + if _, err := os.Stat(zipPath); err == nil { + t.Errorf("expected no archive at %s", zipPath) + } + if _, err := os.Stat(zipPath + ".part"); err == nil { + t.Errorf("expected no partial archive at %s.part", zipPath) + } +} + +func TestExportArchivesLocalAndRemoteWithManifest(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + + local := writeFixture(t, src, "sunset.jpg", "local-bytes") + remoteLocal := writeFixture(t, src, "wallhaven-abc123.jpg", "remote-bytes") + + dl := &stubDownloader{files: map[string]string{ + "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg": remoteLocal, + }} + + items := []Item{ + {Path: local, Name: "sunset.jpg", Meta: map[string]interface{}{"type": "local"}}, + { + Path: "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg", + Name: "abc123", + Meta: map[string]interface{}{"type": "wallhaven", "id": "abc123"}, + }, + } + + zipPath := runExport(t, New(dl), items, dest) + + got := zipEntries(t, zipPath) + want := []string{"abc123.jpg", manifestName, "sunset.jpg"} + sort.Strings(want) + if len(got) != len(want) { + t.Fatalf("entries = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("entries = %v, want %v", got, want) + } + } + + if body := string(readZipEntry(t, zipPath, "sunset.jpg")); body != "local-bytes" { + t.Errorf("sunset.jpg = %q, want %q", body, "local-bytes") + } + + var manifest []manifestEntry + if err := json.Unmarshal(readZipEntry(t, zipPath, manifestName), &manifest); err != nil { + t.Fatalf("manifest: %v", err) + } + if len(manifest) != 2 { + t.Fatalf("manifest has %d entries, want 2", len(manifest)) + } + if manifest[1].Source != items[1].Path { + t.Errorf("manifest source = %q, want %q", manifest[1].Source, items[1].Path) + } + if manifest[1].Meta["id"] != "abc123" { + t.Errorf("manifest meta id = %v, want abc123", manifest[1].Meta["id"]) + } +} + +// A wallhaven id has no extension; the one from the URL should be borrowed so +// the archived file stays openable. +func TestEntryLabelBorrowsExtensionFromPath(t *testing.T) { + got := entryLabel(Item{Path: "https://w.wallhaven.cc/full/ab/wallhaven-abc.png", Name: "abc"}) + if got != "abc.png" { + t.Errorf("entryLabel = %q, want abc.png", got) + } +} + +func TestExportDeduplicatesEntryNames(t *testing.T) { + root := t.TempDir() + dest := t.TempDir() + + a := writeFixture(t, filepath.Join(root, "a"), "wall.jpg", "a") + b := writeFixture(t, filepath.Join(root, "b"), "wall.jpg", "b") + + zipPath := runExport(t, New(nil), []Item{{Path: a}, {Path: b}}, dest) + + got := zipEntries(t, zipPath) + want := []string{manifestName, "wall-2.jpg", "wall.jpg"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("entries = %v, want %v", got, want) + } + } + if body := string(readZipEntry(t, zipPath, "wall-2.jpg")); body != "b" { + t.Errorf("wall-2.jpg = %q, want %q", body, "b") + } +} + +func TestExportSkipsUnreachableItemsButKeepsGoing(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + + good := writeFixture(t, src, "good.jpg", "good") + dl := &stubDownloader{failAll: true} + + items := []Item{ + {Path: filepath.Join(src, "missing.jpg")}, + {Path: good}, + {Path: "https://w.wallhaven.cc/full/zz/wallhaven-zzz.jpg"}, + } + + zipPath := runExport(t, New(dl), items, dest) + + got := zipEntries(t, zipPath) + want := []string{manifestName, "good.jpg"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("entries = %v, want %v", got, want) + } +} + +func TestExportFailsWhenNothingIsExportable(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + + items := []Item{{Path: filepath.Join(src, "nope.jpg")}} + zipPath := runExport(t, New(nil), items, dest) + + assertNoLeftovers(t, zipPath) +} + +func TestCancelLeavesNoArchiveBehind(t *testing.T) { + dest := t.TempDir() + dl := &stubDownloader{block: make(chan struct{})} + e := New(dl) + + zipPath, err := e.Start(context.Background(), []Item{ + {Path: "https://w.wallhaven.cc/full/aa/wallhaven-aaa.jpg"}, + }, dest) + if err != nil { + t.Fatalf("Start: %v", err) + } + + e.Cancel() + waitIdle(t, e) + assertNoLeftovers(t, zipPath) +} + +func TestStartRejectsEmptySelection(t *testing.T) { + if _, err := New(nil).Start(context.Background(), nil, t.TempDir()); err == nil { + t.Fatal("expected an error for an empty selection") + } +} + +func TestStartRejectsConcurrentExports(t *testing.T) { + dest := t.TempDir() + dl := &stubDownloader{block: make(chan struct{})} + e := New(dl) + + items := []Item{{Path: "https://w.wallhaven.cc/full/aa/wallhaven-aaa.jpg"}} + if _, err := e.Start(context.Background(), items, dest); err != nil { + t.Fatalf("first Start: %v", err) + } + if _, err := e.Start(context.Background(), items, dest); err == nil { + t.Error("expected the second Start to be rejected") + } + + e.Cancel() + waitIdle(t, e) +} + +func TestArchiveNameNeverOverwritesAnExistingExport(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + fixture := writeFixture(t, src, "wall.jpg", "x") + + e := New(nil) + first := runExport(t, e, []Item{{Path: fixture}}, dest) + second := runExport(t, e, []Item{{Path: fixture}}, dest) + + if first == second { + t.Fatalf("second export reused %s", first) + } + if _, err := os.Stat(first); err != nil { + t.Errorf("first archive was clobbered: %v", err) + } + if filepath.Base(second) != archiveBaseName()+"-2.zip" { + t.Errorf("second archive = %s, want %s-2.zip", filepath.Base(second), archiveBaseName()) + } +} + +func TestSanitizeNameStripsPathTraversal(t *testing.T) { + if got := entryLabel(Item{Path: "/tmp/x.jpg", Name: "../../etc/passwd"}); got != "etcpasswd.jpg" { + t.Errorf("entryLabel = %q, want etcpasswd.jpg", got) + } +} + +type cancelAfterCheck struct { + context.Context + cancel context.CancelFunc + checks int +} + +func (c *cancelAfterCheck) Err() error { + c.checks++ + err := c.Context.Err() + if c.checks == 1 { + c.cancel() + } + return err +} + +func TestCancelDuringFinalArchiveItemPreventsPublication(t *testing.T) { + dir := t.TempDir() + source := writeFixture(t, dir, "wallpaper.jpg", "image") + final := filepath.Join(dir, "favorites.zip") + base, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx := &cancelAfterCheck{Context: base, cancel: cancel} + _, err := writeArchive(context.Background(), ctx, []resolved{{item: Item{Path: source}, local: source}}, final+".part", final) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want cancellation", err) + } + assertNoLeftovers(t, final) +} + +func TestArchivePreservesExistingFiles(t *testing.T) { + for _, suffix := range []string{"", ".part"} { + t.Run(suffix, func(t *testing.T) { + dir := t.TempDir() + source := writeFixture(t, dir, "wallpaper.jpg", "image") + final := filepath.Join(dir, "favorites.zip") + protected := writeFixture(t, dir, "favorites.zip"+suffix, "existing content") + _, err := writeArchive(context.Background(), context.Background(), []resolved{{item: Item{Path: source}, local: source}}, final+".part", final) + if err == nil { + t.Fatal("archive replaces an existing file") + } + data, err := os.ReadFile(protected) + if err != nil || string(data) != "existing content" { + t.Fatalf("existing file changed: %q, %v", data, err) + } + }) + } +} + +func TestExportReservesManifestName(t *testing.T) { + source := writeFixture(t, t.TempDir(), "wall.jpg", "image") + path := runExport(t, New(nil), []Item{{Path: source, Name: manifestName}}, t.TempDir()) + if got := zipEntries(t, path); len(got) != 2 || got[0] != "favorites-2.json" || got[1] != manifestName { + t.Fatalf("archive entries = %v", got) + } +} + +func TestRemoteEntryLabelExcludesQueryAndFragment(t *testing.T) { + got := entryLabel(Item{Path: "https://example.com/wall.png?download=1#preview", Name: "wall"}) + if got != "wall.png" { + t.Fatalf("entry name = %q", got) + } +} diff --git a/internal/wallhaven/client.go b/internal/wallhaven/client.go index 5656e13e..422ebbdd 100644 --- a/internal/wallhaven/client.go +++ b/internal/wallhaven/client.go @@ -1,6 +1,7 @@ package wallhaven import ( + "context" "encoding/json" "fmt" "io" @@ -226,10 +227,19 @@ func (c *Client) Info(id string) (*WallpaperInfo, error) { // Download downloads a wallpaper image to the local downloads directory. // Returns the local file path. func (c *Client) Download(imageURL string) (string, error) { - return c.download(imageURL, platform.DownloadDir(), wallpaper.MaxImageBytes) + return c.DownloadContext(context.Background(), imageURL) +} + +// DownloadContext downloads a wallpaper with cancellation and the shared network limits. +func (c *Client) DownloadContext(ctx context.Context, imageURL string) (string, error) { + return c.downloadContext(ctx, imageURL, platform.DownloadDir(), wallpaper.MaxImageBytes) } func (c *Client) download(rawURL, destDir string, maxBytes int64) (string, error) { + return c.downloadContext(context.Background(), rawURL, destDir, maxBytes) +} + +func (c *Client) downloadContext(ctx context.Context, rawURL, destDir string, maxBytes int64) (string, error) { if err := wallpaper.ValidateRemoteURL(rawURL); err != nil { return "", err } @@ -240,7 +250,11 @@ func (c *Client) download(rawURL, destDir string, maxBytes int64) (string, error return "", fmt.Errorf("invalid download filename") } destPath := filepath.Join(destDir, filename) - if err := wallpaper.DownloadFile(c.http, rawURL, destPath, maxBytes); err != nil { + client := *c.http + if maxBytes == wallpaper.MaxImageBytes { + client.Timeout = 5 * time.Minute + } + if err := wallpaper.DownloadFileContext(ctx, &client, rawURL, destPath, maxBytes); err != nil { return "", err } return destPath, nil diff --git a/internal/wallpaper/download.go b/internal/wallpaper/download.go index 11e71b91..b44a81c7 100644 --- a/internal/wallpaper/download.go +++ b/internal/wallpaper/download.go @@ -77,6 +77,14 @@ func DownloadToCache(rawURL string, maxBytes int64) (string, error) { // atomically at dest. Existing regular files within the limit are reused. // client must use NewPublicHTTPClient's redirect and dial policy. func DownloadFile(client *http.Client, rawURL, dest string, maxBytes int64) error { + return DownloadFileContext(context.Background(), client, rawURL, dest, maxBytes) +} + +// DownloadFileContext applies the shared download limits and observes cancellation. +func DownloadFileContext(ctx context.Context, client *http.Client, rawURL, dest string, maxBytes int64) error { + if err := ctx.Err(); err != nil { + return err + } if maxBytes <= 0 || maxBytes == 1<<63-1 { return fmt.Errorf("invalid download size limit") } @@ -95,7 +103,11 @@ func DownloadFile(client *http.Client, rawURL, dest string, maxBytes int64) erro return fmt.Errorf("stat download: %w", err) } - resp, err := client.Get(rawURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("create download request: %w", err) + } + resp, err := client.Do(req) if err != nil { return fmt.Errorf("download: %w", err) } @@ -128,6 +140,9 @@ func DownloadFile(client *http.Client, rawURL, dest string, maxBytes int64) erro if err := tmp.Close(); err != nil { return fmt.Errorf("close: %w", err) } + if err := ctx.Err(); err != nil { + return err + } if err := os.Rename(tmpName, dest); err != nil { return fmt.Errorf("rename: %w", err) } diff --git a/internal/wallpaper/download_test.go b/internal/wallpaper/download_test.go index 7d9ee99a..bc035978 100644 --- a/internal/wallpaper/download_test.go +++ b/internal/wallpaper/download_test.go @@ -50,6 +50,25 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestDownloadContextReachesTransportAndPreventsPublication(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + cancel() + <-req.Context().Done() + return nil, req.Context().Err() + })} + dir := t.TempDir() + err := DownloadFileContext(ctx, client, "https://example.com/wallpaper.png", filepath.Join(dir, "wallpaper.png"), 1024) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want cancellation", err) + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 0 { + t.Fatalf("cancelled download leaves files: %v, %v", entries, err) + } +} + type readerFunc func([]byte) (int, error) func (f readerFunc) Read(p []byte) (int, error) { return f(p) }