From b82c8a45e7b606bc43ecf7a40acfed3fd0c5ba1c Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:00:50 -0300 Subject: [PATCH 01/16] Add GitHub source provider for remote repo wallpapers - New internal/githubsource/ package: parses GitHub URLs, queries Contents API, filters images, returns raw.githubusercontent.com URLs - Frontend: GitHub tab with URL input, lazy-loading image grid, download-on-use flow (reuses DownloadWallpaper) - Nav: Sources dropdown grouping Wallhaven, GitHub, Local - Tests: parseURL for 5 URL formats, filterImages, buildRawURL --- app.go | 13 + frontend/src/App.svelte | 4 + .../components/github/GitHubBrowser.svelte | 222 +++ .../lib/components/github/RemoteImage.svelte | 30 + .../lib/components/layout/HeaderBar.svelte | 127 +- frontend/src/lib/stores/github.svelte.ts | 50 + frontend/src/lib/stores/ui.svelte.ts | 1 + frontend/wailsjs/go/main/App.d.ts | 151 +- frontend/wailsjs/go/main/App.js | 130 +- frontend/wailsjs/go/models.ts | 1220 +++++++++-------- internal/githubsource/provider.go | 231 ++++ internal/githubsource/provider_test.go | 176 +++ internal/githubsource/types.go | 17 + 13 files changed, 1603 insertions(+), 769 deletions(-) create mode 100644 frontend/src/lib/components/github/GitHubBrowser.svelte create mode 100644 frontend/src/lib/components/github/RemoteImage.svelte create mode 100644 frontend/src/lib/stores/github.svelte.ts create mode 100644 internal/githubsource/provider.go create mode 100644 internal/githubsource/provider_test.go create mode 100644 internal/githubsource/types.go diff --git a/app.go b/app.go index 2cc5f47..f15bc1d 100644 --- a/app.go +++ b/app.go @@ -16,6 +16,7 @@ import ( "aether/internal/color" "aether/internal/extraction" "aether/internal/favorites" + "aether/internal/githubsource" "aether/internal/omarchy" "aether/internal/platform" "aether/internal/template" @@ -37,6 +38,7 @@ type App struct { blueprints *blueprint.Service favorites *favorites.Service wallhaven *wallhaven.Client + github *githubsource.Client batch *batch.Processor themeWatcher *theme.ThemeWatcher media *MediaServer @@ -72,6 +74,7 @@ func NewApp() *App { blueprints: blueprint.NewService(), favorites: favorites.NewService(), wallhaven: wallhaven.NewClient(), + github: githubsource.NewClient(), batch: batch.NewProcessor(), themeWatcher: theme.NewThemeWatcher(), } @@ -663,6 +666,16 @@ func (a *App) DownloadWallpaper(imageURL string) (string, error) { return a.wallhaven.Download(imageURL) } +// --------------------------------------------------------------------------- +// GitHub Source +// --------------------------------------------------------------------------- + +// ListGitHubImages fetches all image files from a GitHub repository URL. +// Accepts github.com, .github.io, and raw.githubusercontent.com URLs. +func (a *App) ListGitHubImages(rawURL string) ([]githubsource.ImageInfo, error) { + return a.github.ListImages(rawURL) +} + // --------------------------------------------------------------------------- // Local Wallpapers // --------------------------------------------------------------------------- diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index ef3d304..ec5f38b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -6,6 +6,7 @@ import TargetAppsStrip from '$lib/components/layout/TargetAppsStrip.svelte'; import ThemeEditor from '$lib/components/editor/ThemeEditor.svelte'; import WallhavenBrowser from '$lib/components/wallhaven/WallhavenBrowser.svelte'; + import GitHubBrowser from '$lib/components/github/GitHubBrowser.svelte'; import LocalBrowser from '$lib/components/local/LocalBrowser.svelte'; import FavoritesView from '$lib/components/favorites/FavoritesView.svelte'; import BlueprintsView from '$lib/components/blueprints/BlueprintsView.svelte'; @@ -39,6 +40,7 @@ const VALID_TABS: readonly Tab[] = [ 'editor', 'wallhaven', + 'github', 'local', 'favorites', 'blueprints', @@ -439,6 +441,8 @@ {:else if activeTab === 'wallhaven'} + {:else if activeTab === 'github'} + {:else if activeTab === 'local'} {:else if activeTab === 'favorites'} diff --git a/frontend/src/lib/components/github/GitHubBrowser.svelte b/frontend/src/lib/components/github/GitHubBrowser.svelte new file mode 100644 index 0000000..2dc3537 --- /dev/null +++ b/frontend/src/lib/components/github/GitHubBrowser.svelte @@ -0,0 +1,222 @@ + + +
+ + GitHub URL + + +
+ + {#if results.length > 0} + {results.length} image{results.length === 1 ? '' : 's'} + {/if} +
+
+ +
+ {#if isLoading} + + {:else if error} + + {:else if results.length === 0} + + {#snippet icon()} + + + + + {/snippet} + + {:else} +
+ {#each results as img, i} +
+ + + + +
+ +
+ + + +
+
+ +
+ {img.name} +
+
+ {/each} +
+ {/if} +
+
+ += 0 ? results[previewIndex]?.url : ''} + alt={previewIndex >= 0 ? results[previewIndex]?.name : ''} + open={previewIndex >= 0} + onclose={() => (previewIndex = -1)} + hasPrev={previewIndex > 0} + hasNext={previewIndex < results.length - 1} + onprev={() => previewIndex--} + onnext={() => previewIndex++} +/> diff --git a/frontend/src/lib/components/github/RemoteImage.svelte b/frontend/src/lib/components/github/RemoteImage.svelte new file mode 100644 index 0000000..04e81da --- /dev/null +++ b/frontend/src/lib/components/github/RemoteImage.svelte @@ -0,0 +1,30 @@ + + +
+ {#if inView} + + {/if} +
diff --git a/frontend/src/lib/components/layout/HeaderBar.svelte b/frontend/src/lib/components/layout/HeaderBar.svelte index 9ebe1c4..4bf11d2 100644 --- a/frontend/src/lib/components/layout/HeaderBar.svelte +++ b/frontend/src/lib/components/layout/HeaderBar.svelte @@ -14,6 +14,8 @@ let sidebarVisible = $derived(getSidebarVisible()); let activeTab = $derived(getActiveTab()); let isMac = $state(false); + let sourcesOpen = $state(false); + let sourcesRef = $state(null); onMount(async () => { try { @@ -26,46 +28,73 @@ { id: 'editor', label: 'Editor', - // Sliders (adjustments) icon: '', }, - { - id: 'wallhaven', - label: 'Wallhaven', - // Globe - icon: '', - }, - { - id: 'local', - label: 'Local', - // Folder - icon: '', - }, { id: 'favorites', label: 'Favorites', - // Heart icon: '', }, { id: 'blueprints', label: 'Blueprints', - // Layers icon: '', }, { id: 'system', label: 'System Themes', - // Paintbrush / Brush icon: '', }, { id: 'about', label: 'About', - // Info circle icon: '', }, ]; + + const sourceItems: {id: Tab; label: string; icon: string}[] = [ + { + id: 'wallhaven', + label: 'Wallhaven', + icon: '', + }, + { + id: 'github', + label: 'GitHub', + icon: '', + }, + { + id: 'local', + label: 'Local', + icon: '', + }, + ]; + + let anySourceActive = $derived( + activeTab === 'wallhaven' || activeTab === 'github' || activeTab === 'local' + ); + + function selectSource(id: Tab) { + setActiveTab(id); + sourcesOpen = false; + } + + function toggleSources() { + sourcesOpen = !sourcesOpen; + } + + $effect(() => { + if (!sourcesOpen || !sourcesRef) return; + + function onPointerDown(e: PointerEvent) { + if (sourcesRef && !sourcesRef.contains(e.target as Node)) { + sourcesOpen = false; + } + } + + window.addEventListener('pointerdown', onPointerDown); + return () => window.removeEventListener('pointerdown', onPointerDown); + });
{/each} + + +
+ + + {#if sourcesOpen} +
+ {#each sourceItems as item} + + {/each} +
+ {/if} +
([]); +let isLoading = $state(false); +let error = $state(''); + +export function getURL(): string { + return url; +} + +export function getResults(): ImageInfo[] { + return results; +} + +export function getIsLoading(): boolean { + return isLoading; +} + +export function getError(): string { + return error; +} + +export function setURL(u: string): void { + url = u; +} + +export async function fetchImages(): Promise { + const trimmed = url.trim(); + if (!trimmed) return; + + isLoading = true; + error = ''; + results = []; + + try { + const {ListGitHubImages} = await import( + '../../../wailsjs/go/main/App' + ); + const data = await ListGitHubImages(trimmed); + results = Array.isArray(data) ? data : []; + } catch (e: any) { + error = e?.message || 'Failed to fetch images from GitHub'; + results = []; + } finally { + isLoading = false; + } +} diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index a919c1d..302b087 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -3,6 +3,7 @@ import {STORAGE_KEYS} from '$lib/constants/storage'; export type Tab = | 'editor' | 'wallhaven' + | 'github' | 'local' | 'favorites' | 'blueprints' diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index c05d572..c54d549 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -5,154 +5,127 @@ import {theme} from '../models'; import {main} from '../models'; import {favorites} from '../models'; import {ipc} from '../models'; +import {githubsource} from '../models'; import {omarchy} from '../models'; import {wallpaper} from '../models'; import {wallhaven} from '../models'; -export function AdjustPaletteColors( - arg1: Array, - arg2: color.Adjustments -): Promise>; +export function AdjustPaletteColors(arg1:Array,arg2:color.Adjustments):Promise>; -export function ApplyBlueprint(arg1: string): Promise; +export function ApplyBlueprint(arg1:string):Promise; -export function ApplyOmarchyThemeByName(arg1: string): Promise; +export function ApplyOmarchyThemeByName(arg1:string):Promise; -export function ApplyTheme( - arg1: main.ApplyThemeRequest -): Promise; +export function ApplyTheme(arg1:main.ApplyThemeRequest):Promise; -export function BlueprintExists(arg1: string): Promise; +export function BlueprintExists(arg1:string):Promise; -export function CancelBatchProcessing(): Promise; +export function CancelBatchProcessing():Promise; -export function CancelExternalImport(): Promise; +export function CancelExternalImport():Promise; -export function ClearTheme(): Promise; +export function ClearTheme():Promise; -export function CloseIPC(): Promise; +export function CloseIPC():Promise; -export function ComputeVariables( - arg1: Array, - arg2: Record, - arg3: boolean -): Promise>; +export function ComputeVariables(arg1:Array,arg2:Record,arg3:boolean):Promise>; -export function ConfirmExternalImport(): Promise; +export function ConfirmExternalImport():Promise; -export function ContrastRatio(arg1: string, arg2: string): Promise; +export function ContrastRatio(arg1:string,arg2:string):Promise; -export function DeleteBlueprint(arg1: string): Promise; +export function DeleteBlueprint(arg1:string):Promise; -export function DownloadWallpaper(arg1: string): Promise; +export function DownloadWallpaper(arg1:string):Promise; -export function ExportTheme(arg1: main.ExportThemeRequest): Promise; +export function ExportTheme(arg1:main.ExportThemeRequest):Promise; -export function ExtractColors( - arg1: string, - arg2: boolean, - arg3: string -): Promise; +export function ExtractColors(arg1:string,arg2:boolean,arg3:string):Promise; -export function ExtractColorsFromImages( - arg1: Array, - arg2: boolean, - arg3: string -): Promise; +export function ExtractColorsFromImages(arg1:Array,arg2:boolean,arg3:string):Promise; -export function GenerateGradient(arg1: string, arg2: string): Promise; +export function GenerateGradient(arg1:string,arg2:string):Promise; -export function GeneratePaletteFromColor(arg1: string): Promise; +export function GeneratePaletteFromColor(arg1:string):Promise; -export function GetFavorites(): Promise>; +export function GetFavorites():Promise>; -export function GetFocusTab(): Promise; +export function GetFocusTab():Promise; -export function GetInitialState(): Promise; +export function GetInitialState():Promise; -export function GetMediaURL(arg1: string): Promise; +export function GetMediaURL(arg1:string):Promise; -export function GetPendingExternalImport(): Promise; +export function GetPendingExternalImport():Promise; -export function GetPreview(arg1: string): Promise; +export function GetPreview(arg1:string):Promise; -export function GetSettings(): Promise>; +export function GetSettings():Promise>; -export function GetTemplateColors(): Promise>>; +export function GetTemplateColors():Promise>>; -export function GetThemeColors(): Promise>; +export function GetThemeColors():Promise>; -export function GetThumbnail(arg1: string): Promise; +export function GetThumbnail(arg1:string):Promise; -export function GetWallhavenConfig(): Promise>; +export function GetWallhavenConfig():Promise>; -export function GetWallpaperTags(): Promise>; +export function GetWallpaperTags():Promise>; -export function HandleDroppedFiles(arg1: Array): Promise; +export function HandleDroppedFiles(arg1:Array):Promise; -export function HandleIPC(arg1: ipc.Request): Promise; +export function HandleIPC(arg1:ipc.Request):Promise; -export function ImportFileDialog(arg1: string): Promise; +export function ImportFileDialog(arg1:string):Promise; -export function IsAetherWpAvailable(): Promise; +export function IsAetherWpAvailable():Promise; -export function IsFavorite(arg1: string): Promise; +export function IsFavorite(arg1:string):Promise; -export function IsMacOS(): Promise; +export function IsMacOS():Promise; -export function IsOmarchyInstalled(): Promise; +export function IsOmarchyInstalled():Promise; -export function IsPreviewCached(arg1: string): Promise; +export function IsPreviewCached(arg1:string):Promise; -export function ListBlueprints(): Promise>>; +export function ListBlueprints():Promise>>; -export function LoadBlueprint(arg1: string): Promise; +export function ListGitHubImages(arg1:string):Promise>; -export function LoadOmarchyThemes(): Promise>; +export function LoadBlueprint(arg1:string):Promise; -export function OpenExternalImportInEditor(): Promise; +export function LoadOmarchyThemes():Promise>; -export function OpenFileDialog(): Promise; +export function OpenExternalImportInEditor():Promise; -export function PreviewExtractColors( - arg1: string, - arg2: boolean, - arg3: string -): Promise; +export function OpenFileDialog():Promise; -export function ReadImageAsDataURL(arg1: string): Promise; +export function PreviewExtractColors(arg1:string,arg2:boolean,arg3:string):Promise; -export function ResetState(): Promise; +export function ReadImageAsDataURL(arg1:string):Promise; -export function SaveBlueprint(arg1: main.SaveBlueprintRequest): Promise; +export function ResetState():Promise; -export function SaveDataURLToFile(arg1: string, arg2: string): Promise; +export function SaveBlueprint(arg1:main.SaveBlueprintRequest):Promise; -export function SaveSettings(arg1: Record): Promise; +export function SaveDataURLToFile(arg1:string,arg2:string):Promise; -export function SaveWallhavenConfig(arg1: Record): Promise; +export function SaveSettings(arg1:Record):Promise; -export function SaveWallpaperTags(arg1: Record): Promise; +export function SaveWallhavenConfig(arg1:Record):Promise; -export function ScanLocalWallpapers(): Promise>; +export function SaveWallpaperTags(arg1:Record):Promise; -export function SearchWallhaven( - arg1: wallhaven.SearchParams -): Promise; +export function ScanLocalWallpapers():Promise>; -export function SetExtractionMode(arg1: string): Promise; +export function SearchWallhaven(arg1:wallhaven.SearchParams):Promise; -export function SetWallhavenAPIKey(arg1: string): Promise; +export function SetExtractionMode(arg1:string):Promise; -export function StartBatchProcessing( - arg1: Array, - arg2: boolean -): Promise; +export function SetWallhavenAPIKey(arg1:string):Promise; -export function SyncState(arg1: main.SyncStateRequest): Promise; +export function StartBatchProcessing(arg1:Array,arg2:boolean):Promise; -export function ToggleFavorite( - arg1: string, - arg2: string, - arg3: Record -): Promise; +export function SyncState(arg1:main.SyncStateRequest):Promise; + +export function ToggleFavorite(arg1:string,arg2:string,arg3:Record):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index da54d36..9813d81 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -3,245 +3,241 @@ // This file is automatically generated. DO NOT EDIT export function AdjustPaletteColors(arg1, arg2) { - return window['go']['main']['App']['AdjustPaletteColors'](arg1, arg2); + return window['go']['main']['App']['AdjustPaletteColors'](arg1, arg2); } export function ApplyBlueprint(arg1) { - return window['go']['main']['App']['ApplyBlueprint'](arg1); + return window['go']['main']['App']['ApplyBlueprint'](arg1); } export function ApplyOmarchyThemeByName(arg1) { - return window['go']['main']['App']['ApplyOmarchyThemeByName'](arg1); + return window['go']['main']['App']['ApplyOmarchyThemeByName'](arg1); } export function ApplyTheme(arg1) { - return window['go']['main']['App']['ApplyTheme'](arg1); + return window['go']['main']['App']['ApplyTheme'](arg1); } export function BlueprintExists(arg1) { - return window['go']['main']['App']['BlueprintExists'](arg1); + return window['go']['main']['App']['BlueprintExists'](arg1); } export function CancelBatchProcessing() { - return window['go']['main']['App']['CancelBatchProcessing'](); + return window['go']['main']['App']['CancelBatchProcessing'](); } export function CancelExternalImport() { - return window['go']['main']['App']['CancelExternalImport'](); + return window['go']['main']['App']['CancelExternalImport'](); } export function ClearTheme() { - return window['go']['main']['App']['ClearTheme'](); + return window['go']['main']['App']['ClearTheme'](); } export function CloseIPC() { - return window['go']['main']['App']['CloseIPC'](); + return window['go']['main']['App']['CloseIPC'](); } export function ComputeVariables(arg1, arg2, arg3) { - return window['go']['main']['App']['ComputeVariables'](arg1, arg2, arg3); + return window['go']['main']['App']['ComputeVariables'](arg1, arg2, arg3); } export function ConfirmExternalImport() { - return window['go']['main']['App']['ConfirmExternalImport'](); + return window['go']['main']['App']['ConfirmExternalImport'](); } export function ContrastRatio(arg1, arg2) { - return window['go']['main']['App']['ContrastRatio'](arg1, arg2); + return window['go']['main']['App']['ContrastRatio'](arg1, arg2); } export function DeleteBlueprint(arg1) { - return window['go']['main']['App']['DeleteBlueprint'](arg1); + return window['go']['main']['App']['DeleteBlueprint'](arg1); } export function DownloadWallpaper(arg1) { - return window['go']['main']['App']['DownloadWallpaper'](arg1); + return window['go']['main']['App']['DownloadWallpaper'](arg1); } export function ExportTheme(arg1) { - return window['go']['main']['App']['ExportTheme'](arg1); + return window['go']['main']['App']['ExportTheme'](arg1); } export function ExtractColors(arg1, arg2, arg3) { - return window['go']['main']['App']['ExtractColors'](arg1, arg2, arg3); + return window['go']['main']['App']['ExtractColors'](arg1, arg2, arg3); } export function ExtractColorsFromImages(arg1, arg2, arg3) { - return window['go']['main']['App']['ExtractColorsFromImages']( - arg1, - arg2, - arg3 - ); + return window['go']['main']['App']['ExtractColorsFromImages'](arg1, arg2, arg3); } export function GenerateGradient(arg1, arg2) { - return window['go']['main']['App']['GenerateGradient'](arg1, arg2); + return window['go']['main']['App']['GenerateGradient'](arg1, arg2); } export function GeneratePaletteFromColor(arg1) { - return window['go']['main']['App']['GeneratePaletteFromColor'](arg1); + return window['go']['main']['App']['GeneratePaletteFromColor'](arg1); } export function GetFavorites() { - return window['go']['main']['App']['GetFavorites'](); + return window['go']['main']['App']['GetFavorites'](); } export function GetFocusTab() { - return window['go']['main']['App']['GetFocusTab'](); + return window['go']['main']['App']['GetFocusTab'](); } export function GetInitialState() { - return window['go']['main']['App']['GetInitialState'](); + return window['go']['main']['App']['GetInitialState'](); } export function GetMediaURL(arg1) { - return window['go']['main']['App']['GetMediaURL'](arg1); + return window['go']['main']['App']['GetMediaURL'](arg1); } export function GetPendingExternalImport() { - return window['go']['main']['App']['GetPendingExternalImport'](); + return window['go']['main']['App']['GetPendingExternalImport'](); } export function GetPreview(arg1) { - return window['go']['main']['App']['GetPreview'](arg1); + return window['go']['main']['App']['GetPreview'](arg1); } export function GetSettings() { - return window['go']['main']['App']['GetSettings'](); + return window['go']['main']['App']['GetSettings'](); } export function GetTemplateColors() { - return window['go']['main']['App']['GetTemplateColors'](); + return window['go']['main']['App']['GetTemplateColors'](); } export function GetThemeColors() { - return window['go']['main']['App']['GetThemeColors'](); + return window['go']['main']['App']['GetThemeColors'](); } export function GetThumbnail(arg1) { - return window['go']['main']['App']['GetThumbnail'](arg1); + return window['go']['main']['App']['GetThumbnail'](arg1); } export function GetWallhavenConfig() { - return window['go']['main']['App']['GetWallhavenConfig'](); + return window['go']['main']['App']['GetWallhavenConfig'](); } export function GetWallpaperTags() { - return window['go']['main']['App']['GetWallpaperTags'](); + return window['go']['main']['App']['GetWallpaperTags'](); } export function HandleDroppedFiles(arg1) { - return window['go']['main']['App']['HandleDroppedFiles'](arg1); + return window['go']['main']['App']['HandleDroppedFiles'](arg1); } export function HandleIPC(arg1) { - return window['go']['main']['App']['HandleIPC'](arg1); + return window['go']['main']['App']['HandleIPC'](arg1); } export function ImportFileDialog(arg1) { - return window['go']['main']['App']['ImportFileDialog'](arg1); + return window['go']['main']['App']['ImportFileDialog'](arg1); } export function IsAetherWpAvailable() { - return window['go']['main']['App']['IsAetherWpAvailable'](); + return window['go']['main']['App']['IsAetherWpAvailable'](); } export function IsFavorite(arg1) { - return window['go']['main']['App']['IsFavorite'](arg1); + return window['go']['main']['App']['IsFavorite'](arg1); } export function IsMacOS() { - return window['go']['main']['App']['IsMacOS'](); + return window['go']['main']['App']['IsMacOS'](); } export function IsOmarchyInstalled() { - return window['go']['main']['App']['IsOmarchyInstalled'](); + return window['go']['main']['App']['IsOmarchyInstalled'](); } export function IsPreviewCached(arg1) { - return window['go']['main']['App']['IsPreviewCached'](arg1); + return window['go']['main']['App']['IsPreviewCached'](arg1); } export function ListBlueprints() { - return window['go']['main']['App']['ListBlueprints'](); + return window['go']['main']['App']['ListBlueprints'](); +} + +export function ListGitHubImages(arg1) { + return window['go']['main']['App']['ListGitHubImages'](arg1); } export function LoadBlueprint(arg1) { - return window['go']['main']['App']['LoadBlueprint'](arg1); + return window['go']['main']['App']['LoadBlueprint'](arg1); } export function LoadOmarchyThemes() { - return window['go']['main']['App']['LoadOmarchyThemes'](); + return window['go']['main']['App']['LoadOmarchyThemes'](); } export function OpenExternalImportInEditor() { - return window['go']['main']['App']['OpenExternalImportInEditor'](); + return window['go']['main']['App']['OpenExternalImportInEditor'](); } export function OpenFileDialog() { - return window['go']['main']['App']['OpenFileDialog'](); + return window['go']['main']['App']['OpenFileDialog'](); } export function PreviewExtractColors(arg1, arg2, arg3) { - return window['go']['main']['App']['PreviewExtractColors']( - arg1, - arg2, - arg3 - ); + return window['go']['main']['App']['PreviewExtractColors'](arg1, arg2, arg3); } export function ReadImageAsDataURL(arg1) { - return window['go']['main']['App']['ReadImageAsDataURL'](arg1); + return window['go']['main']['App']['ReadImageAsDataURL'](arg1); } export function ResetState() { - return window['go']['main']['App']['ResetState'](); + return window['go']['main']['App']['ResetState'](); } export function SaveBlueprint(arg1) { - return window['go']['main']['App']['SaveBlueprint'](arg1); + return window['go']['main']['App']['SaveBlueprint'](arg1); } export function SaveDataURLToFile(arg1, arg2) { - return window['go']['main']['App']['SaveDataURLToFile'](arg1, arg2); + return window['go']['main']['App']['SaveDataURLToFile'](arg1, arg2); } export function SaveSettings(arg1) { - return window['go']['main']['App']['SaveSettings'](arg1); + return window['go']['main']['App']['SaveSettings'](arg1); } export function SaveWallhavenConfig(arg1) { - return window['go']['main']['App']['SaveWallhavenConfig'](arg1); + return window['go']['main']['App']['SaveWallhavenConfig'](arg1); } export function SaveWallpaperTags(arg1) { - return window['go']['main']['App']['SaveWallpaperTags'](arg1); + return window['go']['main']['App']['SaveWallpaperTags'](arg1); } export function ScanLocalWallpapers() { - return window['go']['main']['App']['ScanLocalWallpapers'](); + return window['go']['main']['App']['ScanLocalWallpapers'](); } export function SearchWallhaven(arg1) { - return window['go']['main']['App']['SearchWallhaven'](arg1); + return window['go']['main']['App']['SearchWallhaven'](arg1); } export function SetExtractionMode(arg1) { - return window['go']['main']['App']['SetExtractionMode'](arg1); + return window['go']['main']['App']['SetExtractionMode'](arg1); } export function SetWallhavenAPIKey(arg1) { - return window['go']['main']['App']['SetWallhavenAPIKey'](arg1); + return window['go']['main']['App']['SetWallhavenAPIKey'](arg1); } export function StartBatchProcessing(arg1, arg2) { - return window['go']['main']['App']['StartBatchProcessing'](arg1, arg2); + return window['go']['main']['App']['StartBatchProcessing'](arg1, arg2); } export function SyncState(arg1) { - return window['go']['main']['App']['SyncState'](arg1); + return window['go']['main']['App']['SyncState'](arg1); } export function ToggleFavorite(arg1, arg2, arg3) { - return window['go']['main']['App']['ToggleFavorite'](arg1, arg2, arg3); + return window['go']['main']['App']['ToggleFavorite'](arg1, arg2, arg3); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 6cae67f..c4dcb25 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,632 +1,660 @@ export namespace color { - export class Adjustments { - vibrance: number; - saturation: number; - contrast: number; - brightness: number; - shadows: number; - highlights: number; - hueShift: number; - temperature: number; - tint: number; - gamma: number; - blackPoint: number; - whitePoint: number; + + export class Adjustments { + vibrance: number; + saturation: number; + contrast: number; + brightness: number; + shadows: number; + highlights: number; + hueShift: number; + temperature: number; + tint: number; + gamma: number; + blackPoint: number; + whitePoint: number; + + static createFrom(source: any = {}) { + return new Adjustments(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.vibrance = source["vibrance"]; + this.saturation = source["saturation"]; + this.contrast = source["contrast"]; + this.brightness = source["brightness"]; + this.shadows = source["shadows"]; + this.highlights = source["highlights"]; + this.hueShift = source["hueShift"]; + this.temperature = source["temperature"]; + this.tint = source["tint"]; + this.gamma = source["gamma"]; + this.blackPoint = source["blackPoint"]; + this.whitePoint = source["whitePoint"]; + } + } - static createFrom(source: any = {}) { - return new Adjustments(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.vibrance = source['vibrance']; - this.saturation = source['saturation']; - this.contrast = source['contrast']; - this.brightness = source['brightness']; - this.shadows = source['shadows']; - this.highlights = source['highlights']; - this.hueShift = source['hueShift']; - this.temperature = source['temperature']; - this.tint = source['tint']; - this.gamma = source['gamma']; - this.blackPoint = source['blackPoint']; - this.whitePoint = source['whitePoint']; - } - } } export namespace favorites { - export class Favorite { - path: string; - type?: string; - data?: Record; - - static createFrom(source: any = {}) { - return new Favorite(source); - } + + export class Favorite { + path: string; + type?: string; + data?: Record; + + static createFrom(source: any = {}) { + return new Favorite(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.path = source["path"]; + this.type = source["type"]; + this.data = source["data"]; + } + } - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.path = source['path']; - this.type = source['type']; - this.data = source['data']; - } - } } -export namespace ipc { - export class Request { - cmd: string; - path?: string; - mode?: string; - name?: string; - index?: number; - value?: string; - palette?: string[]; - vibrance?: number; - saturation?: number; - contrast?: number; - brightness?: number; - shadows?: number; - highlights?: number; - hue_shift?: number; - temperature?: number; - tint?: number; - gamma?: number; - black_point?: number; - white_point?: number; - light_mode?: boolean; +export namespace githubsource { + + export class ImageInfo { + name: string; + url: string; + size: number; + + static createFrom(source: any = {}) { + return new ImageInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.url = source["url"]; + this.size = source["size"]; + } + } - static createFrom(source: any = {}) { - return new Request(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.cmd = source['cmd']; - this.path = source['path']; - this.mode = source['mode']; - this.name = source['name']; - this.index = source['index']; - this.value = source['value']; - this.palette = source['palette']; - this.vibrance = source['vibrance']; - this.saturation = source['saturation']; - this.contrast = source['contrast']; - this.brightness = source['brightness']; - this.shadows = source['shadows']; - this.highlights = source['highlights']; - this.hue_shift = source['hue_shift']; - this.temperature = source['temperature']; - this.tint = source['tint']; - this.gamma = source['gamma']; - this.black_point = source['black_point']; - this.white_point = source['white_point']; - this.light_mode = source['light_mode']; - } - } - export class Response { - ok: boolean; - error?: string; - palette?: string[]; - light_mode?: boolean; - mode?: string; - wallpaper?: string; - data?: number[]; +} - static createFrom(source: any = {}) { - return new Response(source); - } +export namespace ipc { + + export class Request { + cmd: string; + path?: string; + mode?: string; + name?: string; + index?: number; + value?: string; + palette?: string[]; + vibrance?: number; + saturation?: number; + contrast?: number; + brightness?: number; + shadows?: number; + highlights?: number; + hue_shift?: number; + temperature?: number; + tint?: number; + gamma?: number; + black_point?: number; + white_point?: number; + light_mode?: boolean; + + static createFrom(source: any = {}) { + return new Request(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.cmd = source["cmd"]; + this.path = source["path"]; + this.mode = source["mode"]; + this.name = source["name"]; + this.index = source["index"]; + this.value = source["value"]; + this.palette = source["palette"]; + this.vibrance = source["vibrance"]; + this.saturation = source["saturation"]; + this.contrast = source["contrast"]; + this.brightness = source["brightness"]; + this.shadows = source["shadows"]; + this.highlights = source["highlights"]; + this.hue_shift = source["hue_shift"]; + this.temperature = source["temperature"]; + this.tint = source["tint"]; + this.gamma = source["gamma"]; + this.black_point = source["black_point"]; + this.white_point = source["white_point"]; + this.light_mode = source["light_mode"]; + } + } + export class Response { + ok: boolean; + error?: string; + palette?: string[]; + light_mode?: boolean; + mode?: string; + wallpaper?: string; + data?: number[]; + + static createFrom(source: any = {}) { + return new Response(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ok = source["ok"]; + this.error = source["error"]; + this.palette = source["palette"]; + this.light_mode = source["light_mode"]; + this.mode = source["mode"]; + this.wallpaper = source["wallpaper"]; + this.data = source["data"]; + } + } - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.ok = source['ok']; - this.error = source['error']; - this.palette = source['palette']; - this.light_mode = source['light_mode']; - this.mode = source['mode']; - this.wallpaper = source['wallpaper']; - this.data = source['data']; - } - } } export namespace main { - export class ApplyThemeRequest { - palette: string[]; - wallpaperPath: string; - lightMode: boolean; - additionalImages: string[]; - extendedColors: Record; - settings: theme.Settings; - appOverrides: Record; - - static createFrom(source: any = {}) { - return new ApplyThemeRequest(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.palette = source['palette']; - this.wallpaperPath = source['wallpaperPath']; - this.lightMode = source['lightMode']; - this.additionalImages = source['additionalImages']; - this.extendedColors = source['extendedColors']; - this.settings = this.convertValues( - source['settings'], - theme.Settings - ); - this.appOverrides = source['appOverrides']; - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => - this.convertValues(elem, classs) - ); - } else if ('object' === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } - export class ExportThemeRequest { - name: string; - includedApps: string[]; - palette: string[]; - wallpaperPath: string; - lightMode: boolean; - additionalImages: string[]; - extendedColors: Record; - installToOmarchy: boolean; - appOverrides: Record; - - static createFrom(source: any = {}) { - return new ExportThemeRequest(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.name = source['name']; - this.includedApps = source['includedApps']; - this.palette = source['palette']; - this.wallpaperPath = source['wallpaperPath']; - this.lightMode = source['lightMode']; - this.additionalImages = source['additionalImages']; - this.extendedColors = source['extendedColors']; - this.installToOmarchy = source['installToOmarchy']; - this.appOverrides = source['appOverrides']; - } - } - export class ExternalImportPreview { - has_external_theme: boolean; - has_colors: boolean; - has_wallpaper: boolean; - source_url: string; - palette?: string[]; - wallpaper?: string; - theme_name?: string; - mode?: string; - edit: boolean; - - static createFrom(source: any = {}) { - return new ExternalImportPreview(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.has_external_theme = source['has_external_theme']; - this.has_colors = source['has_colors']; - this.has_wallpaper = source['has_wallpaper']; - this.source_url = source['source_url']; - this.palette = source['palette']; - this.wallpaper = source['wallpaper']; - this.theme_name = source['theme_name']; - this.mode = source['mode']; - this.edit = source['edit']; - } - } - export class ExtractFromImagesResult { - palette: string[]; - skipped: number; - - static createFrom(source: any = {}) { - return new ExtractFromImagesResult(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.palette = source['palette']; - this.skipped = source['skipped']; - } - } - export class ImportResult { - colors: string[]; - name: string; - path: string; - wallpaperPath: string; - lightMode: boolean; - - static createFrom(source: any = {}) { - return new ImportResult(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.colors = source['colors']; - this.name = source['name']; - this.path = source['path']; - this.wallpaperPath = source['wallpaperPath']; - this.lightMode = source['lightMode']; - } - } - export class SaveBlueprintRequest { - name: string; - palette: string[]; - wallpaperPath: string; - lightMode: boolean; - additionalImages: string[]; - lockedColors: number[]; - extendedColors: Record; - appOverrides: Record; - adjustments: Record; - - static createFrom(source: any = {}) { - return new SaveBlueprintRequest(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.name = source['name']; - this.palette = source['palette']; - this.wallpaperPath = source['wallpaperPath']; - this.lightMode = source['lightMode']; - this.additionalImages = source['additionalImages']; - this.lockedColors = source['lockedColors']; - this.extendedColors = source['extendedColors']; - this.appOverrides = source['appOverrides']; - this.adjustments = source['adjustments']; - } - } - export class SyncStateRequest { - palette: string[]; - wallpaperPath: string; - lightMode: boolean; - extendedColors: Record; - appOverrides: Record; - additionalImages: string[]; + + export class ApplyThemeRequest { + palette: string[]; + wallpaperPath: string; + lightMode: boolean; + additionalImages: string[]; + extendedColors: Record; + settings: theme.Settings; + appOverrides: Record; + + static createFrom(source: any = {}) { + return new ApplyThemeRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.palette = source["palette"]; + this.wallpaperPath = source["wallpaperPath"]; + this.lightMode = source["lightMode"]; + this.additionalImages = source["additionalImages"]; + this.extendedColors = source["extendedColors"]; + this.settings = this.convertValues(source["settings"], theme.Settings); + this.appOverrides = source["appOverrides"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class ExportThemeRequest { + name: string; + includedApps: string[]; + palette: string[]; + wallpaperPath: string; + lightMode: boolean; + additionalImages: string[]; + extendedColors: Record; + installToOmarchy: boolean; + appOverrides: Record; + + static createFrom(source: any = {}) { + return new ExportThemeRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.includedApps = source["includedApps"]; + this.palette = source["palette"]; + this.wallpaperPath = source["wallpaperPath"]; + this.lightMode = source["lightMode"]; + this.additionalImages = source["additionalImages"]; + this.extendedColors = source["extendedColors"]; + this.installToOmarchy = source["installToOmarchy"]; + this.appOverrides = source["appOverrides"]; + } + } + export class ExternalImportPreview { + has_external_theme: boolean; + has_colors: boolean; + has_wallpaper: boolean; + source_url: string; + palette?: string[]; + wallpaper?: string; + theme_name?: string; + mode?: string; + edit: boolean; + + static createFrom(source: any = {}) { + return new ExternalImportPreview(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.has_external_theme = source["has_external_theme"]; + this.has_colors = source["has_colors"]; + this.has_wallpaper = source["has_wallpaper"]; + this.source_url = source["source_url"]; + this.palette = source["palette"]; + this.wallpaper = source["wallpaper"]; + this.theme_name = source["theme_name"]; + this.mode = source["mode"]; + this.edit = source["edit"]; + } + } + export class ExtractFromImagesResult { + palette: string[]; + skipped: number; + + static createFrom(source: any = {}) { + return new ExtractFromImagesResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.palette = source["palette"]; + this.skipped = source["skipped"]; + } + } + export class ImportResult { + colors: string[]; + name: string; + path: string; + wallpaperPath: string; + lightMode: boolean; + + static createFrom(source: any = {}) { + return new ImportResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.colors = source["colors"]; + this.name = source["name"]; + this.path = source["path"]; + this.wallpaperPath = source["wallpaperPath"]; + this.lightMode = source["lightMode"]; + } + } + export class SaveBlueprintRequest { + name: string; + palette: string[]; + wallpaperPath: string; + lightMode: boolean; + additionalImages: string[]; + lockedColors: number[]; + extendedColors: Record; + appOverrides: Record; + adjustments: Record; + + static createFrom(source: any = {}) { + return new SaveBlueprintRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.palette = source["palette"]; + this.wallpaperPath = source["wallpaperPath"]; + this.lightMode = source["lightMode"]; + this.additionalImages = source["additionalImages"]; + this.lockedColors = source["lockedColors"]; + this.extendedColors = source["extendedColors"]; + this.appOverrides = source["appOverrides"]; + this.adjustments = source["adjustments"]; + } + } + export class SyncStateRequest { + palette: string[]; + wallpaperPath: string; + lightMode: boolean; + extendedColors: Record; + appOverrides: Record; + additionalImages: string[]; + + static createFrom(source: any = {}) { + return new SyncStateRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.palette = source["palette"]; + this.wallpaperPath = source["wallpaperPath"]; + this.lightMode = source["lightMode"]; + this.extendedColors = source["extendedColors"]; + this.appOverrides = source["appOverrides"]; + this.additionalImages = source["additionalImages"]; + } + } - static createFrom(source: any = {}) { - return new SyncStateRequest(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.palette = source['palette']; - this.wallpaperPath = source['wallpaperPath']; - this.lightMode = source['lightMode']; - this.extendedColors = source['extendedColors']; - this.appOverrides = source['appOverrides']; - this.additionalImages = source['additionalImages']; - } - } } export namespace omarchy { - export class Theme { - name: string; - path: string; - colors: string[]; - background: string; - foreground: string; - wallpapers: string[]; - isSymlink: boolean; - isCurrentTheme: boolean; - isAetherGenerated: boolean; - - static createFrom(source: any = {}) { - return new Theme(source); - } + + export class Theme { + name: string; + path: string; + colors: string[]; + background: string; + foreground: string; + wallpapers: string[]; + isSymlink: boolean; + isCurrentTheme: boolean; + isAetherGenerated: boolean; + + static createFrom(source: any = {}) { + return new Theme(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.path = source["path"]; + this.colors = source["colors"]; + this.background = source["background"]; + this.foreground = source["foreground"]; + this.wallpapers = source["wallpapers"]; + this.isSymlink = source["isSymlink"]; + this.isCurrentTheme = source["isCurrentTheme"]; + this.isAetherGenerated = source["isAetherGenerated"]; + } + } - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.name = source['name']; - this.path = source['path']; - this.colors = source['colors']; - this.background = source['background']; - this.foreground = source['foreground']; - this.wallpapers = source['wallpapers']; - this.isSymlink = source['isSymlink']; - this.isCurrentTheme = source['isCurrentTheme']; - this.isAetherGenerated = source['isAetherGenerated']; - } - } } export namespace template { - export class ColorRoles { - background: string; - foreground: string; - black: string; - red: string; - green: string; - yellow: string; - blue: string; - magenta: string; - cyan: string; - white: string; - bright_black: string; - bright_red: string; - bright_green: string; - bright_yellow: string; - bright_blue: string; - bright_magenta: string; - bright_cyan: string; - bright_white: string; - accent: string; - cursor: string; - selection_foreground: string; - selection_background: string; - - static createFrom(source: any = {}) { - return new ColorRoles(source); - } + + export class ColorRoles { + background: string; + foreground: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + bright_black: string; + bright_red: string; + bright_green: string; + bright_yellow: string; + bright_blue: string; + bright_magenta: string; + bright_cyan: string; + bright_white: string; + accent: string; + cursor: string; + selection_foreground: string; + selection_background: string; + + static createFrom(source: any = {}) { + return new ColorRoles(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.background = source["background"]; + this.foreground = source["foreground"]; + this.black = source["black"]; + this.red = source["red"]; + this.green = source["green"]; + this.yellow = source["yellow"]; + this.blue = source["blue"]; + this.magenta = source["magenta"]; + this.cyan = source["cyan"]; + this.white = source["white"]; + this.bright_black = source["bright_black"]; + this.bright_red = source["bright_red"]; + this.bright_green = source["bright_green"]; + this.bright_yellow = source["bright_yellow"]; + this.bright_blue = source["bright_blue"]; + this.bright_magenta = source["bright_magenta"]; + this.bright_cyan = source["bright_cyan"]; + this.bright_white = source["bright_white"]; + this.accent = source["accent"]; + this.cursor = source["cursor"]; + this.selection_foreground = source["selection_foreground"]; + this.selection_background = source["selection_background"]; + } + } - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.background = source['background']; - this.foreground = source['foreground']; - this.black = source['black']; - this.red = source['red']; - this.green = source['green']; - this.yellow = source['yellow']; - this.blue = source['blue']; - this.magenta = source['magenta']; - this.cyan = source['cyan']; - this.white = source['white']; - this.bright_black = source['bright_black']; - this.bright_red = source['bright_red']; - this.bright_green = source['bright_green']; - this.bright_yellow = source['bright_yellow']; - this.bright_blue = source['bright_blue']; - this.bright_magenta = source['bright_magenta']; - this.bright_cyan = source['bright_cyan']; - this.bright_white = source['bright_white']; - this.accent = source['accent']; - this.cursor = source['cursor']; - this.selection_foreground = source['selection_foreground']; - this.selection_background = source['selection_background']; - } - } } export namespace theme { - export class ApplyResult { - success: boolean; - isOmarchy: boolean; - themePath: string; - - static createFrom(source: any = {}) { - return new ApplyResult(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.success = source['success']; - this.isOmarchy = source['isOmarchy']; - this.themePath = source['themePath']; - } - } - export class Settings { - includeGtk: boolean; - includeZed: boolean; - includeVscode: boolean; - includeNeovim: boolean; - selectedNeovimConfig: string; - excludedApps?: Record; - videoCpuMode: boolean; + + export class ApplyResult { + success: boolean; + isOmarchy: boolean; + themePath: string; + + static createFrom(source: any = {}) { + return new ApplyResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.success = source["success"]; + this.isOmarchy = source["isOmarchy"]; + this.themePath = source["themePath"]; + } + } + export class Settings { + includeGtk: boolean; + includeZed: boolean; + includeVscode: boolean; + includeNeovim: boolean; + selectedNeovimConfig: string; + excludedApps?: Record; + videoCpuMode: boolean; + + static createFrom(source: any = {}) { + return new Settings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.includeGtk = source["includeGtk"]; + this.includeZed = source["includeZed"]; + this.includeVscode = source["includeVscode"]; + this.includeNeovim = source["includeNeovim"]; + this.selectedNeovimConfig = source["selectedNeovimConfig"]; + this.excludedApps = source["excludedApps"]; + this.videoCpuMode = source["videoCpuMode"]; + } + } + export class StateSnapshot { + palette: string[]; + wallpaperPath: string; + lightMode: boolean; + lockedColors: Record; + colorRoles: template.ColorRoles; + extendedColors: Record; + extractionMode: string; + additionalImages: string[]; + appOverrides: Record; + + static createFrom(source: any = {}) { + return new StateSnapshot(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.palette = source["palette"]; + this.wallpaperPath = source["wallpaperPath"]; + this.lightMode = source["lightMode"]; + this.lockedColors = source["lockedColors"]; + this.colorRoles = this.convertValues(source["colorRoles"], template.ColorRoles); + this.extendedColors = source["extendedColors"]; + this.extractionMode = source["extractionMode"]; + this.additionalImages = source["additionalImages"]; + this.appOverrides = source["appOverrides"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } - static createFrom(source: any = {}) { - return new Settings(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.includeGtk = source['includeGtk']; - this.includeZed = source['includeZed']; - this.includeVscode = source['includeVscode']; - this.includeNeovim = source['includeNeovim']; - this.selectedNeovimConfig = source['selectedNeovimConfig']; - this.excludedApps = source['excludedApps']; - this.videoCpuMode = source['videoCpuMode']; - } - } - export class StateSnapshot { - palette: string[]; - wallpaperPath: string; - lightMode: boolean; - lockedColors: Record; - colorRoles: template.ColorRoles; - extendedColors: Record; - extractionMode: string; - additionalImages: string[]; - appOverrides: Record; - - static createFrom(source: any = {}) { - return new StateSnapshot(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.palette = source['palette']; - this.wallpaperPath = source['wallpaperPath']; - this.lightMode = source['lightMode']; - this.lockedColors = source['lockedColors']; - this.colorRoles = this.convertValues( - source['colorRoles'], - template.ColorRoles - ); - this.extendedColors = source['extendedColors']; - this.extractionMode = source['extractionMode']; - this.additionalImages = source['additionalImages']; - this.appOverrides = source['appOverrides']; - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => - this.convertValues(elem, classs) - ); - } else if ('object' === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } } export namespace wallhaven { - export class SearchMeta { - current_page: number; - last_page: number; - total: number; - seed?: string; - - static createFrom(source: any = {}) { - return new SearchMeta(source); - } + + export class SearchMeta { + current_page: number; + last_page: number; + total: number; + seed?: string; + + static createFrom(source: any = {}) { + return new SearchMeta(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.current_page = source["current_page"]; + this.last_page = source["last_page"]; + this.total = source["total"]; + this.seed = source["seed"]; + } + } + export class SearchParams { + q: string; + categories: string; + purity: string; + sorting: string; + order: string; + page: number; + atleast: string; + colors: string; + + static createFrom(source: any = {}) { + return new SearchParams(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.q = source["q"]; + this.categories = source["categories"]; + this.purity = source["purity"]; + this.sorting = source["sorting"]; + this.order = source["order"]; + this.page = source["page"]; + this.atleast = source["atleast"]; + this.colors = source["colors"]; + } + } + export class WallpaperInfo { + id: string; + url: string; + path: string; + resolution: string; + file_size: number; + category: string; + purity: string; + thumbs: Record; + + static createFrom(source: any = {}) { + return new WallpaperInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.url = source["url"]; + this.path = source["path"]; + this.resolution = source["resolution"]; + this.file_size = source["file_size"]; + this.category = source["category"]; + this.purity = source["purity"]; + this.thumbs = source["thumbs"]; + } + } + export class SearchResult { + data: WallpaperInfo[]; + meta: SearchMeta; + + static createFrom(source: any = {}) { + return new SearchResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.data = this.convertValues(source["data"], WallpaperInfo); + this.meta = this.convertValues(source["meta"], SearchMeta); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.current_page = source['current_page']; - this.last_page = source['last_page']; - this.total = source['total']; - this.seed = source['seed']; - } - } - export class SearchParams { - q: string; - categories: string; - purity: string; - sorting: string; - order: string; - page: number; - atleast: string; - colors: string; - - static createFrom(source: any = {}) { - return new SearchParams(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.q = source['q']; - this.categories = source['categories']; - this.purity = source['purity']; - this.sorting = source['sorting']; - this.order = source['order']; - this.page = source['page']; - this.atleast = source['atleast']; - this.colors = source['colors']; - } - } - export class WallpaperInfo { - id: string; - url: string; - path: string; - resolution: string; - file_size: number; - category: string; - purity: string; - thumbs: Record; - - static createFrom(source: any = {}) { - return new WallpaperInfo(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.id = source['id']; - this.url = source['url']; - this.path = source['path']; - this.resolution = source['resolution']; - this.file_size = source['file_size']; - this.category = source['category']; - this.purity = source['purity']; - this.thumbs = source['thumbs']; - } - } - export class SearchResult { - data: WallpaperInfo[]; - meta: SearchMeta; - - static createFrom(source: any = {}) { - return new SearchResult(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.data = this.convertValues(source['data'], WallpaperInfo); - this.meta = this.convertValues(source['meta'], SearchMeta); - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => - this.convertValues(elem, classs) - ); - } else if ('object' === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } } export namespace wallpaper { - export class WallpaperInfo { - path: string; - name: string; - size: number; - modTime: number; + + export class WallpaperInfo { + path: string; + name: string; + size: number; + modTime: number; + + static createFrom(source: any = {}) { + return new WallpaperInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.path = source["path"]; + this.name = source["name"]; + this.size = source["size"]; + this.modTime = source["modTime"]; + } + } - static createFrom(source: any = {}) { - return new WallpaperInfo(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.path = source['path']; - this.name = source['name']; - this.size = source['size']; - this.modTime = source['modTime']; - } - } } + diff --git a/internal/githubsource/provider.go b/internal/githubsource/provider.go new file mode 100644 index 0000000..63f1d15 --- /dev/null +++ b/internal/githubsource/provider.go @@ -0,0 +1,231 @@ +package githubsource + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +const githubAPIBase = "https://api.github.com" +const rawBase = "https://raw.githubusercontent.com" + +// imageExtensions are the image file extensions accepted by this provider. +var imageExtensions = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".webp": true, +} + +// parsedGitHubURL holds the components extracted from a GitHub URL. +type parsedGitHubURL struct { + Owner string + Repo string + Branch string + Path string +} + +// Client is an HTTP client for the GitHub Contents API. +type Client struct { + http *http.Client +} + +// NewClient creates a new GitHub source client. +func NewClient() *Client { + return &Client{ + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// ListImages parses a GitHub URL and returns all image files found at that +// location. Supports github.com repos, GitHub Pages (.github.io), and +// raw.githubusercontent.com URLs. +func (c *Client) ListImages(rawURL string) ([]ImageInfo, error) { + gh, err := parseURL(rawURL) + if err != nil { + return nil, err + } + + contents, err := c.listContents(gh.Owner, gh.Repo, gh.Path, gh.Branch) + if err != nil { + return nil, err + } + + images := filterImages(contents) + if len(images) == 0 { + return nil, fmt.Errorf("no images found in %s", rawURL) + } + + result := make([]ImageInfo, len(images)) + for i, item := range images { + result[i] = ImageInfo{ + Name: item.Name, + URL: buildRawURL(gh.Owner, gh.Repo, gh.Branch, item.Path), + Size: item.Size, + } + } + + return result, nil +} + +// listContents calls the GitHub Contents API for a given path in a repo. +func (c *Client) listContents(owner, repo, filePath, branch string) ([]githubContent, error) { + apiURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s", githubAPIBase, owner, repo, filePath) + if branch != "" { + apiURL += "?ref=" + url.QueryEscape(branch) + } + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", "Aether/1.0") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("github API request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusForbidden: + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github API rate limit exceeded: %s", string(body)) + case http.StatusNotFound: + return nil, fmt.Errorf("repository or path not found: %s/%s/%s", owner, repo, filePath) + case http.StatusOK: + // proceed + default: + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github API returned %d: %s", resp.StatusCode, string(body)) + } + + var raw json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + var items []githubContent + if err := json.Unmarshal(raw, &items); err == nil { + return items, nil + } + + var single githubContent + if err := json.Unmarshal(raw, &single); err != nil { + return nil, fmt.Errorf("unexpected API response format") + } + return []githubContent{single}, nil +} + +// filterImages filters a GitHub API contents response to only image files. +func filterImages(items []githubContent) []githubContent { + out := make([]githubContent, 0, len(items)) + for _, item := range items { + if item.Type != "file" { + continue + } + ext := strings.ToLower(path.Ext(item.Name)) + if imageExtensions[ext] { + out = append(out, item) + } + } + return out +} + +// buildRawURL constructs a raw.githubusercontent.com URL for a file. +func buildRawURL(owner, repo, branch, filePath string) string { + return fmt.Sprintf("%s/%s/%s/%s/%s", rawBase, owner, repo, branch, filePath) +} + +// parseURL parses a GitHub URL and extracts owner, repo, branch, and path. +// Supported formats: +// - https://github.com/{owner}/{repo}[/tree/{branch}/{path}] +// - https://github.com/{owner}/{repo}/blob/{branch}/{path} +// - https://{owner}.github.io/{path} +// - https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} +func parseURL(rawURL string) (*parsedGitHubURL, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + host := strings.ToLower(u.Host) + segments := splitPath(u.Path) + + // raw.githubusercontent.com/{owner}/{repo}/{branch}/{path...} + if host == "raw.githubusercontent.com" { + if len(segments) < 3 { + return nil, fmt.Errorf("raw URL must include owner, repo, and branch") + } + return &parsedGitHubURL{ + Owner: segments[0], + Repo: segments[1], + Branch: segments[2], + Path: strings.Join(segments[3:], "/"), + }, nil + } + + // {owner}.github.io/{path...} + if strings.HasSuffix(host, ".github.io") { + owner := strings.TrimSuffix(host, ".github.io") + if owner == "" { + return nil, fmt.Errorf("invalid GitHub Pages URL") + } + return &parsedGitHubURL{ + Owner: owner, + Repo: owner + ".github.io", + Branch: "", + Path: strings.Join(segments, "/"), + }, nil + } + + // github.com/{owner}/{repo}[/tree|blob/{branch}/{path}] + if host != "github.com" { + return nil, fmt.Errorf("not a GitHub URL: %s", rawURL) + } + + if len(segments) < 2 { + return nil, fmt.Errorf("GitHub URL must include owner and repo") + } + + owner := segments[0] + repo := strings.TrimSuffix(segments[1], ".git") + branch := "" + filePath := "" + + if len(segments) >= 4 { + switch segments[2] { + case "tree", "blob": + branch = segments[3] + if len(segments) > 4 { + filePath = strings.Join(segments[4:], "/") + } + default: + filePath = strings.Join(segments[2:], "/") + } + } + + return &parsedGitHubURL{ + Owner: owner, + Repo: repo, + Branch: branch, + Path: filePath, + }, nil +} + +// splitPath splits a URL path into non-empty segments. +func splitPath(p string) []string { + var segs []string + for _, s := range strings.Split(p, "/") { + if s != "" { + segs = append(segs, s) + } + } + return segs +} diff --git a/internal/githubsource/provider_test.go b/internal/githubsource/provider_test.go new file mode 100644 index 0000000..f25d976 --- /dev/null +++ b/internal/githubsource/provider_test.go @@ -0,0 +1,176 @@ +package githubsource + +import ( + "testing" +) + +func TestParseURL_githubCom(t *testing.T) { + tests := []struct { + raw string + owner string + repo string + branch string + path string + }{ + {"https://github.com/dharmx/walls", "dharmx", "walls", "", ""}, + {"https://github.com/dharmx/walls.git", "dharmx", "walls", "", ""}, + {"https://github.com/dharmx/walls/", "dharmx", "walls", "", ""}, + {"https://github.com/dharmx/walls/tree/main", "dharmx", "walls", "main", ""}, + {"https://github.com/dharmx/walls/tree/main/subdir", "dharmx", "walls", "main", "subdir"}, + {"https://github.com/dharmx/walls/tree/master/images/nature", "dharmx", "walls", "master", "images/nature"}, + {"https://github.com/dharmx/walls/blob/main/wallpaper.jpg", "dharmx", "walls", "main", "wallpaper.jpg"}, + {"https://github.com/bjarneo/wallpapers/tree/gh-pages", "bjarneo", "wallpapers", "gh-pages", ""}, + } + + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + gh, err := parseURL(tt.raw) + if err != nil { + t.Fatalf("parseURL(%q) unexpected error: %v", tt.raw, err) + } + if gh.Owner != tt.owner { + t.Errorf("owner = %q, want %q", gh.Owner, tt.owner) + } + if gh.Repo != tt.repo { + t.Errorf("repo = %q, want %q", gh.Repo, tt.repo) + } + if gh.Branch != tt.branch { + t.Errorf("branch = %q, want %q", gh.Branch, tt.branch) + } + if gh.Path != tt.path { + t.Errorf("path = %q, want %q", gh.Path, tt.path) + } + }) + } +} + +func TestParseURL_githubPages(t *testing.T) { + tests := []struct { + raw string + owner string + repo string + branch string + path string + }{ + {"https://bjarneo.github.io/wallpapers/", "bjarneo", "bjarneo.github.io", "", "wallpapers"}, + {"https://bjarneo.github.io/", "bjarneo", "bjarneo.github.io", "", ""}, + {"https://bjarneo.github.io/wallpapers/nature", "bjarneo", "bjarneo.github.io", "", "wallpapers/nature"}, + } + + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + gh, err := parseURL(tt.raw) + if err != nil { + t.Fatalf("parseURL(%q) unexpected error: %v", tt.raw, err) + } + if gh.Owner != tt.owner { + t.Errorf("owner = %q, want %q", gh.Owner, tt.owner) + } + if gh.Repo != tt.repo { + t.Errorf("repo = %q, want %q", gh.Repo, tt.repo) + } + if gh.Branch != tt.branch { + t.Errorf("branch = %q, want %q", gh.Branch, tt.branch) + } + if gh.Path != tt.path { + t.Errorf("path = %q, want %q", gh.Path, tt.path) + } + }) + } +} + +func TestParseURL_rawContent(t *testing.T) { + tests := []struct { + raw string + owner string + repo string + branch string + path string + }{ + {"https://raw.githubusercontent.com/bjarneo/wallpapers/main/wallpaper.jpg", "bjarneo", "wallpapers", "main", "wallpaper.jpg"}, + {"https://raw.githubusercontent.com/dharmx/walls/master/images/nature/mountain.png", "dharmx", "walls", "master", "images/nature/mountain.png"}, + } + + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + gh, err := parseURL(tt.raw) + if err != nil { + t.Fatalf("parseURL(%q) unexpected error: %v", tt.raw, err) + } + if gh.Owner != tt.owner { + t.Errorf("owner = %q, want %q", gh.Owner, tt.owner) + } + if gh.Repo != tt.repo { + t.Errorf("repo = %q, want %q", gh.Repo, tt.repo) + } + if gh.Branch != tt.branch { + t.Errorf("branch = %q, want %q", gh.Branch, tt.branch) + } + if gh.Path != tt.path { + t.Errorf("path = %q, want %q", gh.Path, tt.path) + } + }) + } +} + +func TestParseURL_errors(t *testing.T) { + invalid := []string{ + "", + "not-a-url", + "https://example.com/some/page", + "https://gitlab.com/owner/repo", + "https://raw.githubusercontent.com/onlyowner", + } + + for _, raw := range invalid { + t.Run(raw, func(t *testing.T) { + _, err := parseURL(raw) + if err == nil { + t.Errorf("parseURL(%q) expected error, got nil", raw) + } + }) + } +} + +func TestFilterImages(t *testing.T) { + items := []githubContent{ + {Name: "photo.jpg", Type: "file", Size: 1024}, + {Name: "photo.jpeg", Type: "file", Size: 2048}, + {Name: "screenshot.png", Type: "file", Size: 4096}, + {Name: "animation.webp", Type: "file", Size: 512}, + {Name: "document.pdf", Type: "file", Size: 300}, + {Name: "script.js", Type: "file", Size: 100}, + {Name: "subdir", Type: "dir", Size: 0}, + {Name: "archive.zip", Type: "file", Size: 9999}, + {Name: "image.PNG", Type: "file", Size: 2000}, // uppercase + {Name: "Photo.JPG", Type: "file", Size: 3000}, // uppercase + } + + images := filterImages(items) + if len(images) != 6 { + t.Fatalf("got %d images, want 6", len(images)) + } + + expected := map[string]bool{ + "photo.jpg": true, + "photo.jpeg": true, + "screenshot.png": true, + "animation.webp": true, + "image.PNG": true, + "Photo.JPG": true, + } + + for _, img := range images { + if !expected[img.Name] { + t.Errorf("unexpected image: %s", img.Name) + } + } +} + +func TestBuildRawURL(t *testing.T) { + url := buildRawURL("dharmx", "walls", "main", "images/nature/mountain.png") + want := "https://raw.githubusercontent.com/dharmx/walls/main/images/nature/mountain.png" + if url != want { + t.Errorf("got %q, want %q", url, want) + } +} diff --git a/internal/githubsource/types.go b/internal/githubsource/types.go new file mode 100644 index 0000000..072fad7 --- /dev/null +++ b/internal/githubsource/types.go @@ -0,0 +1,17 @@ +package githubsource + +// ImageInfo describes a single image file from a GitHub repository. +type ImageInfo struct { + Name string `json:"name"` + URL string `json:"url"` // raw.githubusercontent.com download URL + Size int64 `json:"size"` +} + +// githubContent is a single item from the GitHub Contents API response. +type githubContent struct { + Name string `json:"name"` + Type string `json:"type"` // "file" or "dir" + Path string `json:"path"` + Size int64 `json:"size"` + DownloadURL string `json:"download_url"` +} From 173733b34d860d4b1b722848cdc0bed9862d8577 Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:14:48 -0300 Subject: [PATCH 02/16] Restructure nav: unify Sources dropdown into tabs array Replace separate sourceItems array + slice rendering with a single {#each tabs as tab} loop using a discriminated NavItem type. Dropdown items (children) live inline in the nav data, keeping the order explicit and extensible for future additions. --- .../lib/components/layout/HeaderBar.svelte | 197 +++++++++--------- 1 file changed, 97 insertions(+), 100 deletions(-) diff --git a/frontend/src/lib/components/layout/HeaderBar.svelte b/frontend/src/lib/components/layout/HeaderBar.svelte index 4bf11d2..257aecd 100644 --- a/frontend/src/lib/components/layout/HeaderBar.svelte +++ b/frontend/src/lib/components/layout/HeaderBar.svelte @@ -24,12 +24,26 @@ } catch {} }); - const tabs: {id: Tab; label: string; icon: string}[] = [ + type TabItem = {id: Tab; label: string; icon: string}; + type DropdownItem = {id: 'sources'; label: string; icon: string; children: TabItem[]}; + type NavItem = TabItem | DropdownItem; + + const tabs: NavItem[] = [ { id: 'editor', label: 'Editor', icon: '', }, + { + id: 'sources', + label: 'Sources', + icon: '', + children: [ + {id: 'wallhaven', label: 'Wallhaven', icon: ''}, + {id: 'github', label: 'GitHub', icon: ''}, + {id: 'local', label: 'Local', icon: ''}, + ], + }, { id: 'favorites', label: 'Favorites', @@ -52,24 +66,6 @@ }, ]; - const sourceItems: {id: Tab; label: string; icon: string}[] = [ - { - id: 'wallhaven', - label: 'Wallhaven', - icon: '', - }, - { - id: 'github', - label: 'GitHub', - icon: '', - }, - { - id: 'local', - label: 'Local', - icon: '', - }, - ]; - let anySourceActive = $derived( activeTab === 'wallhaven' || activeTab === 'github' || activeTab === 'local' ); @@ -146,91 +142,92 @@ {/if}
Date: Tue, 23 Jun 2026 00:28:51 -0300 Subject: [PATCH 03/16] Add directory navigation to GitHub source browser - ImageInfo now carries Type (file/dir) and Path fields - ListImages returns ListContentsResult wrapper with mixed file/dir items - Uses download_url from GitHub API directly (no branch resolution needed) - parseURL handles 3+ segment URLs as owner/repo/path for dir navigation - Frontend shows folder cards with folder icon; click navigates deeper - Up-arrow button in header for parent directory - Counter shows images and directories separately - svelte-check 0 errors, go test -v PASS --- app.go | 7 +- .../components/github/GitHubBrowser.svelte | 195 ++++++++++++------ frontend/src/lib/stores/github.svelte.ts | 19 +- frontend/wailsjs/go/main/App.d.ts | 2 +- frontend/wailsjs/go/models.ts | 34 +++ internal/githubsource/provider.go | 65 +++--- internal/githubsource/provider_test.go | 30 +++ internal/githubsource/types.go | 11 +- 8 files changed, 258 insertions(+), 105 deletions(-) diff --git a/app.go b/app.go index f15bc1d..9c860dd 100644 --- a/app.go +++ b/app.go @@ -670,9 +670,10 @@ func (a *App) DownloadWallpaper(imageURL string) (string, error) { // GitHub Source // --------------------------------------------------------------------------- -// ListGitHubImages fetches all image files from a GitHub repository URL. -// Accepts github.com, .github.io, and raw.githubusercontent.com URLs. -func (a *App) ListGitHubImages(rawURL string) ([]githubsource.ImageInfo, error) { +// ListGitHubImages fetches all image files and subdirectories from a GitHub +// repository URL. Accepts github.com, .github.io, and +// raw.githubusercontent.com URLs. +func (a *App) ListGitHubImages(rawURL string) (*githubsource.ListContentsResult, error) { return a.github.ListImages(rawURL) } diff --git a/frontend/src/lib/components/github/GitHubBrowser.svelte b/frontend/src/lib/components/github/GitHubBrowser.svelte index 2dc3537..b923e4d 100644 --- a/frontend/src/lib/components/github/GitHubBrowser.svelte +++ b/frontend/src/lib/components/github/GitHubBrowser.svelte @@ -6,6 +6,7 @@ import LoadingState from '$lib/components/shared/LoadingState.svelte'; import ViewHeader from '$lib/components/shared/ViewHeader.svelte'; import ImagePreview from '$lib/components/shared/ImagePreview.svelte'; + import type {githubsource} from '../../../../wailsjs/go/models'; import { setWallpaperPath, addAdditionalImage, @@ -21,11 +22,35 @@ getError, setURL, fetchImages, + navigateToDir as storeNavigateToDir, + goUp as storeGoUp, } from '$lib/stores/github.svelte'; + type ImageInfo = githubsource.ImageInfo; + let urlInput = $state(getURL()); let previewIndex = $state(-1); + let results = $derived(getResults()); + let isLoading = $derived(getIsLoading()); + let error = $derived(getError()); + let fileResults = $derived(results.filter(i => i.type === 'file')); + let dirResults = $derived(results.filter(i => i.type === 'dir')); + + let canGoUp = $derived.by(() => { + const u = getURL(); + try { + const parsed = new URL(u); + if (parsed.hostname !== 'github.com') return false; + const segs = parsed.pathname.replace(/\/+$/, '').split('/').filter(Boolean); + if (segs.length <= 2) return false; + if (segs.length === 4 && segs[2] === 'tree') return false; + return true; + } catch { + return false; + } + }); + function handleSubmit() { setURL(urlInput); fetchImages(); @@ -35,7 +60,17 @@ if (e.key === 'Enter') handleSubmit(); } - async function handleUse(image: {name: string; url: string}) { + function handleNavigate(dirName: string) { + storeNavigateToDir(dirName); + urlInput = getURL(); + } + + function handleGoUp() { + storeGoUp(); + urlInput = getURL(); + } + + async function handleUse(image: ImageInfo) { try { const {DownloadWallpaper} = await import( '../../../../wailsjs/go/main/App' @@ -49,7 +84,7 @@ } } - async function handleAddExtra(event: MouseEvent, image: {name: string; url: string}) { + async function handleAddExtra(event: MouseEvent, image: ImageInfo) { event.stopPropagation(); try { showToast('Downloading wallpaper...'); @@ -68,13 +103,9 @@ } } - function handlePreview(index: number) { - previewIndex = index; + function handlePreview(img: ImageInfo) { + previewIndex = fileResults.findIndex(f => f.path === img.path); } - - let results = $derived(getResults()); - let isLoading = $derived(getIsLoading()); - let error = $derived(getError());
@@ -82,6 +113,12 @@ GitHub URL + {#if results.length > 0} {results.length} image{results.length === 1 ? '' : 's'}{fileResults.length} image{fileResults.length === 1 ? '' : 's'} + {dirResults.length > 0 + ? `, ${dirResults.length} director${dirResults.length === 1 ? 'y' : 'ies'}` + : ''} {/if}
@@ -108,13 +148,13 @@
{#if isLoading} - + {:else if error} {:else if results.length === 0} {#snippet icon()} - {#each results as img, i} -
- - - - +
+ {item.name} +
+
+ {:else}
-
- - +
+ +
+ + + + +
handleUse(item)} + title="Download, set as wallpaper, and open in editor" + >Use +
+ + + +
-
-
- {img.name} +
+ {item.name} +
-
+ {/if} {/each}
{/if} @@ -211,12 +272,12 @@
= 0 ? results[previewIndex]?.url : ''} - alt={previewIndex >= 0 ? results[previewIndex]?.name : ''} + src={previewIndex >= 0 ? fileResults[previewIndex]?.url : ''} + alt={previewIndex >= 0 ? fileResults[previewIndex]?.name : ''} open={previewIndex >= 0} onclose={() => (previewIndex = -1)} hasPrev={previewIndex > 0} - hasNext={previewIndex < results.length - 1} + hasNext={previewIndex < fileResults.length - 1} onprev={() => previewIndex--} onnext={() => previewIndex++} /> diff --git a/frontend/src/lib/stores/github.svelte.ts b/frontend/src/lib/stores/github.svelte.ts index c75065a..36863e0 100644 --- a/frontend/src/lib/stores/github.svelte.ts +++ b/frontend/src/lib/stores/github.svelte.ts @@ -27,6 +27,21 @@ export function setURL(u: string): void { url = u; } +export function navigateToDir(dirName: string): void { + const base = url.replace(/\/+$/, ''); + url = `${base}/${dirName}`; + fetchImages(); +} + +export function goUp(): void { + const trimmed = url.replace(/\/+$/, ''); + const lastSlash = trimmed.lastIndexOf('/'); + if (lastSlash > 0) { + url = trimmed.substring(0, lastSlash); + fetchImages(); + } +} + export async function fetchImages(): Promise { const trimmed = url.trim(); if (!trimmed) return; @@ -39,8 +54,8 @@ export async function fetchImages(): Promise { const {ListGitHubImages} = await import( '../../../wailsjs/go/main/App' ); - const data = await ListGitHubImages(trimmed); - results = Array.isArray(data) ? data : []; + const result = await ListGitHubImages(trimmed); + results = result?.items ?? []; } catch (e: any) { error = e?.message || 'Failed to fetch images from GitHub'; results = []; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index c54d549..f7e9e18 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -90,7 +90,7 @@ export function IsPreviewCached(arg1:string):Promise; export function ListBlueprints():Promise>>; -export function ListGitHubImages(arg1:string):Promise>; +export function ListGitHubImages(arg1:string):Promise; export function LoadBlueprint(arg1:string):Promise; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index c4dcb25..50a6436 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -64,6 +64,8 @@ export namespace githubsource { name: string; url: string; size: number; + type: string; + path: string; static createFrom(source: any = {}) { return new ImageInfo(source); @@ -74,7 +76,39 @@ export namespace githubsource { this.name = source["name"]; this.url = source["url"]; this.size = source["size"]; + this.type = source["type"]; + this.path = source["path"]; + } + } + export class ListContentsResult { + items: ImageInfo[]; + + static createFrom(source: any = {}) { + return new ListContentsResult(source); } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.items = this.convertValues(source["items"], ImageInfo); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } } } diff --git a/internal/githubsource/provider.go b/internal/githubsource/provider.go index 63f1d15..95b411d 100644 --- a/internal/githubsource/provider.go +++ b/internal/githubsource/provider.go @@ -42,10 +42,10 @@ func NewClient() *Client { } } -// ListImages parses a GitHub URL and returns all image files found at that -// location. Supports github.com repos, GitHub Pages (.github.io), and -// raw.githubusercontent.com URLs. -func (c *Client) ListImages(rawURL string) ([]ImageInfo, error) { +// ListImages parses a GitHub URL and returns all files (images) and directories +// found at that location. Supports github.com repos, GitHub Pages +// (.github.io), and raw.githubusercontent.com URLs. +func (c *Client) ListImages(rawURL string) (*ListContentsResult, error) { gh, err := parseURL(rawURL) if err != nil { return nil, err @@ -56,21 +56,27 @@ func (c *Client) ListImages(rawURL string) ([]ImageInfo, error) { return nil, err } - images := filterImages(contents) - if len(images) == 0 { - return nil, fmt.Errorf("no images found in %s", rawURL) - } - - result := make([]ImageInfo, len(images)) - for i, item := range images { - result[i] = ImageInfo{ - Name: item.Name, - URL: buildRawURL(gh.Owner, gh.Repo, gh.Branch, item.Path), - Size: item.Size, + items := make([]ImageInfo, 0, len(contents)) + for _, item := range contents { + if item.Type == "dir" { + items = append(items, ImageInfo{ + Name: item.Name, + Size: item.Size, + Type: "dir", + Path: item.Path, + }) + } else if isImageFile(item.Name) && item.DownloadURL != "" { + items = append(items, ImageInfo{ + Name: item.Name, + URL: item.DownloadURL, + Size: item.Size, + Type: "file", + Path: item.Path, + }) } } - return result, nil + return &ListContentsResult{Items: items}, nil } // listContents calls the GitHub Contents API for a given path in a repo. @@ -123,15 +129,17 @@ func (c *Client) listContents(owner, repo, filePath, branch string) ([]githubCon return []githubContent{single}, nil } +// isImageFile checks if a filename has an image extension. +func isImageFile(name string) bool { + ext := strings.ToLower(path.Ext(name)) + return imageExtensions[ext] +} + // filterImages filters a GitHub API contents response to only image files. func filterImages(items []githubContent) []githubContent { out := make([]githubContent, 0, len(items)) for _, item := range items { - if item.Type != "file" { - continue - } - ext := strings.ToLower(path.Ext(item.Name)) - if imageExtensions[ext] { + if item.Type == "file" && isImageFile(item.Name) { out = append(out, item) } } @@ -199,16 +207,13 @@ func parseURL(rawURL string) (*parsedGitHubURL, error) { branch := "" filePath := "" - if len(segments) >= 4 { - switch segments[2] { - case "tree", "blob": - branch = segments[3] - if len(segments) > 4 { - filePath = strings.Join(segments[4:], "/") - } - default: - filePath = strings.Join(segments[2:], "/") + if len(segments) >= 4 && (segments[2] == "tree" || segments[2] == "blob") { + branch = segments[3] + if len(segments) > 4 { + filePath = strings.Join(segments[4:], "/") } + } else if len(segments) >= 3 { + filePath = strings.Join(segments[2:], "/") } return &parsedGitHubURL{ diff --git a/internal/githubsource/provider_test.go b/internal/githubsource/provider_test.go index f25d976..2e2537f 100644 --- a/internal/githubsource/provider_test.go +++ b/internal/githubsource/provider_test.go @@ -20,6 +20,8 @@ func TestParseURL_githubCom(t *testing.T) { {"https://github.com/dharmx/walls/tree/master/images/nature", "dharmx", "walls", "master", "images/nature"}, {"https://github.com/dharmx/walls/blob/main/wallpaper.jpg", "dharmx", "walls", "main", "wallpaper.jpg"}, {"https://github.com/bjarneo/wallpapers/tree/gh-pages", "bjarneo", "wallpapers", "gh-pages", ""}, + {"https://github.com/dharmx/walls/abstract", "dharmx", "walls", "", "abstract"}, + {"https://github.com/dharmx/walls/subdir/nested", "dharmx", "walls", "", "subdir/nested"}, } for _, tt := range tests { @@ -167,6 +169,34 @@ func TestFilterImages(t *testing.T) { } } +func TestIsImageFile(t *testing.T) { + tests := []struct { + name string + image bool + }{ + {"photo.jpg", true}, + {"photo.jpeg", true}, + {"screenshot.png", true}, + {"animation.webp", true}, + {"image.PNG", true}, + {"Photo.JPG", true}, + {"noext", false}, + {"document.pdf", false}, + {"script.js", false}, + {"archive.zip", false}, + {"Makefile", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isImageFile(tt.name) + if got != tt.image { + t.Errorf("isImageFile(%q) = %v, want %v", tt.name, got, tt.image) + } + }) + } +} + func TestBuildRawURL(t *testing.T) { url := buildRawURL("dharmx", "walls", "main", "images/nature/mountain.png") want := "https://raw.githubusercontent.com/dharmx/walls/main/images/nature/mountain.png" diff --git a/internal/githubsource/types.go b/internal/githubsource/types.go index 072fad7..9d84241 100644 --- a/internal/githubsource/types.go +++ b/internal/githubsource/types.go @@ -1,10 +1,17 @@ package githubsource -// ImageInfo describes a single image file from a GitHub repository. +// ImageInfo describes a single item (file or directory) from a GitHub repository. type ImageInfo struct { Name string `json:"name"` - URL string `json:"url"` // raw.githubusercontent.com download URL + URL string `json:"url"` // raw.githubusercontent.com download URL (empty for dirs) Size int64 `json:"size"` + Type string `json:"type"` // "file" or "dir" + Path string `json:"path"` // repo-relative path +} + +// ListContentsResult is returned by ListImages, containing both files and dirs. +type ListContentsResult struct { + Items []ImageInfo `json:"items"` } // githubContent is a single item from the GitHub Contents API response. From 3397aa04ad248095f14b618e5f7ca023f97bd621 Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:27:26 -0300 Subject: [PATCH 04/16] Add GitHub source cache, favorites, and saved repos - Add in-memory TTL cache (5 min, 100 entry FIFO eviction) - Cache ListImages results to speed up repeated directory navigation - Add heart/favorite button on each GitHub image card (ToggleFavorite IPC) - Add star/bookmark saved-repos dropdown with localStorage persistence - Fix nested + +
+ + {#if savedReposOpen} +
+
+ SAVED REPOS + +
+ {#if savedRepos.length === 0} +
No saved repos yet
+ {:else} + {#each savedRepos as repo} +
loadSavedRepo(repo.url)} + onkeydown={e => { if (e.key === 'Enter') loadSavedRepo(repo.url); }} + > + + + + {repo.name} + +
+ {/each} + {/if} +
+ {/if} +
+ + +
- + - - + + +
diff --git a/frontend/src/lib/components/github/RemoteImage.svelte b/frontend/src/lib/components/github/RemoteImage.svelte index 04e81da..4ac1a98 100644 --- a/frontend/src/lib/components/github/RemoteImage.svelte +++ b/frontend/src/lib/components/github/RemoteImage.svelte @@ -25,6 +25,7 @@ {alt} class="h-full w-full object-cover" loading="lazy" + decoding="async" /> {/if}
From 6ddd40bc4f4bcb2a220cc56bc4b9daa75d9e45d9 Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:44:07 -0300 Subject: [PATCH 06/16] Add server-side thumbnail generation for GitHub images - Download full-res GitHub raw image in Go, resize to 300px PNG - Cache thumbnail on disk (~/.cache/aether/thumbnails/github/.png) - Return as data URL to frontend (single IPC call, tiny payload) - RemoteImage calls GetGitHubThumbnail instead of loading raw URL - First load downloads and caches; subsequent loads are instant --- app.go | 6 ++ .../lib/components/github/RemoteImage.svelte | 22 +++- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + internal/githubsource/provider.go | 102 ++++++++++++++++++ 5 files changed, 133 insertions(+), 3 deletions(-) diff --git a/app.go b/app.go index 9c860dd..eeafd18 100644 --- a/app.go +++ b/app.go @@ -677,6 +677,12 @@ func (a *App) ListGitHubImages(rawURL string) (*githubsource.ListContentsResult, return a.github.ListImages(rawURL) } +// GetGitHubThumbnail downloads an image from a GitHub raw URL, generates a +// thumbnail, caches it to disk, and returns a data URL for the frontend. +func (a *App) GetGitHubThumbnail(rawURL string) (string, error) { + return githubsource.DownloadThumbnail(rawURL) +} + // --------------------------------------------------------------------------- // Local Wallpapers // --------------------------------------------------------------------------- diff --git a/frontend/src/lib/components/github/RemoteImage.svelte b/frontend/src/lib/components/github/RemoteImage.svelte index 4ac1a98..5fa1bbf 100644 --- a/frontend/src/lib/components/github/RemoteImage.svelte +++ b/frontend/src/lib/components/github/RemoteImage.svelte @@ -5,13 +5,29 @@ let el = $state(null); let inView = $state(false); + let thumbSrc = $state(''); + + function loadThumb() { + if (thumbSrc) return; + (async () => { + try { + const {GetGitHubThumbnail} = await import( + '../../../../wailsjs/go/main/App' + ); + thumbSrc = await GetGitHubThumbnail(url); + } catch {} + })(); + } $effect(() => { if (!el) return; return observeIntersection( el, entry => { - if (entry.isIntersecting) inView = true; + if (entry.isIntersecting) { + inView = true; + loadThumb(); + } }, {rootMargin: '400px 0px'} ); @@ -19,9 +35,9 @@
- {#if inView} + {#if inView && thumbSrc} ; export function GetFavorites():Promise>; +export function GetGitHubThumbnail(arg1:string):Promise; + export function GetFocusTab():Promise; export function GetInitialState():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 9813d81..6b24b3a 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -82,6 +82,10 @@ export function GetFavorites() { return window['go']['main']['App']['GetFavorites'](); } +export function GetGitHubThumbnail(arg1) { + return window['go']['main']['App']['GetGitHubThumbnail'](arg1); +} + export function GetFocusTab() { return window['go']['main']['App']['GetFocusTab'](); } diff --git a/internal/githubsource/provider.go b/internal/githubsource/provider.go index e1e73bd..17203d4 100644 --- a/internal/githubsource/provider.go +++ b/internal/githubsource/provider.go @@ -1,16 +1,29 @@ package githubsource import ( + "bytes" + "crypto/md5" + "encoding/base64" "encoding/json" "fmt" + "image" + "image/png" "io" "net/http" "net/url" + "os" "path" + "path/filepath" "strings" "time" + + "golang.org/x/image/draw" + + "aether/internal/platform" ) +const thumbnailSize = 300 + const githubAPIBase = "https://api.github.com" const rawBase = "https://raw.githubusercontent.com" @@ -248,3 +261,92 @@ func splitPath(p string) []string { } return segs } + +// cachePath returns the filesystem path for a cached thumbnail of the given URL. +func thumbnailCachePath(rawURL string) string { + hash := fmt.Sprintf("%x", md5.Sum([]byte(rawURL))) + return filepath.Join(platform.ThumbnailDir(), "github", hash+".png") +} + +// DownloadThumbnail downloads an image from a URL, generates a thumbnail, +// caches it to disk, and returns it as a data URL. Subsequent calls for the +// same URL skip the download and return the cached thumbnail. +func DownloadThumbnail(rawURL string) (string, error) { + cacheFile := thumbnailCachePath(rawURL) + + // Return cached thumbnail if it exists + if data, err := os.ReadFile(cacheFile); err == nil { + encoded := base64.StdEncoding.EncodeToString(data) + return fmt.Sprintf("data:image/png;base64,%s", encoded), nil + } + + // Download the image + resp, err := http.Get(rawURL) + if err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download returned %d", resp.StatusCode) + } + + // Decode + img, _, err := image.Decode(resp.Body) + if err != nil { + return "", fmt.Errorf("decode failed: %w", err) + } + + // Scale to thumbnail + thumb := scaleImage(img, thumbnailSize) + + // Encode to PNG and write to cache + if err := os.MkdirAll(filepath.Dir(cacheFile), 0755); err != nil { + return "", fmt.Errorf("create cache dir: %w", err) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, thumb); err != nil { + return "", fmt.Errorf("encode thumbnail: %w", err) + } + + if err := os.WriteFile(cacheFile, buf.Bytes(), 0644); err != nil { + return "", fmt.Errorf("write cache file: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + return fmt.Sprintf("data:image/png;base64,%s", encoded), nil +} + +// scaleImage scales an image to fit within size×size bounding box, preserving +// aspect ratio. +func scaleImage(src image.Image, size int) image.Image { + bounds := src.Bounds() + srcW := bounds.Dx() + srcH := bounds.Dy() + + if srcW == 0 || srcH == 0 { + return src + } + + var dstW, dstH int + if srcW >= srcH { + dstW = size + dstH = size * srcH / srcW + } else { + dstH = size + dstW = size * srcW / srcH + } + + if dstW < 1 { + dstW = 1 + } + if dstH < 1 { + dstH = 1 + } + + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil) + + return dst +} From eea2d30b30d6ae03b1704933a1c05b373efe8a1a Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:47:07 -0300 Subject: [PATCH 07/16] Update Wails runtime bindings to v2.12.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-generate Wails JS/TS bindings and bump indirect dependencies (go-toast, wails v2.11.0→v2.12.0). GetGitHubThumbnail entry re-ordered alphabetically in App.d.ts/App.js. --- frontend/wailsjs/go/main/App.d.ts | 4 +- frontend/wailsjs/go/main/App.js | 8 +- frontend/wailsjs/runtime/runtime.d.ts | 122 +++++++++++++++++++------- frontend/wailsjs/runtime/runtime.js | 58 +++++++++++- go.mod | 3 +- go.sum | 6 +- 6 files changed, 160 insertions(+), 41 deletions(-) diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index b9ed154..8b422b8 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -50,10 +50,10 @@ export function GeneratePaletteFromColor(arg1:string):Promise; export function GetFavorites():Promise>; -export function GetGitHubThumbnail(arg1:string):Promise; - export function GetFocusTab():Promise; +export function GetGitHubThumbnail(arg1:string):Promise; + export function GetInitialState():Promise; export function GetMediaURL(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 6b24b3a..76dd2fd 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -82,14 +82,14 @@ export function GetFavorites() { return window['go']['main']['App']['GetFavorites'](); } -export function GetGitHubThumbnail(arg1) { - return window['go']['main']['App']['GetGitHubThumbnail'](arg1); -} - export function GetFocusTab() { return window['go']['main']['App']['GetFocusTab'](); } +export function GetGitHubThumbnail(arg1) { + return window['go']['main']['App']['GetGitHubThumbnail'](arg1); +} + export function GetInitialState() { return window['go']['main']['App']['GetInitialState'](); } diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 713e3f8..3bbea84 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -21,8 +21,8 @@ export interface Size { export interface Screen { isCurrent: boolean; isPrimary: boolean; - width: number; - height: number; + width : number + height : number } // Environment information such as platform, buildtype, ... @@ -38,32 +38,19 @@ export interface EnvironmentInfo { export function EventsEmit(eventName: string, ...data: any): void; // [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. -export function EventsOn( - eventName: string, - callback: (...data: any) => void -): () => void; +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; // [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) // sets up a listener for the given event name, but will only trigger a given number times. -export function EventsOnMultiple( - eventName: string, - callback: (...data: any) => void, - maxCallbacks: number -): () => void; +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; // [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) // sets up a listener for the given event name, but will only trigger once. -export function EventsOnce( - eventName: string, - callback: (...data: any) => void -): () => void; +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; // [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) // unregisters the listener for the given event name. -export function EventsOff( - eventName: string, - ...additionalEventNames: string[] -): void; +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; // [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) // unregisters all listeners. @@ -213,12 +200,7 @@ export function WindowIsNormal(): Promise; // [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) // Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. -export function WindowSetBackgroundColour( - R: number, - G: number, - B: number, - A: number -): void; +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; // [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) // Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. @@ -254,17 +236,95 @@ export function ClipboardSetText(text: string): Promise; // [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) // OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. -export function OnFileDrop( - callback: (x: number, y: number, paths: string[]) => void, - useDropTarget: boolean -): void; +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void // [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) // OnFileDropOff removes the drag and drop listeners and handlers. -export function OnFileDropOff(): void; +export function OnFileDropOff() :void // Check if the file path resolver is available export function CanResolveFilePaths(): boolean; // Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void; +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 7674e0d..556621e 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -49,7 +49,7 @@ export function EventsOff(eventName, ...additionalEventNames) { } export function EventsOffAll() { - return window.runtime.EventsOffAll(); + return window.runtime.EventsOffAll(); } export function EventsOnce(eventName, callback) { @@ -240,3 +240,59 @@ export function CanResolveFilePaths() { export function ResolveFilePaths(files) { return window.runtime.ResolveFilePaths(files); } + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +} \ No newline at end of file diff --git a/go.mod b/go.mod index f930750..cba1c11 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,12 @@ module aether go 1.23 require ( - github.com/wailsapp/wails/v2 v2.11.0 + github.com/wailsapp/wails/v2 v2.12.0 golang.org/x/image v0.23.0 ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect diff --git a/go.sum b/go.sum index fcaee66..b96a604 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -57,8 +59,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= -github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= From b6b81ff1b9dfe0283410a675400d92bed224cd80 Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:25:18 -0300 Subject: [PATCH 08/16] Pre-warm thumbnail cache after GitHub API fetch Kick off GetGitHubThumbnail for the first 12 images as soon as ListImages returns, so HTTP download + Go resize starts in the background. By the time the user scrolls to each card, the thumbnail is on disk and RemoteImage gets it instantly. --- frontend/src/lib/stores/github.svelte.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/stores/github.svelte.ts b/frontend/src/lib/stores/github.svelte.ts index 36863e0..eb4b512 100644 --- a/frontend/src/lib/stores/github.svelte.ts +++ b/frontend/src/lib/stores/github.svelte.ts @@ -51,11 +51,20 @@ export async function fetchImages(): Promise { results = []; try { - const {ListGitHubImages} = await import( + const {ListGitHubImages, GetGitHubThumbnail} = await import( '../../../wailsjs/go/main/App' ); const result = await ListGitHubImages(trimmed); results = result?.items ?? []; + + // Pre-warm disk cache for first 12 images so they load instantly on scroll. + // Fire-and-forget: each call starts the HTTP download + resize in a Go + // goroutine; by the time RemoteImage calls GetGitHubThumbnail, the cached + // thumbnail file exists and returns immediately. + const files = results.filter(i => i.type === 'file'); + for (const f of files.slice(0, 12)) { + GetGitHubThumbnail(f.url).catch(() => {}); + } } catch (e: any) { error = e?.message || 'Failed to fetch images from GitHub'; results = []; From 9ef9a4d06719afd19ff314970e1ce396b8f468ec Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:31:10 -0300 Subject: [PATCH 09/16] Add client-side name filter to GitHub browser Text input filters grid results by filename (case-insensitive). Clears automatically when navigating to a new directory. Count changes to '12 / 20' style to show filtered vs total. --- .../components/github/GitHubBrowser.svelte | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/github/GitHubBrowser.svelte b/frontend/src/lib/components/github/GitHubBrowser.svelte index 1c531fb..c5b4e4b 100644 --- a/frontend/src/lib/components/github/GitHubBrowser.svelte +++ b/frontend/src/lib/components/github/GitHubBrowser.svelte @@ -52,12 +52,20 @@ // Per-image favorite state: url → boolean let favState = $state>({}); + let nameFilter = $state(''); let results = $derived(getResults()); let isLoading = $derived(getIsLoading()); let error = $derived(getError()); - let fileResults = $derived(results.filter(i => i.type === 'file')); - let dirResults = $derived(results.filter(i => i.type === 'dir')); + let filteredResults = $derived( + nameFilter + ? results.filter(i => + i.name.toLowerCase().includes(nameFilter.toLowerCase()) + ) + : results + ); + let fileResults = $derived(filteredResults.filter(i => i.type === 'file')); + let dirResults = $derived(filteredResults.filter(i => i.type === 'dir')); let canGoUp = $derived.by(() => { const u = getURL(); @@ -274,13 +282,20 @@ > {isLoading ? 'Loading...' : 'Fetch'} + {#if results.length > 0} + + {/if}
{#if results.length > 0} {fileResults.length} image{fileResults.length === 1 ? '' : 's'} - {dirResults.length > 0 - ? `, ${dirResults.length} director${dirResults.length === 1 ? 'y' : 'ies'}` + >{fileResults.length} / {results.length}{dirResults.length > 0 + ? `, ${dirResults.length} dir${dirResults.length === 1 ? '' : 's'}` : ''} {/if} @@ -292,7 +307,7 @@ {:else if error} - {:else if results.length === 0} + {:else if filteredResults.length === 0 && results.length === 0} {/snippet} + {:else if filteredResults.length === 0 && results.length > 0} + (nameFilter = '')} + /> {:else}
- {#each results as item, i (item.path)} + {#each filteredResults as item, i (item.path)} {#if item.type === 'dir'}
Date: Tue, 23 Jun 2026 20:36:11 -0300 Subject: [PATCH 10/16] Show thumbnails for GitHub favorites FavoritesView now calls GetGitHubThumbnail for remote URLs that don't have a native thumbUrl (GitHub favorites). Fire-and-forget so the loop doesn't block on HTTP downloads. --- .../components/favorites/FavoritesView.svelte | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/src/lib/components/favorites/FavoritesView.svelte b/frontend/src/lib/components/favorites/FavoritesView.svelte index 9a802bb..16841d1 100644 --- a/frontend/src/lib/components/favorites/FavoritesView.svelte +++ b/frontend/src/lib/components/favorites/FavoritesView.svelte @@ -61,6 +61,16 @@ } } + async function loadRemoteThumb(path: string) { + try { + const {GetGitHubThumbnail} = await import( + '../../../../wailsjs/go/main/App' + ); + const dataUrl = await GetGitHubThumbnail(path); + if (dataUrl) setCachedImage('thumb:' + path, dataUrl); + } catch {} + } + async function loadThumbnails() { for (const fav of favorites) { if (isThumbnailCached(fav.path)) continue; @@ -71,6 +81,14 @@ continue; } + // Remote GitHub URLs — use Go thumbnail generator (download + + // resize to 300px, cached on disk for subsequent loads). Fire and + // forget so the loop doesn't block on HTTP downloads. + if (fav.path.startsWith('http://') || fav.path.startsWith('https://')) { + loadRemoteThumb(fav.path); + continue; + } + // Local files — load thumbnail loadThumbnail(fav.path); } From 12eafd2a7c37e51a91eb36fd753db14d055519af Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:53:31 -0300 Subject: [PATCH 11/16] Show original image dimensions and file size in GitHub browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ThumbnailResult struct (dataURL, width, height) to Go thumbnail pipeline — dimensions come from image.Decode before resize. Cache them in a sidecar .dims file alongside the PNG so cached thumbnails also report original dimensions. Replace filename in card info bar with '1920×1080 · 1.2 MB' style; filename visible as tooltip. --- app.go | 5 +- .../components/favorites/FavoritesView.svelte | 4 +- .../components/github/GitHubBrowser.svelte | 33 ++++++++- .../lib/components/github/RemoteImage.svelte | 20 +++++- frontend/wailsjs/go/main/App.d.ts | 2 +- frontend/wailsjs/go/models.ts | 16 +++++ internal/githubsource/provider.go | 72 +++++++++++++++---- internal/githubsource/types.go | 8 +++ 8 files changed, 134 insertions(+), 26 deletions(-) diff --git a/app.go b/app.go index eeafd18..b42c17f 100644 --- a/app.go +++ b/app.go @@ -678,8 +678,9 @@ func (a *App) ListGitHubImages(rawURL string) (*githubsource.ListContentsResult, } // GetGitHubThumbnail downloads an image from a GitHub raw URL, generates a -// thumbnail, caches it to disk, and returns a data URL for the frontend. -func (a *App) GetGitHubThumbnail(rawURL string) (string, error) { +// thumbnail, caches it to disk, and returns a ThumbnailResult with the data +// URL and original image dimensions. +func (a *App) GetGitHubThumbnail(rawURL string) (*githubsource.ThumbnailResult, error) { return githubsource.DownloadThumbnail(rawURL) } diff --git a/frontend/src/lib/components/favorites/FavoritesView.svelte b/frontend/src/lib/components/favorites/FavoritesView.svelte index 16841d1..21e920f 100644 --- a/frontend/src/lib/components/favorites/FavoritesView.svelte +++ b/frontend/src/lib/components/favorites/FavoritesView.svelte @@ -66,8 +66,8 @@ const {GetGitHubThumbnail} = await import( '../../../../wailsjs/go/main/App' ); - const dataUrl = await GetGitHubThumbnail(path); - if (dataUrl) setCachedImage('thumb:' + path, dataUrl); + const result = await GetGitHubThumbnail(path); + if (result?.dataURL) setCachedImage('thumb:' + path, result.dataURL); } catch {} } diff --git a/frontend/src/lib/components/github/GitHubBrowser.svelte b/frontend/src/lib/components/github/GitHubBrowser.svelte index c5b4e4b..8d48865 100644 --- a/frontend/src/lib/components/github/GitHubBrowser.svelte +++ b/frontend/src/lib/components/github/GitHubBrowser.svelte @@ -53,6 +53,7 @@ // Per-image favorite state: url → boolean let favState = $state>({}); let nameFilter = $state(''); + let dims = $state>({}); let results = $derived(getResults()); let isLoading = $derived(getIsLoading()); @@ -97,6 +98,7 @@ function handleSubmit() { setURL(urlInput); + dims = {}; fetchImages(); } @@ -105,15 +107,28 @@ } function handleNavigate(dirName: string) { + dims = {}; storeNavigateToDir(dirName); urlInput = getURL(); } function handleGoUp() { + dims = {}; storeGoUp(); urlInput = getURL(); } + function formatSize(bytes: number): string { + if (!bytes) return ''; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + function handleLoad(url: string, w: number, h: number) { + dims = {...dims, [url]: {width: w, height: h}}; + } + async function handleUse(image: ImageInfo) { try { const {DownloadWallpaper} = await import( @@ -188,6 +203,7 @@ } function loadSavedRepo(url: string) { + dims = {}; setURL(url); urlInput = url; savedReposOpen = false; @@ -373,7 +389,11 @@ title="Download, set as wallpaper, and open in editor" >
- + handleLoad(item.url, w, h)} + />
@@ -443,8 +463,15 @@
-
- {item.name} +
+ {#if dims[item.url]} + {dims[item.url].width}×{dims[item.url].height} + + {/if} + {formatSize(item.size)}
{/if} diff --git a/frontend/src/lib/components/github/RemoteImage.svelte b/frontend/src/lib/components/github/RemoteImage.svelte index 5fa1bbf..8a7acc5 100644 --- a/frontend/src/lib/components/github/RemoteImage.svelte +++ b/frontend/src/lib/components/github/RemoteImage.svelte @@ -1,20 +1,34 @@ + +
+
+ {#if inView && thumbSrc} + {image.name} + {:else if inView} +
+ {/if} +
+ + + + + + + + +
+ {thumbDims} + {formatSize(image.size)} +
+ + +
+ +
+ + + +
+
+
From 95908261036f722de8966718e53371fea6d10ce4 Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:58:28 -0300 Subject: [PATCH 14/16] Review fixes: WebP support, timeout, atomic writes, canGoUp, rename Download --- README.md | 3 +- app.go | 2 +- docs/github-source.md | 42 +++++++++++++++++++ .../components/github/GitHubBrowser.svelte | 21 ++++++++-- internal/extraction/pixel_sampler.go | 1 + internal/githubsource/provider.go | 11 ++++- internal/wallhaven/client.go | 8 ++-- 7 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 docs/github-source.md diff --git a/README.md b/README.md index b016e22..d76785d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ A visual theming application for [Omarchy](https://omarchy.org). Extract colors ### Wallpaper Tools - Animated wallpaper support: `.gif`, `.mp4`, and `.webm` via the built-in `aether-wp` service -- Search and download wallpapers from wallhaven.cc directly in the app +- Search and download wallpapers from [wallhaven.cc](docs/wallhaven.md) directly in the app +- Browse and use wallpapers from any public [GitHub repo](docs/github-source.md) - Full wallpaper editor with blur, exposure, sharpen, vignette, grain, and color toning - 12 one-click image presets: Cinematic, Vintage, Film, Dramatic, and more diff --git a/app.go b/app.go index b42c17f..09f9a65 100644 --- a/app.go +++ b/app.go @@ -663,7 +663,7 @@ func (a *App) SearchWallhaven(params wallhaven.SearchParams) (*wallhaven.SearchR // DownloadWallpaper downloads a wallpaper from a URL. Returns local path. func (a *App) DownloadWallpaper(imageURL string) (string, error) { - return a.wallhaven.Download(imageURL) + return a.wallhaven.DownloadImage(imageURL) } // --------------------------------------------------------------------------- diff --git a/docs/github-source.md b/docs/github-source.md new file mode 100644 index 0000000..f7029bd --- /dev/null +++ b/docs/github-source.md @@ -0,0 +1,42 @@ +# GitHub Source + +Aether can browse image repositories on **GitHub** and use wallpapers directly from them — no cloning or manual download needed. Works with any public repo containing `.jpg`, `.jpeg`, `.png`, or `.webp` images. + +## Features + +- **Browse any public GitHub repo** by URL — supports `github.com`, `*.github.io` (GitHub Pages), and `raw.githubusercontent.com` links +- **Directory navigation** — click folders to navigate deeper, arrow button to go up +- **Thumbnails** — server-side generated 300px previews, cached to disk for instant repeat views +- **Name filter** — client-side text filter to narrow down results +- **Saved repos** — bookmark repos for quick access (stored in browser localStorage) +- **Favorites** — star individual wallpapers to add to the global Favorites tab +- **Additional images** — add wallpapers to the blend set for multi-image extraction +- **Wallpaper only** — apply a wallpaper without re-extracting the palette +- **Full-size preview** — overlay preview with next/previous navigation + +## Usage + +1. Open the **Sources** dropdown in the header bar and select **GitHub** +2. Paste a GitHub URL in the input field and click **Fetch** + - Examples: `https://github.com/dharmx/walls`, `https://github.com/bjarneo/wallpapers/tree/gh-pages` +3. Browse images and directories +4. Click **Use** on any wallpaper to download it, set it as the current wallpaper, and switch to the Editor + +## Supported URL Formats + +| Format | Example | +|--------|---------| +| Repository root | `https://github.com/owner/repo` | +| With branch | `https://github.com/owner/repo/tree/main` | +| Subdirectory | `https://github.com/owner/repo/tree/main/wallpapers/nature` | +| Single file | `https://github.com/owner/repo/blob/main/image.jpg` | +| Without branch | `https://github.com/owner/repo/subdir` | +| GitHub Pages | `https://owner.github.io/wallpapers` | +| Raw URL | `https://raw.githubusercontent.com/owner/repo/branch/path` | + +## Performance + +- The **GitHub API** response is cached in memory for 5 minutes (100 entries max, LRU eviction) +- Thumbnails are generated server-side and cached to `~/.cache/aether/github/` as PNG files with a `.dims` sidecar for original dimensions +- The first 12 images in each fetch are **pre-warmed** in the thumbnail cache so they load instantly on scroll +- Images load lazily — only cards that scroll into view trigger thumbnail generation diff --git a/frontend/src/lib/components/github/GitHubBrowser.svelte b/frontend/src/lib/components/github/GitHubBrowser.svelte index 84512b1..936843a 100644 --- a/frontend/src/lib/components/github/GitHubBrowser.svelte +++ b/frontend/src/lib/components/github/GitHubBrowser.svelte @@ -62,11 +62,24 @@ const u = getURL(); try { const parsed = new URL(u); - if (parsed.hostname !== 'github.com') return false; + const host = parsed.hostname.toLowerCase(); const segs = parsed.pathname.replace(/\/+$/, '').split('/').filter(Boolean); - if (segs.length <= 2) return false; - if (segs.length === 4 && segs[2] === 'tree') return false; - return true; + + if (host === 'github.com') { + if (segs.length <= 2) return false; + if (segs.length === 4 && segs[2] === 'tree') return false; + return true; + } + + if (host.endsWith('.github.io')) { + return segs.length > 0; + } + + if (host === 'raw.githubusercontent.com') { + return segs.length > 3; + } + + return false; } catch { return false; } diff --git a/internal/extraction/pixel_sampler.go b/internal/extraction/pixel_sampler.go index 426b1d3..4dfe3b9 100644 --- a/internal/extraction/pixel_sampler.go +++ b/internal/extraction/pixel_sampler.go @@ -6,6 +6,7 @@ import ( _ "image/gif" _ "image/jpeg" _ "image/png" + _ "golang.org/x/image/webp" "math" "os" diff --git a/internal/githubsource/provider.go b/internal/githubsource/provider.go index 4b7b3aa..11f1d55 100644 --- a/internal/githubsource/provider.go +++ b/internal/githubsource/provider.go @@ -8,6 +8,7 @@ import ( "fmt" "image" "image/png" + _ "golang.org/x/image/webp" "io" "net/http" "net/url" @@ -315,7 +316,8 @@ func DownloadThumbnail(rawURL string) (*ThumbnailResult, error) { } // Download the image - resp, err := http.Get(rawURL) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(rawURL) if err != nil { return nil, fmt.Errorf("download failed: %w", err) } @@ -349,9 +351,14 @@ func DownloadThumbnail(rawURL string) (*ThumbnailResult, error) { return nil, fmt.Errorf("encode thumbnail: %w", err) } - if err := os.WriteFile(cacheFile, buf.Bytes(), 0644); err != nil { + tmpPath := cacheFile + ".tmp" + if err := os.WriteFile(tmpPath, buf.Bytes(), 0644); err != nil { return nil, fmt.Errorf("write cache file: %w", err) } + if err := os.Rename(tmpPath, cacheFile); err != nil { + os.Remove(tmpPath) + return nil, fmt.Errorf("rename cache file: %w", err) + } // Save original dimensions sidecar _ = writeCachedDims(rawURL, origW, origH) diff --git a/internal/wallhaven/client.go b/internal/wallhaven/client.go index 61379a8..379f476 100644 --- a/internal/wallhaven/client.go +++ b/internal/wallhaven/client.go @@ -210,7 +210,7 @@ func (c *Client) DownloadFromURL(wallpaperURL string) (string, error) { if err != nil { return "", err } - return c.Download(imageURL) + return c.DownloadImage(imageURL) } // DownloadThumb downloads a wallhaven thumbnail URL into the dedicated cache @@ -292,9 +292,9 @@ func (c *Client) Info(id string) (*WallpaperInfo, error) { return &result.Data, nil } -// Download downloads a wallpaper image to the local downloads directory. -// Returns the local file path. -func (c *Client) Download(imageURL string) (string, error) { +// DownloadImage downloads an image from a URL to the local downloads directory. +// Returns the local file path. Works for any HTTP/HTTPS URL, not only wallhaven. +func (c *Client) DownloadImage(imageURL string) (string, error) { filename := filepath.Base(imageURL) if filename == "" || filename == "." || filename == "/" { return "", fmt.Errorf("cannot determine filename from URL: %s", imageURL) From 5f20e1851e7a90a38be0578e01f7d69bea538d13 Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:16:54 -0300 Subject: [PATCH 15/16] Fix Sources dropdown: hover instead of click, remove overflow-x-auto and shrink-0 that broke layout --- .../src/lib/components/layout/HeaderBar.svelte | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/layout/HeaderBar.svelte b/frontend/src/lib/components/layout/HeaderBar.svelte index 257aecd..a15d267 100644 --- a/frontend/src/lib/components/layout/HeaderBar.svelte +++ b/frontend/src/lib/components/layout/HeaderBar.svelte @@ -75,10 +75,6 @@ sourcesOpen = false; } - function toggleSources() { - sourcesOpen = !sourcesOpen; - } - $effect(() => { if (!sourcesOpen || !sourcesRef) return; @@ -143,13 +139,18 @@
+ From 37fe8929124256b3ff79f09906c0a754c638a257 Mon Sep 17 00:00:00 2001 From: OsmarBogarin <174164899+OsmarBogarin@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:33:31 -0300 Subject: [PATCH 16/16] Rewrite Sources as bare + {@html tab.icon} + + {tab.label} + + + {#if sourcesOpen}
{#each tab.children as child} - +
{/each} {/if} - + {:else}