diff --git a/app.go b/app.go index 6301c96..9ddc975 100644 --- a/app.go +++ b/app.go @@ -4,10 +4,12 @@ import ( "bufio" "bytes" "context" + "encoding/json" "errors" "fmt" "io" "net" + "net/http" "os" "path" "path/filepath" @@ -1543,18 +1545,19 @@ func (a *App) SetDeviceTimezone(timezone string, deviceType string) { } type PackageInfo struct { - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - UpstreamAuthor string `json:"upstreamAuthor"` - Categories []string `json:"categories"` - URL string `json:"url"` - License string `json:"license"` - Devices []string `json:"devices"` - Depends []string `json:"depends"` - Conflicts []string `json:"conflicts"` - OSMin *string `json:"osMin"` - OSMax *string `json:"osMax"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + UpstreamAuthor string `json:"upstreamAuthor"` + Categories []string `json:"categories"` + URL string `json:"url"` + License string `json:"license"` + Devices []string `json:"devices"` + Depends []string `json:"depends"` + Conflicts []string `json:"conflicts"` + OSMin *string `json:"osMin"` + OSMax *string `json:"osMax"` + OSConstraints []vellum.OSConstraint `json:"osConstraints"` } var hiddenPackages = map[string]bool{ @@ -1606,6 +1609,7 @@ func (a *App) GetPackages(deviceType, firmware, arch string) []PackageInfo { Conflicts: pkg.Conflicts, OSMin: pkg.OSMin, OSMax: pkg.OSMax, + OSConstraints: pkg.OSConstraints, }) } @@ -2583,6 +2587,103 @@ func (a *App) GetAppVersion() string { return version } +type UpdateCheckResult struct { + UpdateAvailable bool `json:"updateAvailable"` + LatestVersion string `json:"latestVersion"` + CurrentVersion string `json:"currentVersion"` + ReleaseURL string `json:"releaseURL"` + Error string `json:"error,omitempty"` +} + +func (a *App) CheckForAppUpdate() UpdateCheckResult { + result := UpdateCheckResult{ + CurrentVersion: version, + } + + if version == "dev" { + return result + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get("https://api.github.com/repos/rmitchellscott/remanager/releases/latest") + if err != nil { + debug.Printf("[DEBUG] CheckForAppUpdate: request failed: %v\n", err) + result.Error = "network_error" + return result + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + debug.Printf("[DEBUG] CheckForAppUpdate: HTTP %d\n", resp.StatusCode) + result.Error = "api_error" + return result + } + + var release struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + result.Error = "read_error" + return result + } + + if err := json.Unmarshal(body, &release); err != nil { + result.Error = "parse_error" + return result + } + + result.LatestVersion = release.TagName + result.ReleaseURL = release.HTMLURL + result.UpdateAvailable = isNewerVersion(version, release.TagName) + + debug.Printf("[DEBUG] CheckForAppUpdate: current=%s, latest=%s, updateAvailable=%v\n", + version, release.TagName, result.UpdateAvailable) + + return result +} + +func isNewerVersion(current, latest string) bool { + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") + + currentParts := strings.Split(current, ".") + latestParts := strings.Split(latest, ".") + + for len(currentParts) < 3 { + currentParts = append(currentParts, "0") + } + for len(latestParts) < 3 { + latestParts = append(latestParts, "0") + } + + for i := 0; i < 3; i++ { + currNum, err1 := strconv.Atoi(currentParts[i]) + latestNum, err2 := strconv.Atoi(latestParts[i]) + + if err1 != nil || err2 != nil { + if latestParts[i] > currentParts[i] { + return true + } + if latestParts[i] < currentParts[i] { + return false + } + continue + } + + if latestNum > currNum { + return true + } + if latestNum < currNum { + return false + } + } + + return false +} + type SettingsInfo struct { TabVisibility map[string]bool `json:"tabVisibility"` ProxyMode bool `json:"proxyMode"` @@ -2591,6 +2692,7 @@ type SettingsInfo struct { Theme string `json:"theme"` TerminalTheme string `json:"terminalTheme"` EditorTheme string `json:"editorTheme"` + CheckForUpdates bool `json:"checkForUpdates"` } func (a *App) GetSettings() SettingsInfo { @@ -2604,6 +2706,7 @@ func (a *App) GetSettings() SettingsInfo { Theme: "system", TerminalTheme: "match", EditorTheme: "match", + CheckForUpdates: true, } } settings, err := a.settingsStore.Load() @@ -2617,6 +2720,7 @@ func (a *App) GetSettings() SettingsInfo { Theme: "system", TerminalTheme: "match", EditorTheme: "match", + CheckForUpdates: true, } } debug.Printf("[DEBUG] GetSettings: loaded PreventSleep=%v\n", settings.PreventSleep) @@ -2628,10 +2732,11 @@ func (a *App) GetSettings() SettingsInfo { Theme: settings.Theme, TerminalTheme: settings.TerminalTheme, EditorTheme: settings.EditorTheme, + CheckForUpdates: settings.CheckForUpdates, } } -func (a *App) SaveSettings(tabVisibility map[string]bool, proxyMode bool, suppressSystemFileWarnings bool, preventSleep bool, theme string, terminalTheme string, editorTheme string) error { +func (a *App) SaveSettings(tabVisibility map[string]bool, proxyMode bool, suppressSystemFileWarnings bool, preventSleep bool, theme string, terminalTheme string, editorTheme string, checkForUpdates bool) error { debug.Printf("[DEBUG] SaveSettings: preventSleep=%v, isConnected=%v\n", preventSleep, a.IsConnected()) if a.settingsStore == nil { return fmt.Errorf("settings store not initialized") @@ -2644,6 +2749,7 @@ func (a *App) SaveSettings(tabVisibility map[string]bool, proxyMode bool, suppre Theme: theme, TerminalTheme: terminalTheme, EditorTheme: editorTheme, + CheckForUpdates: checkForUpdates, } if preventSleep && a.IsConnected() { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 462c754..a9caedb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -45,6 +45,7 @@ interface PackageInfo { conflicts: string[] osMin: string | null osMax: string | null + osConstraints: { version: string; operator: '>=' | '<' | '>' | '<=' | '=' }[] | null } interface MaintenanceCommandInfo { @@ -196,8 +197,9 @@ declare global { RespondToDialog(confirmed: boolean): Promise CancelInstallation(): Promise GetAppVersion(): Promise - GetSettings(): Promise<{ tabVisibility: Record; proxyMode: boolean; suppressSystemFileWarnings: boolean; preventSleep: boolean; theme: string; terminalTheme: string; editorTheme: string }> - SaveSettings(tabVisibility: Record, proxyMode: boolean, suppressSystemFileWarnings: boolean, preventSleep: boolean, theme: string, terminalTheme: string, editorTheme: string): Promise + CheckForAppUpdate(): Promise<{ updateAvailable: boolean; latestVersion: string; currentVersion: string; releaseURL: string; error?: string }> + GetSettings(): Promise<{ tabVisibility: Record; proxyMode: boolean; suppressSystemFileWarnings: boolean; preventSleep: boolean; theme: string; terminalTheme: string; editorTheme: string; checkForUpdates: boolean }> + SaveSettings(tabVisibility: Record, proxyMode: boolean, suppressSystemFileWarnings: boolean, preventSleep: boolean, theme: string, terminalTheme: string, editorTheme: string, checkForUpdates: boolean): Promise GetSystemColorScheme(): Promise UninstallVellum(removeAllPackages: boolean): Promise CleanupBrokenVellum(): Promise @@ -317,6 +319,7 @@ export default function App() { const [theme, setTheme] = useState('system') const [terminalTheme, setTerminalTheme] = useState('match') const [editorTheme, setEditorTheme] = useState('match') + const [checkForUpdates, setCheckForUpdates] = useState(true) const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches) const [dnsErrorShown, setDnsErrorShown] = useState(false) const [showDnsErrorModal, setShowDnsErrorModal] = useState(false) @@ -540,6 +543,25 @@ export default function App() { localStorage.setItem('theme', loadedTheme) setTerminalTheme(settings?.terminalTheme || 'match') setEditorTheme(settings?.editorTheme || 'match') + const shouldCheckUpdates = settings?.checkForUpdates ?? true + setCheckForUpdates(shouldCheckUpdates) + + if (shouldCheckUpdates) { + window.go.main.App.CheckForAppUpdate().then((result) => { + if (result.updateAvailable && result.latestVersion) { + toast.info(`Update available: ${result.latestVersion}`, { + description: `You're running ${result.currentVersion}`, + action: { + label: 'View Release', + onClick: () => window.runtime.BrowserOpenURL(result.releaseURL) + }, + duration: 6000, + }) + } + }).catch((err) => { + debugLog('[DEBUG] Update check failed:', err) + }) + } } catch (err) { debugLog('Could not load initial data:', err) setSelectedKey('__other__') @@ -1410,7 +1432,8 @@ export default function App() { setShowFilesystemRestoreError(false) } - const handleSaveSettings = async (newTabVisibility: Record, newProxyMode: boolean, newSuppressSystemFileWarnings: boolean, newPreventSleep: boolean, newTheme: string, newTerminalTheme: string, newEditorTheme: string) => { + const handleSaveSettings = async (newTabVisibility: Record, newProxyMode: boolean, newSuppressSystemFileWarnings: boolean, newPreventSleep: boolean, newTheme: string, newTerminalTheme: string, newEditorTheme: string, newCheckForUpdates: boolean) => { + const wasCheckForUpdatesOff = !checkForUpdates setTabVisibility(newTabVisibility) setProxyMode(newProxyMode) setSuppressSystemFileWarnings(newSuppressSystemFileWarnings) @@ -1420,12 +1443,28 @@ export default function App() { await applyThemeWithPortal(newTheme) setTerminalTheme(newTerminalTheme) setEditorTheme(newEditorTheme) - await window.go.main.App.SaveSettings(newTabVisibility, newProxyMode, newSuppressSystemFileWarnings, newPreventSleep, newTheme, newTerminalTheme, newEditorTheme) + setCheckForUpdates(newCheckForUpdates) + await window.go.main.App.SaveSettings(newTabVisibility, newProxyMode, newSuppressSystemFileWarnings, newPreventSleep, newTheme, newTerminalTheme, newEditorTheme, newCheckForUpdates) + + if (wasCheckForUpdatesOff && newCheckForUpdates) { + window.go.main.App.CheckForAppUpdate().then((result) => { + if (result.updateAvailable && result.latestVersion) { + toast.info(`Update available: ${result.latestVersion}`, { + description: `You're running ${result.currentVersion}`, + action: { + label: 'View Release', + onClick: () => window.runtime.BrowserOpenURL(result.releaseURL) + }, + duration: 6000, + }) + } + }).catch(() => {}) + } } const handleEnableProxyModeFromModal = async () => { setProxyMode(true) - await window.go.main.App.SaveSettings(tabVisibility, true, suppressSystemFileWarnings, preventSleep, theme, terminalTheme, editorTheme) + await window.go.main.App.SaveSettings(tabVisibility, true, suppressSystemFileWarnings, preventSleep, theme, terminalTheme, editorTheme, checkForUpdates) setShowDnsErrorModal(false) } @@ -3282,6 +3321,7 @@ export default function App() { theme={theme} terminalTheme={terminalTheme} editorTheme={editorTheme} + checkForUpdates={checkForUpdates} onSaveSettings={handleSaveSettings} onUninstallVellum={handleUninstallVellum} uninstalling={vellumUninstalling} diff --git a/frontend/src/components/PackageDetailPanel.tsx b/frontend/src/components/PackageDetailPanel.tsx index d17546b..c859b54 100644 --- a/frontend/src/components/PackageDetailPanel.tsx +++ b/frontend/src/components/PackageDetailPanel.tsx @@ -3,6 +3,11 @@ import { Button } from '@/components/ui/button' import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet' import { ExternalLink, Plus, Trash2, Check, AlertTriangle } from 'lucide-react' +interface OSConstraint { + version: string + operator: '>=' | '<' | '>' | '<=' | '=' +} + interface PackageInfo { name: string version: string @@ -16,6 +21,7 @@ interface PackageInfo { conflicts: string[] osMin: string | null osMax: string | null + osConstraints: OSConstraint[] | null } interface PackageDetailPanelProps { @@ -55,12 +61,36 @@ export function PackageDetailPanel({ isOsCompatible = true, }: PackageDetailPanelProps) { const formatOsRange = () => { + if (pkg.osConstraints && pkg.osConstraints.length > 0) { + const minC = pkg.osConstraints.find(c => c.operator === '>=') + const maxC = pkg.osConstraints.find(c => c.operator === '<') + const exactC = pkg.osConstraints.find(c => c.operator === '=') + + if (exactC) return exactC.version + + if (minC && maxC) { + const maxInclusive = (parseFloat(maxC.version) - 0.01).toFixed(2) + return minC.version === maxInclusive ? minC.version : `${minC.version} – ${maxInclusive}` + } + + if (minC) return `${minC.version}+` + if (maxC) { + const maxInclusive = (parseFloat(maxC.version) - 0.01).toFixed(2) + return `≤ ${maxInclusive}` + } + } + if (!pkg.osMin && !pkg.osMax) return null if (pkg.osMin && pkg.osMax) { - return pkg.osMin === pkg.osMax ? pkg.osMin : `${pkg.osMin} – ${pkg.osMax}` + const maxInclusive = (parseFloat(pkg.osMax) - 0.01).toFixed(2) + return pkg.osMin === maxInclusive ? pkg.osMin : `${pkg.osMin} – ${maxInclusive}` } if (pkg.osMin) return `${pkg.osMin}+` - return `≤ ${pkg.osMax}` + if (pkg.osMax) { + const maxInclusive = (parseFloat(pkg.osMax) - 0.01).toFixed(2) + return `≤ ${maxInclusive}` + } + return null } const osRange = formatOsRange() diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 41e4efc..b910ea6 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -21,7 +21,8 @@ interface SettingsDialogProps { theme: string terminalTheme: string editorTheme: string - onSaveSettings: (tabVisibility: Record, proxyMode: boolean, suppressSystemFileWarnings: boolean, preventSleep: boolean, theme: string, terminalTheme: string, editorTheme: string) => void + checkForUpdates: boolean + onSaveSettings: (tabVisibility: Record, proxyMode: boolean, suppressSystemFileWarnings: boolean, preventSleep: boolean, theme: string, terminalTheme: string, editorTheme: string, checkForUpdates: boolean) => void onUninstallVellum: (removeAllPackages: boolean) => void uninstalling: boolean uninstallOutput: string @@ -41,6 +42,7 @@ export function SettingsDialog({ theme, terminalTheme, editorTheme, + checkForUpdates, onSaveSettings, onUninstallVellum, uninstalling, @@ -57,6 +59,7 @@ export function SettingsDialog({ const [localTheme, setLocalTheme] = useState(theme) const [localTerminalTheme, setLocalTerminalTheme] = useState(terminalTheme) const [localEditorTheme, setLocalEditorTheme] = useState(editorTheme) + const [localCheckForUpdates, setLocalCheckForUpdates] = useState(checkForUpdates) const [showUninstallConfirm, setShowUninstallConfirm] = useState(false) const [removePackages, setRemovePackages] = useState(true) @@ -67,6 +70,7 @@ export function SettingsDialog({ localTheme !== theme || localTerminalTheme !== terminalTheme || localEditorTheme !== editorTheme || + localCheckForUpdates !== checkForUpdates || JSON.stringify(localTabVisibility) !== JSON.stringify({ ...defaultTabVisibility, ...tabVisibility }) useEffect(() => { @@ -78,8 +82,9 @@ export function SettingsDialog({ setLocalTheme(theme) setLocalTerminalTheme(terminalTheme) setLocalEditorTheme(editorTheme) + setLocalCheckForUpdates(checkForUpdates) } - }, [open, tabVisibility, proxyMode, suppressSystemFileWarnings, preventSleep, theme, terminalTheme, editorTheme]) + }, [open, tabVisibility, proxyMode, suppressSystemFileWarnings, preventSleep, theme, terminalTheme, editorTheme, checkForUpdates]) const handleCancel = () => { setLocalTabVisibility({ ...defaultTabVisibility, ...tabVisibility }) @@ -89,12 +94,13 @@ export function SettingsDialog({ setLocalTheme(theme) setLocalTerminalTheme(terminalTheme) setLocalEditorTheme(editorTheme) + setLocalCheckForUpdates(checkForUpdates) setShowUninstallConfirm(false) onOpenChange(false) } const handleSave = () => { - onSaveSettings(localTabVisibility, localProxyMode, localSuppressSystemFileWarnings, localPreventSleep, localTheme, localTerminalTheme, localEditorTheme) + onSaveSettings(localTabVisibility, localProxyMode, localSuppressSystemFileWarnings, localPreventSleep, localTheme, localTerminalTheme, localEditorTheme, localCheckForUpdates) onOpenChange(false) } @@ -108,6 +114,7 @@ export function SettingsDialog({ setLocalTheme(theme) setLocalTerminalTheme(terminalTheme) setLocalEditorTheme(editorTheme) + setLocalCheckForUpdates(checkForUpdates) } onOpenChange(newOpen) } @@ -222,7 +229,19 @@ export function SettingsDialog({ id="prevent-sleep" checked={localPreventSleep} onCheckedChange={setLocalPreventSleep} - disabled={!isConnected} + /> + +
+
+ +

+ Notify when a new version of reManager is available. +

+
+
@@ -230,7 +249,7 @@ export function SettingsDialog({

Skip confirmation dialogs when modifying system partition files. - Only disable if you are experienced with Linux systems. + Only suppress if you are experienced with Linux systems.

0 { + for _, c := range info.OSConstraints { + cmp := compareVersions(firmware, c.Version) + switch c.Operator { + case ">=": + if cmp < 0 { + return false + } + case ">": + if cmp <= 0 { + return false + } + case "<=": + if cmp > 0 { + return false + } + case "<": + if cmp >= 0 { + return false + } + case "=": + if cmp != 0 { + return false + } + } } - } - if info.OSMax != nil && *info.OSMax != "" { - if compareVersions(firmware, *info.OSMax) > 0 { - return false + } else { + // Legacy fallback: os_max is exclusive + if info.OSMin != nil && *info.OSMin != "" { + if compareVersions(firmware, *info.OSMin) < 0 { + return false + } + } + if info.OSMax != nil && *info.OSMax != "" { + if compareVersions(firmware, *info.OSMax) >= 0 { + return false + } } } }