Skip to content

Commit 0ae46cb

Browse files
committed
feat(desktop): add standalone update settings tab
Expose update status, channel selection, automatic checks, and notifications in a dedicated desktop settings tab. Keep download and installation behind explicit user actions.
1 parent 5c38b19 commit 0ae46cb

104 files changed

Lines changed: 730 additions & 359 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/main.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,13 @@ import {
4545
installDownloadedUpdateNow,
4646
markUpdateNotified,
4747
setAutoUpdate,
48+
setNotifyUpdate,
49+
setUpdateChannel,
4850
skipUpdate,
4951
startUpdateDownload,
5052
undoSkippedUpdate,
5153
updateReleaseNotesUrl,
54+
type UpdateChannel,
5255
} from './updater'
5356
import { windowAppearanceOptions } from './window-options'
5457
import { createDesktopLifecycle, type DesktopLifecycle } from './window-lifecycle'
@@ -200,6 +203,13 @@ function updateVersion(value: unknown): string {
200203
return value
201204
}
202205

206+
function updateChannel(value: unknown): UpdateChannel {
207+
if (value !== 'stable' && value !== 'beta' && value !== 'nightly') {
208+
throw new TypeError('update channel must be stable, beta, or nightly')
209+
}
210+
return value
211+
}
212+
203213
/** Install navigation and permission policy before the first renderer loads. */
204214
function hardenSession(): void {
205215
const desktopSession = session.defaultSession
@@ -267,6 +277,15 @@ ipcMain.handle('pythinker:update:set-auto', (event, enabled: unknown) => {
267277
if (typeof enabled !== 'boolean') throw new TypeError('automatic update checks must be a boolean')
268278
return setAutoUpdate(enabled)
269279
})
280+
ipcMain.handle('pythinker:update:set-channel', (event, channel: unknown) => {
281+
assertTrustedSender(event)
282+
return setUpdateChannel(updateChannel(channel))
283+
})
284+
ipcMain.handle('pythinker:update:set-notify', (event, enabled: unknown) => {
285+
assertTrustedSender(event)
286+
if (typeof enabled !== 'boolean') throw new TypeError('update notifications must be a boolean')
287+
return setNotifyUpdate(enabled)
288+
})
270289
ipcMain.handle('pythinker:update:check', (event) => {
271290
assertTrustedSender(event)
272291
return checkForUpdatesNow()

apps/desktop/src/preload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ contextBridge.exposeInMainWorld('pythinkerDesktop', {
44
platform: process.platform,
55
getUpdateState: () => ipcRenderer.invoke('pythinker:update:get'),
66
setAutoUpdate: (enabled: boolean) => ipcRenderer.invoke('pythinker:update:set-auto', enabled),
7+
setUpdateChannel: (channel: 'stable' | 'beta' | 'nightly') =>
8+
ipcRenderer.invoke('pythinker:update:set-channel', channel),
9+
setNotifyUpdate: (enabled: boolean) => ipcRenderer.invoke('pythinker:update:set-notify', enabled),
710
checkForUpdates: () => ipcRenderer.invoke('pythinker:update:check'),
811
downloadUpdate: () => ipcRenderer.invoke('pythinker:update:download'),
912
cancelUpdateDownload: () => ipcRenderer.invoke('pythinker:update:cancel'),

apps/desktop/src/updater.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ const INITIAL_CHECK_DELAY_MS = 10_000
1212
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1_000
1313
const RELEASE_REPOSITORY_PATH = '/PyModel/pythinker-desktop-releases/releases/tag/'
1414

15+
export type UpdateChannel = 'stable' | 'beta' | 'nightly'
16+
1517
export interface UpdateSettings {
1618
readonly autoUpdate: boolean
19+
readonly channel: UpdateChannel
20+
readonly notifyUpdate: boolean
1721
readonly notifiedVersion?: string
1822
readonly skippedVersion?: string
1923
readonly pendingInstallVersion?: string
@@ -44,6 +48,8 @@ export type UpdateState = {
4448
bytesPerSecond?: number
4549
message?: string
4650
autoUpdate: boolean
51+
channel: UpdateChannel
52+
notifyUpdate: boolean
4753
notifiedVersion?: string
4854
skippedVersion?: string
4955
completedVersion?: string
@@ -54,12 +60,20 @@ export type UpdateTelemetryTrack = (
5460
properties?: Readonly<Record<string, string>>,
5561
) => void
5662

57-
const DEFAULT_SETTINGS: UpdateSettings = { autoUpdate: true }
63+
const DEFAULT_SETTINGS: UpdateSettings = {
64+
autoUpdate: true,
65+
channel: 'stable',
66+
notifyUpdate: true,
67+
}
5868

5969
function optionalString(value: unknown): string | undefined {
6070
return typeof value === 'string' && value.length > 0 ? value : undefined
6171
}
6272

73+
function updateChannel(value: unknown): UpdateChannel {
74+
return value === 'beta' || value === 'nightly' ? value : 'stable'
75+
}
76+
6377
export function readUpdateSettings(dir: string): UpdateSettings {
6478
try {
6579
const parsed: unknown = JSON.parse(readFileSync(join(dir, UPDATE_SETTINGS_FILE), 'utf8'))
@@ -68,6 +82,8 @@ export function readUpdateSettings(dir: string): UpdateSettings {
6882
if (typeof source['autoUpdate'] === 'boolean') {
6983
return {
7084
autoUpdate: source['autoUpdate'],
85+
channel: updateChannel(source['channel']),
86+
notifyUpdate: typeof source['notifyUpdate'] === 'boolean' ? source['notifyUpdate'] : true,
7187
notifiedVersion: optionalString(source['notifiedVersion']),
7288
skippedVersion: optionalString(source['skippedVersion']),
7389
pendingInstallVersion: optionalString(source['pendingInstallVersion']),
@@ -125,6 +141,8 @@ let state: UpdateState = {
125141
status: app.isPackaged ? 'idle' : 'disabled',
126142
installedVersion: app.getVersion(),
127143
autoUpdate: settings.autoUpdate,
144+
channel: settings.channel,
145+
notifyUpdate: settings.notifyUpdate,
128146
}
129147
let getWindow: (() => BrowserWindow | undefined) | undefined
130148
let initialCheckTimer: ReturnType<typeof setTimeout> | undefined
@@ -183,6 +201,8 @@ function updateState(next: Partial<UpdateState>): void {
183201
...state,
184202
...next,
185203
autoUpdate: settings.autoUpdate,
204+
channel: settings.channel,
205+
notifyUpdate: settings.notifyUpdate,
186206
notifiedVersion: settings.notifiedVersion,
187207
skippedVersion: settings.skippedVersion,
188208
completedVersion: settings.completedVersion,
@@ -238,6 +258,9 @@ function hasUpdateConfig(): boolean {
238258
function configureExplicitConsent(): void {
239259
autoUpdater.autoDownload = false
240260
autoUpdater.autoInstallOnAppQuit = false
261+
autoUpdater.channel = settings.channel === 'stable' ? null : settings.channel
262+
autoUpdater.allowPrerelease = settings.channel !== 'stable'
263+
autoUpdater.allowDowngrade = false
241264
}
242265

243266
function disableUpdates(): UpdateState {
@@ -366,6 +389,8 @@ export function initUpdater(
366389
status: app.isPackaged ? 'idle' : 'disabled',
367390
installedVersion: app.getVersion(),
368391
autoUpdate: settings.autoUpdate,
392+
channel: settings.channel,
393+
notifyUpdate: settings.notifyUpdate,
369394
notifiedVersion: settings.notifiedVersion,
370395
skippedVersion: settings.skippedVersion,
371396
completedVersion: settings.completedVersion,
@@ -420,6 +445,48 @@ export function setAutoUpdate(enabled: boolean): UpdateState {
420445
return state
421446
}
422447

448+
export function setUpdateChannel(channel: UpdateChannel): UpdateState {
449+
if (settings.channel === channel) return state
450+
if (state.status === 'checking' || state.status === 'downloading' || state.status === 'downloaded') {
451+
throw new Error('Cannot change update channel while an update operation is active')
452+
}
453+
454+
persistSettings({
455+
...settings,
456+
channel,
457+
notifiedVersion: undefined,
458+
skippedVersion: undefined,
459+
})
460+
461+
if (app.isPackaged && hasUpdateConfig()) {
462+
try {
463+
configureExplicitConsent()
464+
} catch (error) {
465+
stateError(error)
466+
return state
467+
}
468+
}
469+
470+
updateState({
471+
status: app.isPackaged && hasUpdateConfig() ? 'idle' : 'disabled',
472+
availableVersion: undefined,
473+
releaseDate: undefined,
474+
releaseNotes: undefined,
475+
lastCheckedAt: undefined,
476+
percent: undefined,
477+
transferred: undefined,
478+
total: undefined,
479+
bytesPerSecond: undefined,
480+
message: undefined,
481+
})
482+
return state
483+
}
484+
485+
export function setNotifyUpdate(enabled: boolean): UpdateState {
486+
persistSettings({ ...settings, notifyUpdate: enabled })
487+
return state
488+
}
489+
423490
export async function checkForUpdatesNow(): Promise<UpdateState> {
424491
if (!app.isPackaged) {
425492
updateState({ status: 'disabled' })

apps/desktop/tests/updater.spec.ts

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ afterEach(() => {
5656
vi.clearAllMocks()
5757
autoUpdater.autoDownload = undefined as unknown as boolean
5858
autoUpdater.autoInstallOnAppQuit = undefined as unknown as boolean
59+
autoUpdater.allowPrerelease = undefined as unknown as boolean
60+
autoUpdater.allowDowngrade = undefined as unknown as boolean
61+
autoUpdater.channel = null
5962
})
6063

6164
function temporaryDirectory(): string {
@@ -66,27 +69,56 @@ function temporaryDirectory(): string {
6669

6770
describe('update settings', () => {
6871
it('defaults automatic updates to enabled when the file is missing', () => {
69-
expect(readUpdateSettings(temporaryDirectory())).toEqual({ autoUpdate: true })
72+
expect(readUpdateSettings(temporaryDirectory())).toEqual({
73+
autoUpdate: true,
74+
channel: 'stable',
75+
notifyUpdate: true,
76+
})
7077
})
7178

7279
it('defaults automatic updates to enabled when the file is corrupt', () => {
7380
const directory = temporaryDirectory()
7481
writeFileSync(join(directory, 'update-settings.json'), '{not-json', 'utf8')
7582

76-
expect(readUpdateSettings(directory)).toEqual({ autoUpdate: true })
83+
expect(readUpdateSettings(directory)).toEqual({
84+
autoUpdate: true,
85+
channel: 'stable',
86+
notifyUpdate: true,
87+
})
88+
})
89+
90+
it('round-trips automatic checks, update notifications, and the channel', () => {
91+
const directory = temporaryDirectory()
92+
writeUpdateSettings(directory, {
93+
autoUpdate: false,
94+
channel: 'beta',
95+
notifyUpdate: false,
96+
})
97+
98+
expect(readUpdateSettings(directory)).toEqual({
99+
autoUpdate: false,
100+
channel: 'beta',
101+
notifyUpdate: false,
102+
})
77103
})
78104

79-
it('round-trips the automatic-updates setting', () => {
105+
it('uses safe defaults for missing or invalid new settings', () => {
80106
const directory = temporaryDirectory()
81-
writeUpdateSettings(directory, { autoUpdate: false })
107+
writeFileSync(join(directory, 'update-settings.json'), '{"autoUpdate":false,"channel":"preview"}\n', 'utf8')
82108

83-
expect(readUpdateSettings(directory)).toEqual({ autoUpdate: false })
109+
expect(readUpdateSettings(directory)).toEqual({
110+
autoUpdate: false,
111+
channel: 'stable',
112+
notifyUpdate: true,
113+
})
84114
})
85115

86116
it('persists update notification, skip, install, and completion receipts separately', () => {
87117
const directory = temporaryDirectory()
88118
const value = {
89119
autoUpdate: true,
120+
channel: 'nightly' as const,
121+
notifyUpdate: true,
90122
notifiedVersion: '1.2.3',
91123
skippedVersion: '1.2.3',
92124
pendingInstallVersion: '1.3.0',
@@ -106,7 +138,13 @@ describe('update telemetry transitions', () => {
106138
const track = (event: string): void => {
107139
events.push(event)
108140
}
109-
const previous: UpdateState = { status: 'idle', installedVersion: '1.0.0', autoUpdate: true }
141+
const previous: UpdateState = {
142+
status: 'idle',
143+
installedVersion: '1.0.0',
144+
autoUpdate: true,
145+
channel: 'stable',
146+
notifyUpdate: true,
147+
}
110148

111149
trackUpdateTransition(previous, { ...previous, status: 'checking' }, track)
112150
trackUpdateTransition(previous, { ...previous, status: 'available', availableVersion: '0.2.0' }, track)
@@ -136,7 +174,11 @@ describe('release notes URL', () => {
136174
describe('packaged builds without update metadata', () => {
137175
it('disables updates without wiring updater events', () => {
138176
const directory = temporaryDirectory()
139-
writeUpdateSettings(directory, { autoUpdate: false })
177+
writeUpdateSettings(directory, {
178+
autoUpdate: false,
179+
channel: 'stable',
180+
notifyUpdate: true,
181+
})
140182
vi.mocked(app.getPath).mockReturnValue(directory)
141183
Object.defineProperty(app, 'isPackaged', { configurable: true, value: true })
142184
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
@@ -154,6 +196,45 @@ describe('packaged builds without update metadata', () => {
154196
})
155197

156198
describe('strict update consent', () => {
199+
it('changes channels and notification preference without checking, downloading, installing, or downgrading', async () => {
200+
vi.resetModules()
201+
const directory = temporaryDirectory()
202+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
203+
writeFileSync(
204+
join(directory, 'update-settings.json'),
205+
'{"autoUpdate":false,"channel":"beta","notifyUpdate":true}\n',
206+
'utf8',
207+
)
208+
const { app: localApp } = await import('electron')
209+
const { default: localElectronUpdater } = await import('electron-updater')
210+
const {
211+
initUpdater: initLocalUpdater,
212+
setNotifyUpdate: setLocalNotifyUpdate,
213+
setUpdateChannel: setLocalUpdateChannel,
214+
} = await import('../src/updater')
215+
const localAutoUpdater = localElectronUpdater.autoUpdater
216+
vi.mocked(localApp.getPath).mockReturnValue(directory)
217+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
218+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
219+
220+
initLocalUpdater(() => undefined)
221+
expect(localAutoUpdater.channel).toBe('beta')
222+
expect(localAutoUpdater.allowPrerelease).toBe(true)
223+
expect(localAutoUpdater.allowDowngrade).toBe(false)
224+
225+
expect(setLocalUpdateChannel('nightly')).toMatchObject({ channel: 'nightly', status: 'idle' })
226+
expect(setLocalNotifyUpdate(false)).toMatchObject({ notifyUpdate: false })
227+
expect(setLocalUpdateChannel('stable')).toMatchObject({ channel: 'stable', status: 'idle' })
228+
expect(localAutoUpdater.channel).toBeNull()
229+
expect(localAutoUpdater.allowPrerelease).toBe(false)
230+
expect(localAutoUpdater.allowDowngrade).toBe(false)
231+
expect(localAutoUpdater.autoDownload).toBe(false)
232+
expect(localAutoUpdater.autoInstallOnAppQuit).toBe(false)
233+
expect(localAutoUpdater.checkForUpdates).not.toHaveBeenCalled()
234+
expect(localAutoUpdater.downloadUpdate).not.toHaveBeenCalled()
235+
expect(localAutoUpdater.quitAndInstall).not.toHaveBeenCalled()
236+
})
237+
157238
it('manual check cannot download an update', async () => {
158239
vi.resetModules()
159240
const directory = temporaryDirectory()
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"sourceHash": "9b3dc1691590f09af45eb3cab7d356ecf76b4ebe1b2ca9ec4d89b9b50c610d9a",
2+
"sourceHash": "f1c3ebe2f168111a6fd779f6593566ba221abc86d20272fffb04554f39958da5",
33
"sourceFileCount": 399
44
}

0 commit comments

Comments
 (0)