refactor(app): isolate desktop settings update and permission flows

This commit is contained in:
Mohamed Boudra
2026-02-22 11:45:37 +07:00
parent 1c83e10c6d
commit 409a3ac5df
11 changed files with 760 additions and 579 deletions
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HostProfile } from '@/contexts/daemon-registry-context'
async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') {
vi.resetModules()
vi.doMock('react-native', () => ({ Platform: { OS: platform } }))
return import('./desktop-updates')
}
function createHost(input: {
serverId: string
endpoint: string
type?: 'direct' | 'relay'
}): HostProfile {
const now = new Date(0).toISOString()
if (input.type === 'relay') {
return {
serverId: input.serverId,
label: input.serverId,
connections: [
{
id: `${input.serverId}-relay`,
type: 'relay',
relayEndpoint: input.endpoint,
daemonPublicKeyB64: 'test-key',
},
],
preferredConnectionId: `${input.serverId}-relay`,
createdAt: now,
updatedAt: now,
}
}
return {
serverId: input.serverId,
label: input.serverId,
connections: [
{
id: `${input.serverId}-direct`,
type: 'direct',
endpoint: input.endpoint,
},
],
preferredConnectionId: `${input.serverId}-direct`,
createdAt: now,
updatedAt: now,
}
}
describe('desktop-updates helpers', () => {
afterEach(() => {
vi.doUnmock('react-native')
vi.restoreAllMocks()
vi.resetModules()
})
it('normalizes versions for app-daemon comparisons', async () => {
const { normalizeVersionForComparison } = await loadModuleForPlatform('web')
expect(normalizeVersionForComparison(' v0.1.15 ')).toBe('0.1.15')
expect(normalizeVersionForComparison('0.1.15')).toBe('0.1.15')
expect(normalizeVersionForComparison(null)).toBeNull()
})
it('detects version mismatch after normalization', async () => {
const { isVersionMismatch } = await loadModuleForPlatform('web')
expect(isVersionMismatch('v0.1.15', '0.1.15')).toBe(false)
expect(isVersionMismatch('0.1.15', '0.1.16')).toBe(true)
expect(isVersionMismatch('0.1.15', null)).toBe(false)
})
it('formats display versions with v prefix and unavailable fallback', async () => {
const { formatVersionWithPrefix } = await loadModuleForPlatform('web')
expect(formatVersionWithPrefix('0.2.0')).toBe('v0.2.0')
expect(formatVersionWithPrefix('v0.2.0')).toBe('v0.2.0')
expect(formatVersionWithPrefix(null)).toBe('\u2014')
})
it('prefers localhost direct host when selecting local daemon', async () => {
const { findLikelyLocalDaemonHost } = await loadModuleForPlatform('web')
const remote = createHost({ serverId: 'remote', endpoint: '10.0.0.2:6767' })
const local = createHost({ serverId: 'local', endpoint: 'localhost:6767' })
expect(findLikelyLocalDaemonHost([remote, local])?.serverId).toBe('local')
})
it('falls back to first host when no localhost endpoint exists', async () => {
const { findLikelyLocalDaemonHost } = await loadModuleForPlatform('web')
const relay = createHost({ serverId: 'relay', endpoint: 'relay.paseo.sh:443', type: 'relay' })
const remote = createHost({ serverId: 'remote', endpoint: '10.0.0.2:6767' })
expect(findLikelyLocalDaemonHost([relay, remote])?.serverId).toBe('relay')
})
it('builds copyable daemon update diagnostics', async () => {
const { buildDaemonUpdateDiagnostics } = await loadModuleForPlatform('web')
const diagnostics = buildDaemonUpdateDiagnostics({
exitCode: 1,
stdout: 'stdout text',
stderr: 'stderr text',
})
expect(diagnostics).toContain('Exit code: 1')
expect(diagnostics).toContain('STDOUT:\nstdout text')
expect(diagnostics).toContain('STDERR:\nstderr text')
})
})
@@ -0,0 +1,166 @@
import { Platform } from 'react-native'
import type { HostProfile } from '@/contexts/daemon-registry-context'
import { getTauri } from '@/utils/tauri'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean
currentVersion: string | null
latestVersion: string | null
body: string | null
date: string | null
}
export interface DesktopAppUpdateInstallResult {
installed: boolean
version: string | null
message: string
}
export interface LocalDaemonUpdateResult {
exitCode: number
stdout: string
stderr: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function toStringOrNull(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function toStringOrEmpty(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function toNumberOr(defaultValue: number, value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue
}
function isLikelyLocalDirectEndpoint(endpoint: string): boolean {
const normalized = endpoint.trim().toLowerCase()
return (
normalized === 'localhost' ||
normalized.startsWith('localhost:') ||
normalized === '127.0.0.1' ||
normalized.startsWith('127.0.0.1:') ||
normalized === '::1' ||
normalized.startsWith('::1:') ||
normalized === '[::1]' ||
normalized.startsWith('[::1]:')
)
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
}
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>('check_app_update')
if (!isRecord(result)) {
throw new Error('Unexpected response while checking desktop updates.')
}
return {
hasUpdate: result.hasUpdate === true,
currentVersion: toStringOrNull(result.currentVersion),
latestVersion: toStringOrNull(result.latestVersion),
body: toStringOrNull(result.body),
date: toStringOrNull(result.date),
}
}
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>('install_app_update')
if (!isRecord(result)) {
throw new Error('Unexpected response while installing desktop update.')
}
return {
installed: result.installed === true,
version: toStringOrNull(result.version),
message: toStringOrNull(result.message) ?? 'Update completed.',
}
}
export async function runLocalDaemonUpdate(): Promise<LocalDaemonUpdateResult> {
const result = await invokeDesktopCommand<unknown>('run_local_daemon_update')
if (!isRecord(result)) {
throw new Error('Unexpected response while updating local daemon.')
}
return {
exitCode: toNumberOr(1, result.exitCode),
stdout: toStringOrEmpty(result.stdout),
stderr: toStringOrEmpty(result.stderr),
}
}
export function normalizeVersionForComparison(version: string | null | undefined): string | null {
const value = version?.trim()
if (!value) {
return null
}
return value.replace(/^v/i, '')
}
export function isVersionMismatch(
appVersion: string | null | undefined,
daemonVersion: string | null | undefined
): boolean {
const app = normalizeVersionForComparison(appVersion)
const daemon = normalizeVersionForComparison(daemonVersion)
if (!app || !daemon) {
return false
}
return app !== daemon
}
export function formatVersionWithPrefix(version: string | null | undefined): string {
const value = version?.trim()
if (!value) {
return '\u2014'
}
return value.startsWith('v') ? value : `v${value}`
}
export function findLikelyLocalDaemonHost(daemons: HostProfile[]): HostProfile | null {
if (daemons.length === 0) {
return null
}
const localhostDaemon = daemons.find((daemon) =>
daemon.connections.some(
(connection) =>
connection.type === 'direct' && isLikelyLocalDirectEndpoint(connection.endpoint)
)
)
return localhostDaemon ?? daemons[0] ?? null
}
export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string {
const stdout = result.stdout.length > 0 ? result.stdout : '(empty)'
const stderr = result.stderr.length > 0 ? result.stderr : '(empty)'
return [
`Exit code: ${result.exitCode}`,
'',
'STDOUT:',
stdout,
'',
'STDERR:',
stderr,
].join('\n')
}
@@ -0,0 +1,182 @@
import { useCallback, useRef, useState } from 'react'
import {
checkDesktopAppUpdate,
installDesktopAppUpdate,
shouldShowDesktopUpdateSection,
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult,
} from '@/desktop/updates/desktop-updates'
export type DesktopAppUpdateStatus =
| 'idle'
| 'checking'
| 'up-to-date'
| 'available'
| 'installing'
| 'installed'
| 'error'
export interface UseDesktopAppUpdaterReturn {
isDesktop: boolean
status: DesktopAppUpdateStatus
statusText: string
availableUpdate: DesktopAppUpdateCheckResult | null
errorMessage: string | null
lastCheckedAt: number | null
isChecking: boolean
isInstalling: boolean
checkForUpdates: (options?: { silent?: boolean }) => Promise<DesktopAppUpdateCheckResult | null>
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === 'string') {
return error.message
}
return String(error)
}
function formatStatusText(input: {
status: DesktopAppUpdateStatus
availableUpdate: DesktopAppUpdateCheckResult | null
installMessage: string | null
}): string {
const { status, availableUpdate, installMessage } = input
if (status === 'checking') {
return 'Checking for app updates...'
}
if (status === 'installing') {
return 'Installing app update...'
}
if (status === 'up-to-date') {
return 'App is up to date.'
}
if (status === 'available') {
if (availableUpdate?.latestVersion) {
return `Update available: ${availableUpdate.latestVersion}`
}
return 'An app update is available.'
}
if (status === 'installed') {
return installMessage ?? 'App update installed. Restart required.'
}
if (status === 'error') {
return 'Failed to update app.'
}
return 'Update status has not been checked yet.'
}
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktop = shouldShowDesktopUpdateSection()
const requestVersionRef = useRef(0)
const [status, setStatus] = useState<DesktopAppUpdateStatus>('idle')
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [installMessage, setInstallMessage] = useState<string | null>(null)
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null)
const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => {
if (!isDesktop) {
return null
}
const requestVersion = requestVersionRef.current + 1
requestVersionRef.current = requestVersion
if (!options.silent) {
setStatus('checking')
}
setErrorMessage(null)
try {
const result = await checkDesktopAppUpdate()
if (requestVersion !== requestVersionRef.current) {
return result
}
setInstallMessage(null)
setLastCheckedAt(Date.now())
if (result.hasUpdate) {
setAvailableUpdate(result)
setStatus('available')
} else {
setAvailableUpdate(null)
setStatus('up-to-date')
}
return result
} catch (error) {
if (requestVersion !== requestVersionRef.current) {
return null
}
const message = getErrorMessage(error)
if (options.silent) {
console.warn('[DesktopUpdater] Silent update check failed', message)
} else {
setStatus('error')
setErrorMessage(message)
}
return null
}
},
[isDesktop]
)
const installUpdate = useCallback(async () => {
if (!isDesktop) {
return null
}
setStatus('installing')
setErrorMessage(null)
try {
const result = await installDesktopAppUpdate()
setLastCheckedAt(Date.now())
if (result.installed) {
setAvailableUpdate(null)
setInstallMessage(result.message)
setStatus('installed')
} else {
setAvailableUpdate(null)
setInstallMessage(result.message)
setStatus('up-to-date')
}
return result
} catch (error) {
const message = getErrorMessage(error)
setStatus('error')
setErrorMessage(message)
return null
}
}, [isDesktop])
return {
isDesktop,
status,
statusText: formatStatusText({
status,
availableUpdate,
installMessage,
}),
availableUpdate,
errorMessage,
lastCheckedAt,
isChecking: status === 'checking',
isInstalling: status === 'installing',
checkForUpdates,
installUpdate,
}
}