mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(updater,settings): move feature-owned updater + theme-update hooks into their features
This commit is contained in:
@@ -5,8 +5,8 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useInstalledThemesStore } from '@/store/installedThemesStore';
|
||||
import { uninstallTheme } from '@/utils/themes/uninstallTheme';
|
||||
import { installThemeFromRegistry } from '@/utils/themes/installThemeFromRegistry';
|
||||
import { useThemeUpdates } from '@/hooks/useThemeUpdates';
|
||||
import { useThemeAnimationRisk } from '@/hooks/useThemeAnimationRisk';
|
||||
import { useThemeUpdates } from '@/features/settings/hooks/useThemeUpdates';
|
||||
import { useThemeAnimationRisk } from '@/features/settings/hooks/useThemeAnimationRisk';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { AnimatedThemeBadge } from '@/features/settings/components/AnimatedThemeBadge';
|
||||
import { FIXED_THEMES } from '@/utils/themes/fixedThemes';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import CoverLightbox from '@/ui/CoverLightbox';
|
||||
import { useThemeAnimationRisk } from '@/hooks/useThemeAnimationRisk';
|
||||
import { useThemeAnimationRisk } from '@/features/settings/hooks/useThemeAnimationRisk';
|
||||
import { AnimatedThemeBadge } from '@/features/settings/components/AnimatedThemeBadge';
|
||||
import CustomSelect from '@/ui/CustomSelect';
|
||||
import { formatRelativeTime } from '@/lib/format/relativeTime';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Paintbrush, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useThemeUpdates, themeUpdateSignature } from '@/hooks/useThemeUpdates';
|
||||
import { useThemeUpdates, themeUpdateSignature } from '@/features/settings/hooks/useThemeUpdates';
|
||||
|
||||
interface Props {
|
||||
collapsed?: boolean;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { IS_LINUX } from '@/lib/util/platform';
|
||||
|
||||
/**
|
||||
* Whether animated themes may raise CPU load on this setup — Linux with the
|
||||
* Nvidia WebKit quirk active (recorded at startup) or compositing forced off.
|
||||
* The store / theme cards show a warning on animated themes when this is true.
|
||||
*
|
||||
* Fetched once and cached for the process (read-once, per the Tauri-boundary
|
||||
* rule). Always false off Linux, so the `invoke` is skipped there.
|
||||
*/
|
||||
let cached: boolean | null = null;
|
||||
|
||||
export function useThemeAnimationRisk(): boolean {
|
||||
const [risk, setRisk] = useState(cached ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached !== null) {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRisk(cached);
|
||||
return;
|
||||
}
|
||||
if (!IS_LINUX) {
|
||||
cached = false;
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const p = invoke<boolean>('theme_animation_risk');
|
||||
// Guard the mocked-in-tests case where invoke isn't a real promise.
|
||||
if (p && typeof (p as Promise<boolean>).then === 'function') {
|
||||
(p as Promise<boolean>)
|
||||
.then((v) => {
|
||||
cached = !!v;
|
||||
if (alive) setRisk(cached);
|
||||
})
|
||||
.catch(() => {
|
||||
cached = false;
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return risk;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/utils/themes/themeRegistry', () => ({
|
||||
fetchRegistry: vi.fn(),
|
||||
getCachedRegistry: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { fetchRegistry, type Registry } from '@/utils/themes/themeRegistry';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '@/store/installedThemesStore';
|
||||
import { useThemeUpdates, themeUpdateSignature } from '@/features/settings/hooks/useThemeUpdates';
|
||||
|
||||
const fetchRegistryMock = vi.mocked(fetchRegistry);
|
||||
|
||||
function inst(id: string, version: string): InstalledTheme {
|
||||
return { id, name: id, author: 'x', version, description: '', mode: 'dark', css: '', installedAt: 0 };
|
||||
}
|
||||
|
||||
function registry(themes: { id: string; version: string }[]): Registry {
|
||||
return { themes: themes.map(t => ({ id: t.id, name: t.id, version: t.version })) } as unknown as Registry;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useInstalledThemesStore.setState({ themes: [] });
|
||||
fetchRegistryMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useThemeUpdates', () => {
|
||||
it('lists only installed themes that have a newer registry version', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0'), inst('b', '2.0.0'), inst('c', '1.5.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({
|
||||
registry: registry([
|
||||
{ id: 'a', version: '1.1.0' }, // newer → update
|
||||
{ id: 'b', version: '2.0.0' }, // same → no
|
||||
{ id: 'c', version: '1.4.0' }, // older → no
|
||||
]),
|
||||
stale: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(result.current).toHaveLength(1));
|
||||
expect(result.current[0].id).toBe('a');
|
||||
expect(result.current[0].version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('returns nothing when nothing is outdated', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'a', version: '1.0.0' }]), stale: false });
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores registry themes the user has not installed', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'z', version: '9.0.0' }]), stale: false });
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('themeUpdateSignature', () => {
|
||||
it('is order-independent and encodes id@version', () => {
|
||||
const a = themeUpdateSignature([{ id: 'b', version: '2.0.0' }, { id: 'a', version: '1.1.0' }]);
|
||||
const b = themeUpdateSignature([{ id: 'a', version: '1.1.0' }, { id: 'b', version: '2.0.0' }]);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('a@1.1.0,b@2.0.0');
|
||||
});
|
||||
|
||||
it('changes when a version bumps so a dismissed notice can reappear', () => {
|
||||
expect(themeUpdateSignature([{ id: 'a', version: '1.1.0' }]))
|
||||
.not.toBe(themeUpdateSignature([{ id: 'a', version: '1.2.0' }]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
|
||||
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '@/utils/themes/themeRegistry';
|
||||
import { useInstalledThemesStore } from '@/store/installedThemesStore';
|
||||
|
||||
// Refresh the registry from source once per app launch (not just from the
|
||||
// cache). This surfaces newly published themes and updates without the user
|
||||
// having to hit the manual refresh in the Theme Store, and it feeds the
|
||||
// sidebar update notice. Subsequent reads this session use the cache.
|
||||
let sessionRefreshStarted = false;
|
||||
|
||||
/**
|
||||
* Registry entries for installed community themes that have a newer version
|
||||
* available. Returns the full registry theme (css path, version, metadata) so a
|
||||
* caller can update in place. Seeds from the last-cached registry synchronously,
|
||||
* then revalidates (forced on the first call this session). Recomputes when the
|
||||
* installed set changes, so the list shrinks as the user updates themes.
|
||||
*/
|
||||
export function useThemeUpdates(): RegistryTheme[] {
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const [registry, setRegistry] = useState<Registry | null>(() => getCachedRegistry());
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const opts = sessionRefreshStarted ? undefined : { force: true };
|
||||
sessionRefreshStarted = true;
|
||||
fetchRegistry(opts)
|
||||
.then(r => { if (alive) setRegistry(r.registry); })
|
||||
.catch(() => { /* offline: keep whatever the cache gave us */ });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!registry) return [];
|
||||
const installedVersionById = new Map(installed.map(t => [t.id, t.version]));
|
||||
return registry.themes.filter(rt => {
|
||||
const current = installedVersionById.get(rt.id);
|
||||
return current != null && isNewer(rt.version, current);
|
||||
});
|
||||
}, [registry, installed]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of an update set, used to remember a dismissal: the sidebar
|
||||
* notice stays hidden until a new or bumped update changes this string.
|
||||
*/
|
||||
export function themeUpdateSignature(updates: Array<{ id: string; version: string }>): string {
|
||||
return updates.map(u => `${u.id}@${u.version}`).sort().join(',');
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, Refresh
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '@/../package.json';
|
||||
import { formatBytes } from '@/lib/format/formatBytes';
|
||||
import { useAppUpdater } from '@/hooks/useAppUpdater';
|
||||
import { useAppUpdater } from '@/features/updater/hooks/useAppUpdater';
|
||||
import Modal from '@/components/Modal';
|
||||
import Changelog from '@/features/updater/components/Changelog';
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { dirname } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../../../package.json';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '@/lib/util/platform';
|
||||
import { SKIP_KEY, isNewer, isWithinModerationWindow, pickAsset, type ReleaseData, type DlState } from '@/utils/componentHelpers/appUpdaterHelpers';
|
||||
|
||||
/** All update-modal state, the GitHub release probe and the download/relaunch
|
||||
* handlers. The component owns only the early-return guard and the JSX. */
|
||||
export function useAppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [release, setRelease] = useState<ReleaseData | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||
const [isArch, setIsArch] = useState(false);
|
||||
const [dlState, setDlState] = useState<DlState>('idle');
|
||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||
const [dlPath, setDlPath] = useState('');
|
||||
const [dlError, setDlError] = useState('');
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
const countdownRef = useRef<number | null>(null);
|
||||
const relaunchFnRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const fetchRelease = async (preview = false) => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
const version = tag.replace(/^[^0-9]*/, '');
|
||||
if (!version) return;
|
||||
if (!preview) {
|
||||
if (!isNewer(version, currentVersion)) return;
|
||||
const skipped = localStorage.getItem(SKIP_KEY);
|
||||
if (skipped === version) return;
|
||||
// Windows updates go through WinGet moderation; hold the notice until
|
||||
// the release clears the moderation window so users aren't pointed at a
|
||||
// version WinGet hasn't published yet. macOS/Linux use other channels.
|
||||
if (IS_WINDOWS && isWithinModerationWindow(data.published_at, Date.now())) return;
|
||||
}
|
||||
setDismissed(false);
|
||||
setDlState('idle');
|
||||
setRelease({
|
||||
version,
|
||||
tag,
|
||||
body: (data.body ?? '').trim(),
|
||||
assets: data.assets ?? [],
|
||||
});
|
||||
if (IS_LINUX) {
|
||||
const arch = await invoke<boolean>('check_arch_linux');
|
||||
setIsArch(arch);
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000);
|
||||
|
||||
const handler = () => fetchRelease(true);
|
||||
window.addEventListener('psysonic:preview-update', handler);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('psysonic:preview-update', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clean up download listener when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unlistenRef.current?.();
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSkip = () => {
|
||||
if (!release) return;
|
||||
localStorage.setItem(SKIP_KEY, release.version);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const startRestartCountdown = (seconds: number) => {
|
||||
let remaining = seconds;
|
||||
setCountdown(remaining);
|
||||
countdownRef.current = window.setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
setCountdown(null);
|
||||
relaunchFnRef.current?.();
|
||||
} else {
|
||||
setCountdown(remaining);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleRestartNow = async () => {
|
||||
if (countdownRef.current) {
|
||||
window.clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
setCountdown(null);
|
||||
await relaunchFnRef.current?.();
|
||||
};
|
||||
|
||||
const asset = pickAsset(release?.assets ?? []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
|
||||
// the minisign signature against the bundled pubkey, replaces the .app, and
|
||||
// relaunches. No manual "open the DMG" step needed.
|
||||
if (IS_MACOS) {
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: 0 });
|
||||
setDlError('');
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
relaunchFnRef.current = relaunch;
|
||||
const update = await check();
|
||||
if (!update) {
|
||||
setDlError(t('common.updaterErrorMsg'));
|
||||
setDlState('error');
|
||||
return;
|
||||
}
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
await update.downloadAndInstall(event => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? 0;
|
||||
setDlProgress({ bytes: 0, total });
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
setDlProgress({ bytes: downloaded, total });
|
||||
} else if (event.event === 'Finished') {
|
||||
setDlState('done');
|
||||
// downloadAndInstall replaces the .app in place but does not exit
|
||||
// the running process. Give the user a 3s countdown (with a manual
|
||||
// "Restart now" button) before auto-relaunch.
|
||||
startRestartCountdown(3);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!asset) return;
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: asset.size });
|
||||
setDlError('');
|
||||
|
||||
const unlisten = await listen<{ bytes: number; total: number | null }>(
|
||||
'update:download:progress',
|
||||
e => {
|
||||
setDlProgress({
|
||||
bytes: e.payload.bytes,
|
||||
total: e.payload.total ?? asset.size,
|
||||
});
|
||||
}
|
||||
);
|
||||
unlistenRef.current = unlisten;
|
||||
|
||||
try {
|
||||
const finalPath = await invoke<string>('download_update', {
|
||||
url: asset.browser_download_url,
|
||||
filename: asset.name,
|
||||
});
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlPath(finalPath);
|
||||
setDlState('done');
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowFolder = async () => {
|
||||
// tauri-plugin-shell's open() only allows https:// per capability scope —
|
||||
// local paths are blocked and fail silently. Delegate to Rust instead.
|
||||
const dir = await dirname(dlPath);
|
||||
await invoke('open_folder', { path: dir });
|
||||
};
|
||||
|
||||
const showAurHint = IS_LINUX && isArch;
|
||||
// Windows can also update through WinGet once a release clears moderation
|
||||
// (the notice itself is held back for that window, see #1200). Shown next to
|
||||
// the installer download, not instead of it — not every Windows user
|
||||
// installed via WinGet.
|
||||
const showWingetHint = IS_WINDOWS;
|
||||
// On macOS the Tauri Updater handles architecture, signature verification
|
||||
// and in-place install — we don't need (and should not show) a DMG asset.
|
||||
const useTauriUpdater = IS_MACOS;
|
||||
const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset);
|
||||
|
||||
const pct = dlProgress.total > 0
|
||||
? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
release, dismissed, setDismissed, changelogOpen, setChangelogOpen,
|
||||
dlState, dlProgress, dlError, countdown,
|
||||
asset, showAurHint, showWingetHint, useTauriUpdater, showInstallBtn, pct,
|
||||
handleSkip, handleRestartNow, handleDownload, handleShowFolder,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user