diff --git a/.changeset/desktop-update-feed-proxy.md b/.changeset/desktop-update-feed-proxy.md new file mode 100644 index 00000000..ef56bad6 --- /dev/null +++ b/.changeset/desktop-update-feed-proxy.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Desktop: add an opt-in path to fetch updates through the openknowledge.ai proxy so updates can be counted per version. When the build's channel is enabled, electron-updater's feed is pointed at `openknowledge.ai/updates/{channel}` and requests are tagged with `x-ok-from-version` / `x-ok-channel` headers. Default-off — production keeps reading the GitHub `publish:` config until the proxy is verified live, then the enabled-channel set flips to beta-first. A feed failure on the first check reverts to the GitHub provider for the session, so auto-update reliability never drops below GitHub-direct. diff --git a/packages/desktop/src/main/auto-updater.ts b/packages/desktop/src/main/auto-updater.ts index a2af94af..01b69453 100644 --- a/packages/desktop/src/main/auto-updater.ts +++ b/packages/desktop/src/main/auto-updater.ts @@ -1,9 +1,13 @@ +import type { OutgoingHttpHeaders } from 'node:http'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; import type { EventChannels } from '../shared/ipc-events.ts'; import { createHandler } from '../shared/ipc-handler.ts'; import { type SendableWebContents, sendToRenderer } from '../shared/ipc-send.ts'; import type { AppState, UpdateChannel } from './state-store.ts'; +const GITHUB_OWNER = 'inkeep'; +const GITHUB_REPO = 'open-knowledge'; + export interface UpdaterLike { autoDownload: boolean; autoInstallOnAppQuit: boolean; @@ -11,7 +15,13 @@ export interface UpdaterLike { allowPrerelease: boolean; allowDowngrade: boolean; forceDevUpdateConfig: boolean; - setFeedURL(urlOrOptions: string): void; + setFeedURL( + urlOrOptions: + | string + | { provider: 'generic'; url: string } + | { provider: 'github'; owner: string; repo: string }, + ): void; + requestHeaders: OutgoingHttpHeaders | null; on(event: 'checking-for-update', listener: () => void): this; on(event: 'update-available', listener: (info: { version?: string }) => void): this; on(event: 'update-not-available', listener: (info: { version?: string }) => void): this; @@ -66,6 +76,7 @@ interface StartAutoUpdaterOpts { isPackaged: boolean; forceDevBypass?: boolean; feedUrl?: string; + proxyFeed?: { base: string; channels: ReadonlySet }; whenRendererReady?: (fn: () => void) => void; prepareForRelaunch?: () => void | Promise; showCheckNowResult?: (result: CheckNowResult) => void; @@ -223,6 +234,7 @@ export function startAutoUpdater(opts: StartAutoUpdaterOpts): StartAutoUpdaterHa isPackaged, forceDevBypass = false, feedUrl, + proxyFeed, whenRendererReady, showCheckNowResult, clock = DEFAULT_CLOCK, @@ -238,13 +250,54 @@ export function startAutoUpdater(opts: StartAutoUpdaterOpts): StartAutoUpdaterHa applyChannelSettings(updater, buildChannel); updater.forceDevUpdateConfig = forceDevBypass; + let usingProxyFeed = false; + let proxyFallbackTried = false; if (feedUrl) { updater.setFeedURL(feedUrl); logger.info('setFeedURL (dev override) — updater will pull manifest from local mock', { feedUrl, }); + } else if (proxyFeed?.channels.has(buildChannel)) { + const channelPath = buildChannel === 'beta' ? 'beta' : 'stable'; + updater.setFeedURL({ provider: 'generic', url: `${proxyFeed.base}/${channelPath}` }); + updater.requestHeaders = { + 'x-ok-from-version': getAppVersion(), + 'x-ok-channel': channelPath, + }; + usingProxyFeed = true; + logger.info('setFeedURL (proxy) — updater feed pointed at the openknowledge.ai proxy', { + channel: channelPath, + }); } + const revertToGithubFeed = (cause: string): void => { + if (!usingProxyFeed || proxyFallbackTried) return; + proxyFallbackTried = true; + usingProxyFeed = false; + updater.requestHeaders = null; + try { + updater.setFeedURL({ provider: 'github', owner: GITHUB_OWNER, repo: GITHUB_REPO }); + } catch (err) { + logger.error('proxy-feed fallback setFeedURL threw', { + cause, + message: err instanceof Error ? err.message : String(err), + }); + return; + } + logger.warn('proxy feed failed — reverted to GitHub provider for this session', { cause }); + void updater.checkForUpdates().catch((err: Error & { code?: string }) => { + const ctx = { + code: err?.code, + message: err instanceof Error ? err.message : String(err), + }; + if (isClassifiedUpdaterError(err)) { + logger.warn('post-fallback checkForUpdates rejected', ctx); + } else { + logger.debug('post-fallback checkForUpdates rejected', ctx); + } + }); + }; + const broadcast = ( channel: K, payload: EventChannels[K]['payload'], @@ -501,6 +554,7 @@ export function startAutoUpdater(opts: StartAutoUpdaterOpts): StartAutoUpdaterHa }); onDispatch?.('error-unclassified'); } + revertToGithubFeed(err.code ?? err.message); if (relaunchInFlight) { failRelaunch( relaunchInFlight.version, @@ -696,6 +750,7 @@ export function startAutoUpdater(opts: StartAutoUpdaterOpts): StartAutoUpdaterHa logger.debug('first-launch checkForUpdates rejected', { message: err instanceof Error ? err.message : String(err), }); + revertToGithubFeed('first-check-rejected'); startPeriodicChecks(); }); } else { diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index f2f0743c..daa21aea 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -221,6 +221,7 @@ import { setLastUsedProjectParent, setProjectSessionState, setSpellCheckEnabled as setSpellCheckEnabledState, + type UpdateChannel, } from './state-store.ts'; import { isTerminalConsented, isTerminalConsentedWithGrace } from './terminal-consent.ts'; import { type TerminalReaper, wireWindowTerminalReap } from './terminal-lifecycle.ts'; @@ -2802,6 +2803,10 @@ function bootPrimaryInstance(): void { isPackaged: app.isPackaged, forceDevBypass: process.env.OK_UPDATER_FORCE_DEV === '1', feedUrl: process.env.OK_UPDATER_FEED_URL || undefined, + proxyFeed: { + base: 'https://openknowledge.ai/updates', + channels: new Set(), + }, whenRendererReady: (fn) => { const tryFire = (win: BrowserWindow): void => { if (win.webContents.isLoading() || win.webContents.getURL() === '') { diff --git a/packages/desktop/tests/integration/auto-updater.test.ts b/packages/desktop/tests/integration/auto-updater.test.ts index 737490ea..9ec2178f 100644 --- a/packages/desktop/tests/integration/auto-updater.test.ts +++ b/packages/desktop/tests/integration/auto-updater.test.ts @@ -1,5 +1,6 @@ import { describe, expect, mock, test } from 'bun:test'; import { EventEmitter } from 'node:events'; +import type { OutgoingHttpHeaders } from 'node:http'; import { bootAutoUpdater, buildCheckNowResultFromError, @@ -36,7 +37,15 @@ class FakeUpdater extends EventEmitter implements UpdaterLike { allowPrerelease = true; // deliberately non-default so the lock-down is observable allowDowngrade = true; forceDevUpdateConfig = false; - setFeedURL = mock((_urlOrOptions: string) => {}); + requestHeaders: OutgoingHttpHeaders | null = null; + setFeedURL = mock( + ( + _urlOrOptions: + | string + | { provider: 'generic'; url: string } + | { provider: 'github'; owner: string; repo: string }, + ) => {}, + ); checkForUpdates = mock(() => Promise.resolve(undefined)); downloadUpdate = mock(() => Promise.resolve([] as unknown[])); quitAndInstall = mock(() => {}); @@ -141,6 +150,8 @@ function makeRig( isPackaged?: boolean; forceDevBypass?: boolean; feedUrl?: string; + proxyFeed?: { base: string; channels: ReadonlySet<'latest' | 'beta'> }; + updaterSetup?: (u: FakeUpdater) => void; extraWindowCount?: number; prepareForRelaunch?: () => void; showCheckNowResult?: Parameters[0]['showCheckNowResult']; @@ -155,6 +166,8 @@ function makeRig( isPackaged = true, forceDevBypass, feedUrl, + proxyFeed, + updaterSetup, extraWindowCount = 0, prepareForRelaunch, showCheckNowResult, @@ -185,6 +198,7 @@ function makeRig( rig.windows.push(buf); fanOutTargets.push(makeFakeWindow(buf)); } + updaterSetup?.(rig.updater as FakeUpdater); const handle = startAutoUpdater({ updater: rig.updater, ipcMain: rig.ipc, @@ -198,6 +212,7 @@ function makeRig( isPackaged, forceDevBypass, feedUrl, + proxyFeed, prepareForRelaunch, showCheckNowResult, clock: rig.clock, @@ -290,6 +305,153 @@ describe('startAutoUpdater — initial configuration (parent §8.10 LOCKED)', () expect(beta.rig.updater.channel).toBe('beta'); expect(beta.rig.updater.allowPrerelease).toBe(true); }); + + const PROXY_BASE = 'https://openknowledge.ai/updates'; + + test('proxyFeed: beta build with beta enabled → generic feed + version/channel headers', () => { + const { rig } = makeRig({ + appVersion: '0.4.0-beta.7', + proxyFeed: { base: PROXY_BASE, channels: new Set(['beta']) }, + }); + expect(rig.updater.setFeedURL).toHaveBeenCalledWith({ + provider: 'generic', + url: `${PROXY_BASE}/beta`, + }); + expect(rig.updater.requestHeaders).toEqual({ + 'x-ok-from-version': '0.4.0-beta.7', + 'x-ok-channel': 'beta', + }); + }); + + test('proxyFeed: stable build maps the latest channel to the proxy /stable path', () => { + const { rig } = makeRig({ + appVersion: '0.4.0', + proxyFeed: { base: PROXY_BASE, channels: new Set(['latest']) }, + }); + expect(rig.updater.setFeedURL).toHaveBeenCalledWith({ + provider: 'generic', + url: `${PROXY_BASE}/stable`, + }); + expect(rig.updater.requestHeaders).toEqual({ + 'x-ok-from-version': '0.4.0', + 'x-ok-channel': 'stable', + }); + }); + + test('proxyFeed: default-off — channel not in the set leaves the GitHub default', () => { + const { rig } = makeRig({ + appVersion: '0.4.0', // stable build + proxyFeed: { base: PROXY_BASE, channels: new Set(['beta']) }, // only beta enabled + }); + expect(rig.updater.setFeedURL).not.toHaveBeenCalled(); + expect(rig.updater.requestHeaders).toBeNull(); + }); + + test('proxyFeed: a dev feedUrl override takes precedence over the proxy', () => { + const { rig } = makeRig({ + appVersion: '0.4.0', + feedUrl: 'http://127.0.0.1:54321', + proxyFeed: { base: PROXY_BASE, channels: new Set(['latest']) }, + }); + expect(rig.updater.setFeedURL).toHaveBeenCalledTimes(1); + expect(rig.updater.setFeedURL).toHaveBeenCalledWith('http://127.0.0.1:54321'); + expect(rig.updater.requestHeaders).toBeNull(); + }); + + test('proxyFeed: a first-check failure reverts to the GitHub provider for the session', async () => { + const { rig } = makeRig({ + appVersion: '0.4.0-beta.7', + proxyFeed: { base: PROXY_BASE, channels: new Set(['beta']) }, + updaterSetup: (u) => { + let firstCall = true; + u.checkForUpdates = mock(() => { + if (firstCall) { + firstCall = false; + return Promise.reject(new Error('proxy 503')); + } + return Promise.resolve(undefined); + }); + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(rig.updater.setFeedURL).toHaveBeenCalledWith({ + provider: 'github', + owner: 'inkeep', + repo: 'open-knowledge', + }); + expect(rig.updater.requestHeaders).toBeNull(); + expect(rig.clock.setTimeout).toHaveBeenCalledTimes(1); + }); + + test('proxyFeed: an error EVENT (not a rejection) reverts to the GitHub provider', async () => { + const { rig } = makeRig({ + appVersion: '0.4.0-beta.7', + proxyFeed: { base: PROXY_BASE, channels: new Set(['beta']) }, + }); + expect(rig.updater.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: `${PROXY_BASE}/beta`, + }); + expect(rig.updater.requestHeaders).not.toBeNull(); + + rig.updater.emit( + 'error', + Object.assign(new Error('proxy 503'), { code: 'ERR_UPDATER_INVALID_RELEASE_FEED' }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(rig.updater.setFeedURL).toHaveBeenCalledWith({ + provider: 'github', + owner: 'inkeep', + repo: 'open-knowledge', + }); + expect(rig.updater.requestHeaders).toBeNull(); + }); + + test('proxyFeed: a second error event after fallback is a no-op (idempotency guard)', async () => { + const { rig } = makeRig({ + appVersion: '0.4.0-beta.7', + proxyFeed: { base: PROXY_BASE, channels: new Set(['beta']) }, + }); + rig.updater.emit( + 'error', + Object.assign(new Error('proxy 503'), { code: 'ERR_UPDATER_INVALID_RELEASE_FEED' }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const callsAfterFallback = rig.updater.setFeedURL.mock.calls.length; + expect(callsAfterFallback).toBe(2); + + rig.updater.emit( + 'error', + Object.assign(new Error('still broken'), { code: 'ERR_UPDATER_INVALID_RELEASE_FEED' }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(rig.updater.setFeedURL.mock.calls.length).toBe(callsAfterFallback); + }); + + test('proxyFeed: a throw from the fallback setFeedURL is contained (no re-check)', async () => { + const { rig } = makeRig({ + appVersion: '0.4.0-beta.7', + proxyFeed: { base: PROXY_BASE, channels: new Set(['beta']) }, + updaterSetup: (u) => { + const original = u.setFeedURL; + u.setFeedURL = mock((arg) => { + if (typeof arg === 'object' && arg?.provider === 'github') { + throw new Error('setFeedURL boom'); + } + return original(arg); + }); + }, + }); + const checksBeforeError = rig.updater.checkForUpdates.mock.calls.length; + rig.updater.emit( + 'error', + Object.assign(new Error('proxy 503'), { code: 'ERR_UPDATER_INVALID_RELEASE_FEED' }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(rig.updater.checkForUpdates.mock.calls.length).toBe(checksBeforeError); + expect(rig.logger.error).toHaveBeenCalled(); + }); }); describe('cross-channel veto on update-available', () => {