diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6d92891..32e6573b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -611,6 +611,108 @@ fn resolve_hot_cache_root( } } +/// Returns true if the current Linux system is Arch-based +/// (checks /etc/arch-release and /etc/os-release). +#[tauri::command] +fn check_arch_linux() -> bool { + #[cfg(target_os = "linux")] + { + if std::path::Path::new("/etc/arch-release").exists() { + return true; + } + if let Ok(content) = std::fs::read_to_string("/etc/os-release") { + for line in content.lines() { + let lower = line.to_lowercase(); + if lower.starts_with("id=arch") { return true; } + if lower.starts_with("id_like=") && lower.contains("arch") { return true; } + } + } + false + } + #[cfg(not(target_os = "linux"))] + { false } +} + +/// Progress payload emitted during an update binary download. +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdateDownloadProgress { + bytes: u64, + total: Option, +} + +/// Downloads an update installer/package to the user's Downloads folder. +/// Emits `update:download:progress` events with `{ bytes, total }` every 250 ms. +/// Returns the final absolute file path on success. +#[tauri::command] +async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result { + use futures_util::StreamExt; + use std::time::{Duration, Instant}; + use tokio::io::AsyncWriteExt; + + const EMIT_INTERVAL: Duration = Duration::from_millis(250); + + let dest_dir = app.path().download_dir().map_err(|e| e.to_string())?; + let dest_path = dest_dir.join(&filename); + let part_path = dest_dir.join(format!("{}.part", filename)); + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(3600)) + .build() + .map_err(|e| e.to_string())?; + + let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + let total = response.content_length(); + + let result: Result = async { + let mut file = tokio::fs::File::create(&part_path) + .await + .map_err(|e| e.to_string())?; + + let mut bytes_done: u64 = 0; + let mut stream = response.bytes_stream(); + let mut last_emit = Instant::now(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + file.write_all(&chunk).await.map_err(|e| e.to_string())?; + bytes_done += chunk.len() as u64; + + if last_emit.elapsed() >= EMIT_INTERVAL { + let _ = app.emit("update:download:progress", UpdateDownloadProgress { + bytes: bytes_done, + total, + }); + last_emit = Instant::now(); + } + } + file.flush().await.map_err(|e| e.to_string())?; + Ok(bytes_done) + }.await; + + match result { + Err(e) => { + let _ = tokio::fs::remove_file(&part_path).await; + Err(e) + } + Ok(bytes_done) => { + let _ = app.emit("update:download:progress", UpdateDownloadProgress { + bytes: bytes_done, + total: Some(bytes_done), + }); + tokio::fs::rename(&part_path, &dest_path) + .await + .map_err(|e| e.to_string())?; + Ok(dest_path.to_string_lossy().into_owned()) + } + } +} + /// Progress payload emitted to the frontend during a ZIP download. /// `total` is `None` when the server doesn't send a `Content-Length` header /// (Navidrome on-the-fly ZIPs). @@ -1206,6 +1308,8 @@ pub fn run() { toggle_tray_icon, check_dir_accessible, download_zip, + check_arch_linux, + download_update, ]) .run(tauri::generate_context!()) .expect("error while running Psysonic"); diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx index 2d882dfe..9aef9001 100644 --- a/src/components/AppUpdater.tsx +++ b/src/components/AppUpdater.tsx @@ -1,9 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { createPortal } from 'react-dom'; import { open } from '@tauri-apps/plugin-shell'; -import { RefreshCw, X } from 'lucide-react'; +import { listen } from '@tauri-apps/api/event'; +import { dirname } from '@tauri-apps/api/path'; +import { invoke } from '@tauri-apps/api/core'; +import { ArrowUpCircle, ChevronDown, Download, FolderOpen, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { version as currentVersion } from '../../package.json'; +import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; + +const SKIP_KEY = 'psysonic_skipped_update_version'; // Semver comparison: returns true if `a` is newer than `b` function isNewer(a: string, b: string): boolean { @@ -16,57 +22,330 @@ function isNewer(a: string, b: string): boolean { return false; } +function fmtBytes(n: number): string { + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +// Minimal inline-markdown renderer (bold, italic, code) +// IMPORTANT: regex must have NO nested capture groups — split() includes captured +// groups in the result, and nested groups produce undefined entries that crash on .startsWith() +function renderInline(text: string): React.ReactNode[] { + const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); + return parts.map((part, i) => { + if (!part) return null; + if (part.startsWith('**') && part.endsWith('**')) + return {part.slice(2, -2)}; + if (part.startsWith('*') && part.endsWith('*')) + return {part.slice(1, -1)}; + if (part.startsWith('`') && part.endsWith('`')) + return {part.slice(1, -1)}; + return part; + }); +} + +function renderChangelog(body: string) { + return body.split('\n').map((line, i) => { + if (line.startsWith('### ')) + return
{renderInline(line.slice(4))}
; + if (line.startsWith('#### ')) + return
{renderInline(line.slice(5))}
; + if (line.startsWith('## ')) + return null; // skip nested release headers in body + if (line.startsWith('- ')) + return
{renderInline(line.slice(2))}
; + if (line.trim() === '') return null; + return
{renderInline(line)}
; + }); +} + +interface GithubAsset { + name: string; + browser_download_url: string; + size: number; +} + +interface ReleaseData { + version: string; + tag: string; + body: string; + assets: GithubAsset[]; +} + +function pickAsset(assets: GithubAsset[]): GithubAsset | undefined { + if (IS_WINDOWS) { + return assets.find(a => a.name.endsWith('-setup.exe')) + ?? assets.find(a => a.name.endsWith('.exe')); + } + if (IS_MACOS) { + // Prefer Apple Silicon, fall back to Intel + return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64')) + ?? assets.find(a => a.name.endsWith('.dmg')); + } + if (IS_LINUX) { + // AppImage > deb > rpm + return assets.find(a => a.name.endsWith('.AppImage')) + ?? assets.find(a => a.name.endsWith('.deb')) + ?? assets.find(a => a.name.endsWith('.rpm')); + } + return undefined; +} + +type DlState = 'idle' | 'downloading' | 'done' | 'error'; + export default function AppUpdater() { const { t } = useTranslation(); - const [newVersion, setNewVersion] = useState(null); + const [release, setRelease] = useState(null); const [dismissed, setDismissed] = useState(false); + const [changelogOpen, setChangelogOpen] = useState(false); + const [isArch, setIsArch] = useState(false); + const [dlState, setDlState] = useState('idle'); + const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 }); + const [dlPath, setDlPath] = useState(''); + const [dlError, setDlError] = useState(''); + const unlistenRef = useRef<(() => 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; + } + setDismissed(false); + setDlState('idle'); + setRelease({ + version, + tag, + body: (data.body ?? '').trim(), + assets: data.assets ?? [], + }); + if (IS_LINUX) { + const arch = await invoke('check_arch_linux'); + setIsArch(arch); + } + } catch { + // No network or rate-limited — stay idle + } + }; useEffect(() => { let cancelled = false; - const timer = setTimeout(async () => { - if (cancelled) return; - try { - const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest'); - if (!res.ok || cancelled) return; - const data = await res.json(); - const tag: string = data.tag_name ?? ''; - if (!cancelled && tag && isNewer(tag, currentVersion)) { - setNewVersion(tag.replace(/^[^0-9]*/, '')); - } - } catch { - // No network or rate-limited — stay idle - } - }, 4000); - return () => { cancelled = true; clearTimeout(timer); }; + 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); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (!newVersion || dismissed) return null; + // Clean up download listener when component unmounts + useEffect(() => { + return () => { unlistenRef.current?.(); }; + }, []); + + if (!release || dismissed) return null; + + const asset = pickAsset(release.assets); + const showAurHint = IS_LINUX && isArch; + + const handleSkip = () => { + localStorage.setItem(SKIP_KEY, release.version); + setDismissed(true); + }; + + const handleDownload = async () => { + 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('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 () => { + try { + const dir = await dirname(dlPath); + await open(dir); + } catch { + // fallback: try opening the file path directly + await open(dlPath).catch(() => {}); + } + }; + + const pct = dlProgress.total > 0 + ? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100)) + : 0; return createPortal( -
-
- - {t('common.updaterAvailable')} - + <> +
setDismissed(true)} style={{ zIndex: 3000 }} /> +
e.stopPropagation()} + > + {/* Header */} +
+ +
+ {t('common.updaterModalTitle')} + + v{currentVersion} → v{release.version} + +
+ +
+ + {/* Scrollable body: changelog + download area — single overflow container */} +
+ {/* Collapsible Changelog */} + {release.body && ( +
+ + {changelogOpen && ( +
+ {renderChangelog(release.body)} +
+ )} +
+ )} + + {/* Download / AUR area */} +
+ {showAurHint ? ( +
+
{t('common.updaterAurHint')}
+ yay -S psysonic-bin + sudo pacman -Syu psysonic-bin +
+ ) : asset ? ( + <> + {dlState === 'idle' && ( +
+ {asset.name} + {fmtBytes(asset.size)} +
+ )} + {dlState === 'downloading' && ( +
+
+
+
+ {pct}% + + {fmtBytes(dlProgress.bytes)} + {dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`} + +
+ )} + {dlState === 'done' && ( +
+
{t('common.updaterDone')}
+
{t('common.updaterInstallHint')}
+ +
+ )} + {dlState === 'error' && ( +
{dlError || t('common.updaterErrorMsg')}
+ )} + + ) : ( +
+ +
+ )} +
+
{/* end update-modal-body */} + + {/* Footer buttons */} +
+ +
+ + {!showAurHint && asset && dlState === 'idle' && ( + + )} + {dlState === 'error' && ( + + )} +
-
{t('common.updaterVersion', { version: newVersion })}
-
- - -
-
, + , document.body ); } diff --git a/src/locales/de.ts b/src/locales/de.ts index a9e0b1dc..c510ded2 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -350,6 +350,18 @@ export const deTranslation = { updaterAvailable: 'Update verfügbar', updaterVersion: 'v{{version}} verfügbar', updaterWebsite: 'Website', + updaterModalTitle: 'Neue Version verfügbar', + updaterChangelog: 'Was ist neu', + updaterDownloadBtn: 'Jetzt herunterladen', + updaterSkipBtn: 'Version überspringen', + updaterRemindBtn: 'Später erinnern', + updaterDone: 'Download abgeschlossen', + updaterShowFolder: 'Im Ordner anzeigen', + updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.', + updaterAurHint: 'Update über AUR installieren:', + updaterErrorMsg: 'Download fehlgeschlagen', + updaterRetryBtn: 'Erneut versuchen', + updaterOpenGitHub: 'Auf GitHub öffnen', }, settings: { title: 'Einstellungen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 0194dbb0..2b4f2592 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -351,6 +351,18 @@ export const enTranslation = { updaterAvailable: 'Update available', updaterVersion: 'v{{version}} is available', updaterWebsite: 'Website', + updaterModalTitle: 'New Version Available', + updaterChangelog: "What's New", + updaterDownloadBtn: 'Download Now', + updaterSkipBtn: 'Skip this Version', + updaterRemindBtn: 'Remind me Later', + updaterDone: 'Download complete', + updaterShowFolder: 'Show in Folder', + updaterInstallHint: 'Close Psysonic and run the installer manually.', + updaterAurHint: 'Install the update via AUR:', + updaterErrorMsg: 'Download failed', + updaterRetryBtn: 'Retry', + updaterOpenGitHub: 'Open on GitHub', }, settings: { title: 'Settings', @@ -479,6 +491,7 @@ export const enTranslation = { aboutLicense: 'License', aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.', aboutRepo: 'Source Code on GitHub', + aboutPreviewUpdate: 'Preview Update Modal', aboutVersion: 'Version', aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio', aboutAiCredit: 'Developed with the support of Claude Code by Anthropic', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 7ba18126..4e760c65 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -350,6 +350,18 @@ export const frTranslation = { updaterAvailable: 'Mise à jour disponible', updaterVersion: 'v{{version}} est disponible', updaterWebsite: 'Site Web', + updaterModalTitle: 'Nouvelle version disponible', + updaterChangelog: 'Nouveautés', + updaterDownloadBtn: 'Télécharger maintenant', + updaterSkipBtn: 'Ignorer cette version', + updaterRemindBtn: 'Me rappeler plus tard', + updaterDone: 'Téléchargement terminé', + updaterShowFolder: 'Afficher dans le dossier', + updaterInstallHint: 'Fermez Psysonic et lancez le programme d\'installation manuellement.', + updaterAurHint: 'Installer la mise à jour via AUR :', + updaterErrorMsg: 'Échec du téléchargement', + updaterRetryBtn: 'Réessayer', + updaterOpenGitHub: 'Ouvrir sur GitHub', }, settings: { title: 'Paramètres', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 413b3bbd..22755919 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -350,6 +350,18 @@ export const nbTranslation = { updaterAvailable: 'Ny versjon er tilgjengelig', updaterVersion: 'v{{version}} er tilgjengelig', updaterWebsite: 'Nettsted', + updaterModalTitle: 'Ny versjon tilgjengelig', + updaterChangelog: 'Hva er nytt', + updaterDownloadBtn: 'Last ned nå', + updaterSkipBtn: 'Hopp over denne versjonen', + updaterRemindBtn: 'Påminn meg senere', + updaterDone: 'Nedlasting fullført', + updaterShowFolder: 'Vis i mappe', + updaterInstallHint: 'Lukk Psysonic og kjør installasjonsprogrammet manuelt.', + updaterAurHint: 'Installer oppdateringen via AUR:', + updaterErrorMsg: 'Nedlasting mislyktes', + updaterRetryBtn: 'Prøv igjen', + updaterOpenGitHub: 'Åpne på GitHub', }, settings: { title: 'Innstillinger', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 4799ba16..5225d698 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -350,6 +350,18 @@ export const nlTranslation = { updaterAvailable: 'Update beschikbaar', updaterVersion: 'v{{version}} beschikbaar', updaterWebsite: 'Website', + updaterModalTitle: 'Nieuwe versie beschikbaar', + updaterChangelog: 'Wat is er nieuw', + updaterDownloadBtn: 'Nu downloaden', + updaterSkipBtn: 'Deze versie overslaan', + updaterRemindBtn: 'Later herinneren', + updaterDone: 'Download voltooid', + updaterShowFolder: 'Tonen in map', + updaterInstallHint: 'Sluit Psysonic en voer het installatieprogramma handmatig uit.', + updaterAurHint: 'Update installeren via AUR:', + updaterErrorMsg: 'Downloaden mislukt', + updaterRetryBtn: 'Opnieuw proberen', + updaterOpenGitHub: 'Openen op GitHub', }, settings: { title: 'Instellingen', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 555d5369..c5b343de 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -364,6 +364,18 @@ export const ruTranslation = { updaterAvailable: 'Доступно обновление', updaterVersion: 'Версия {{version}} доступна', updaterWebsite: 'Сайт', + updaterModalTitle: 'Доступна новая версия', + updaterChangelog: 'Что нового', + updaterDownloadBtn: 'Скачать сейчас', + updaterSkipBtn: 'Пропустить эту версию', + updaterRemindBtn: 'Напомнить позже', + updaterDone: 'Загрузка завершена', + updaterShowFolder: 'Показать в папке', + updaterInstallHint: 'Закройте Psysonic и запустите установщик вручную.', + updaterAurHint: 'Установить обновление через AUR:', + updaterErrorMsg: 'Ошибка загрузки', + updaterRetryBtn: 'Повторить', + updaterOpenGitHub: 'Открыть на GitHub', }, settings: { title: 'Настройки', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 287a41aa..ddd2d037 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -346,6 +346,18 @@ export const zhTranslation = { updaterAvailable: '有可用更新', updaterVersion: 'v{{version}} 已发布', updaterWebsite: '官网', + updaterModalTitle: '有新版本可用', + updaterChangelog: '更新内容', + updaterDownloadBtn: '立即下载', + updaterSkipBtn: '跳过此版本', + updaterRemindBtn: '稍后提醒', + updaterDone: '下载完成', + updaterShowFolder: '在文件夹中显示', + updaterInstallHint: '请关闭 Psysonic 并手动运行安装程序。', + updaterAurHint: '通过 AUR 安装更新:', + updaterErrorMsg: '下载失败', + updaterRetryBtn: '重试', + updaterOpenGitHub: '在 GitHub 上打开', }, settings: { title: '设置', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 834f00f5..749d9307 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -96,6 +96,7 @@ const CONTRIBUTORS = [ 'Hot playback cache — queue prefetch (PR #123)', 'Per-server music folder filter and sidebar library picker (PR #124, PR #125)', 'Richer star ratings, skip threshold, and library filtering (PR #130)', + 'Statistics: scope album and song totals to selected music library (PR #138)', ], }, { @@ -1861,14 +1862,23 @@ export default function Settings() {
- +
+ + +
diff --git a/src/styles/layout.css b/src/styles/layout.css index 82e1245d..d1241b58 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -154,6 +154,10 @@ .resizer-queue { right: calc(var(--queue-width) - 3px); } +/* In native fullscreen the resizer sits flush against the screen edge and + would interfere with OS edge-swipe gestures — hide it entirely. */ +.app-shell[data-fullscreen] .resizer { display: none; } + /* ─── Sidebar ─── */ .sidebar { grid-area: sidebar; @@ -651,6 +655,157 @@ font-style: italic; } +/* ── Update Modal ────────────────────────────────────────────────────────── */ +.update-modal { + width: min(520px, 94vw); + gap: 0; + padding: 0; + max-height: 85vh; + display: flex; + flex-direction: column; +} +.update-modal-header { + padding: 14px 16px 14px 18px; +} +.update-modal-versions { + font-size: 12px; + color: var(--text-muted); + display: block; + margin-top: 2px; +} +/* Scrollable middle section (changelog + download area combined) */ +.update-modal-body { + flex: 1; + overflow-y: auto; + min-height: 0; +} +/* Changelog accordion */ +.update-modal-changelog { + border-top: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--border-subtle); +} +.update-modal-changelog-toggle { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 9px 18px; + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-align: left; + letter-spacing: 0.03em; + transition: color var(--transition-fast), background var(--transition-fast); +} +.update-modal-changelog-toggle:hover { + color: var(--text-primary); + background: var(--bg-glass); +} +.update-modal-changelog-body { + padding: 4px 18px 12px; +} +/* Download area */ +.update-modal-download-area { + padding: 14px 18px; +} +.update-modal-asset { + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--bg-glass); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 8px 12px; +} +.update-modal-asset-name { + font-size: 12px; + color: var(--text-secondary); + flex: 1; + word-break: break-all; +} +.update-modal-asset-size { + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} +.update-modal-progress { + display: flex; + align-items: center; + gap: var(--space-2); +} +.update-modal-progress .app-updater-progress-bar { flex: 1; height: 4px; } +.update-modal-dl-bytes { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} +.update-modal-done { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.update-modal-done-title { + font-size: 13px; + font-weight: 600; + color: var(--accent); +} +.update-modal-done-hint { + font-size: 12px; + color: var(--text-secondary); +} +.update-modal-folder-btn { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: 12px; +} +/* AUR hint */ +.update-modal-aur { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.update-modal-aur-title { + font-size: 13px; + color: var(--text-secondary); +} +.update-modal-aur-cmd { + display: block; + background: var(--bg-glass); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 12px; + color: var(--accent); + font-family: monospace; + user-select: all; +} +.update-modal-aur-alt { + color: var(--text-muted); +} +.update-modal-asset-none { + padding: 4px 0; +} +/* Footer */ +.update-modal-footer { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 12px 18px; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; +} +.update-modal-skip { + font-size: 12px; + color: var(--text-muted); + padding: var(--space-1) var(--space-2); +} +.update-modal-skip:hover { color: var(--text-secondary); } + .update-toast { margin: 0 var(--space-1) var(--space-2); padding: var(--space-3) var(--space-3); diff --git a/src/utils/platform.ts b/src/utils/platform.ts index 91e1c5e2..0dc1fff8 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -1,2 +1,6 @@ /** True when running on Linux (WebKitGTK). Used to show the custom title bar. */ export const IS_LINUX = navigator.platform.toLowerCase().includes('linux'); +/** True when running on macOS (WKWebView). */ +export const IS_MACOS = navigator.platform.toLowerCase().includes('mac'); +/** True when running on Windows (WebView2). */ +export const IS_WINDOWS = navigator.platform.toLowerCase().includes('win');