Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-update-feed-proxy.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 56 additions & 1 deletion packages/desktop/src/main/auto-updater.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
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;
channel: string | null;
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;
Expand Down Expand Up @@ -66,6 +76,7 @@ interface StartAutoUpdaterOpts {
isPackaged: boolean;
forceDevBypass?: boolean;
feedUrl?: string;
proxyFeed?: { base: string; channels: ReadonlySet<UpdateChannel> };
whenRendererReady?: (fn: () => void) => void;
prepareForRelaunch?: () => void | Promise<void>;
showCheckNowResult?: (result: CheckNowResult) => void;
Expand Down Expand Up @@ -223,6 +234,7 @@ export function startAutoUpdater(opts: StartAutoUpdaterOpts): StartAutoUpdaterHa
isPackaged,
forceDevBypass = false,
feedUrl,
proxyFeed,
whenRendererReady,
showCheckNowResult,
clock = DEFAULT_CLOCK,
Expand All @@ -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 = <K extends keyof EventChannels>(
channel: K,
payload: EventChannels[K]['payload'],
Expand Down Expand Up @@ -501,6 +554,7 @@ export function startAutoUpdater(opts: StartAutoUpdaterOpts): StartAutoUpdaterHa
});
onDispatch?.('error-unclassified');
}
revertToGithubFeed(err.code ?? err.message);
if (relaunchInFlight) {
failRelaunch(
relaunchInFlight.version,
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<UpdateChannel>(),
},
whenRendererReady: (fn) => {
const tryFire = (win: BrowserWindow): void => {
if (win.webContents.isLoading() || win.webContents.getURL() === '') {
Expand Down
164 changes: 163 additions & 1 deletion packages/desktop/tests/integration/auto-updater.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(() => {});
Expand Down Expand Up @@ -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<typeof startAutoUpdater>[0]['showCheckNowResult'];
Expand All @@ -155,6 +166,8 @@ function makeRig(
isPackaged = true,
forceDevBypass,
feedUrl,
proxyFeed,
updaterSetup,
extraWindowCount = 0,
prepareForRelaunch,
showCheckNowResult,
Expand Down Expand Up @@ -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,
Expand All @@ -198,6 +212,7 @@ function makeRig(
isPackaged,
forceDevBypass,
feedUrl,
proxyFeed,
prepareForRelaunch,
showCheckNowResult,
clock: rig.clock,
Expand Down Expand Up @@ -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', () => {
Expand Down