From dd6bdbf297833cef7d58bbffe81c15d74733f5ac Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 31 Jul 2026 13:23:54 +0800 Subject: [PATCH] fix(update): persist update_channel via settings.json instead of hasGit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After b464fdc3 (preserve .git on release switch), the hasGit-based channel detection is unreliable — .git now exists for both release and nightly installs, so page refresh always reset the channel. - Add _resolveChannelFromSettings() with migration heuristic: !hasGit → release (ZIP), detached HEAD → release (on tag), on branch → nightly. Uses gitInfo.branch from check-updates. - Persist resolved channel to settings.json on first load (one-time migration) and on explicit switchChannel. - Add update_channel validation (release|nightly) in backend update_settings handler. - Remove hasGit-based guessing from initialize(); defer to checkForUpdates where full gitInfo is available. - Channel resolution runs before checkForUpdates early-returns to avoid null channelMode on reload-within-interval. Tests: 361 passed. --- py/routes/handlers/misc_handlers.py | 5 ++ static/js/managers/UpdateService.js | 63 ++++++++++++++++--- tests/frontend/managers/updateService.test.js | 22 ++++++- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 5a259e1f..842d48b3 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -1562,6 +1562,11 @@ class SettingsHandler: {"success": False, "error": validation_error} ) + if key == "update_channel" and value not in ("release", "nightly"): + return web.json_response( + {"success": False, "error": "update_channel must be 'release' or 'nightly'"} + ) + if value == "__DELETE__" and key in ( "proxy_username", "proxy_password", diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index 7c277f7b..3f5fdd0b 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -1,11 +1,12 @@ import { modalManager } from './ModalManager.js'; -import { - getStorageItem, - setStorageItem, - getStoredVersionInfo, +import { + getStorageItem, + setStorageItem, + getStoredVersionInfo, setStoredVersionInfo, isVersionMatch } from '../utils/storageHelpers.js'; +import { state } from '../state/index.js'; import { bannerService } from './BannerService.js'; import { translate } from '../utils/i18nHelpers.js'; @@ -59,9 +60,6 @@ export class UpdateService { // Perform update check if needed this.checkVersionInfo().then(() => { - if (this.channelMode === null) { - this.channelMode = this.hasGit ? 'nightly' : 'release'; - } this.checkForUpdates().then(() => { this.updateBadgeVisibility(); }); @@ -118,6 +116,14 @@ export class UpdateService { if (data.success) { this.channelMode = channel; + // Persist channel preference to settings.json + fetch('/api/lm/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ update_channel: channel }) + }).then(r => { + if (!r.ok) console.warn('Failed to persist update channel:', r.status); + }).catch(e => console.warn('Failed to persist update channel:', e)); await this.checkForUpdates({ force: true }); this.updateModalContent(); this.updateChannelUI(); @@ -154,6 +160,20 @@ export class UpdateService { } } + _resolveChannelFromSettings() { + const stored = state?.global?.settings?.update_channel; + if (stored === 'nightly' || stored === 'release') { + return stored; + } + if (!this.hasGit) { + return 'release'; + } + if (this.gitInfo?.branch === 'detached') { + return 'release'; + } + return 'nightly'; + } + async _confirmChannelSwitch(titleKey, messageKey) { return new Promise((resolve) => { const title = translate(titleKey); @@ -475,6 +495,18 @@ export class UpdateService { } async checkForUpdates({ force = false } = {}) { + let needsMigration = false; + if (this.channelMode === null) { + const stored = state?.global?.settings?.update_channel; + if (stored === 'nightly' || stored === 'release') { + this.channelMode = stored; + } else if (!this.hasGit) { + this.channelMode = 'release'; + needsMigration = true; + } + // hasGit=true with no stored value: wait for gitInfo.branch + } + if (!force && !this.updateNotificationsEnabled) { return; } @@ -493,7 +525,7 @@ export class UpdateService { try { // Call backend API to check for updates with nightly flag - const nightly = this.channelMode === 'nightly'; + const nightly = (this.channelMode ?? (this.hasGit ? 'nightly' : 'release')) === 'nightly'; const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`); const data = await response.json(); @@ -503,8 +535,19 @@ export class UpdateService { this.updateInfo = data; this.gitInfo = data.git_info || this.gitInfo; this.hasGit = data.has_git || false; - if (this.channelMode === null) { - this.channelMode = this.hasGit ? 'nightly' : 'release'; + + if (needsMigration || this.channelMode === null) { + this.channelMode = this._resolveChannelFromSettings(); + if (state?.global?.settings) { + state.global.settings.update_channel = this.channelMode; + } + fetch('/api/lm/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ update_channel: this.channelMode }) + }).then(r => { + if (!r.ok) console.warn('Failed to persist update channel:', r.status); + }).catch(e => console.warn('Failed to persist update channel:', e)); } this.updateAvailable = data.update_available; diff --git a/tests/frontend/managers/updateService.test.js b/tests/frontend/managers/updateService.test.js index b5d289e8..00092457 100644 --- a/tests/frontend/managers/updateService.test.js +++ b/tests/frontend/managers/updateService.test.js @@ -1,12 +1,26 @@ import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest'; import { UpdateService } from '../../../static/js/managers/UpdateService.js'; +import { state } from '../../../static/js/state/index.js'; function createFetchResponse(payload) { return { - json: vi.fn().mockResolvedValue(payload) + json: vi.fn().mockResolvedValue(payload), + ok: true, }; } +function stubSettingsUpdateChannel(channel) { + state.global = state.global || {}; + state.global.settings = state.global.settings || {}; + state.global.settings.update_channel = channel; +} + +function clearSettingsUpdateChannel() { + if (state.global?.settings) { + delete state.global.settings.update_channel; + } +} + describe('UpdateService passive checks', () => { let service; let fetchMock; @@ -16,10 +30,13 @@ describe('UpdateService passive checks', () => { success: true, current_version: 'v1.0.0', latest_version: 'v1.0.0', - git_info: { short_hash: 'abc123' } + git_info: { short_hash: 'abc123' }, + has_git: true, })); global.fetch = fetchMock; + stubSettingsUpdateChannel('release'); + service = new UpdateService(); service.updateNotificationsEnabled = false; service.lastCheckTime = 0; @@ -28,6 +45,7 @@ describe('UpdateService passive checks', () => { afterEach(() => { delete global.fetch; + clearSettingsUpdateChannel(); }); it('skips passive update checks when notifications are disabled', async () => {