Skip to content
Open
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
1 change: 1 addition & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork
- Stable-version pushes wait for the matching release to finish so tag-based version resolution advances past the published version.
- Release build jobs skip relay client tracing config because the relay config job is disabled.
- Release builds publish updater metadata against the fork repository.
- Unsigned macOS updates replace the installed app bundle with a detached helper instead of using Squirrel.Mac installation.
- Fork stable release versions use date-based `YYYY.M.DDSS` numbers without build metadata. Release PRs commit them before release artifacts are built and tagged.
- macOS release signing is separate from Apple notarization.
- Self-signed macOS signing certificates are trusted during release builds.
Expand Down
36 changes: 25 additions & 11 deletions apps/desktop/src/electron/ElectronUpdater.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { beforeEach, vi } from "vite-plus/test";

const { autoUpdaterMock } = vi.hoisted(() => ({
autoUpdaterMock: {
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

const { autoUpdaterMock, updaterConstructorMock } = vi.hoisted(() => {
const autoUpdaterMock = {
allowDowngrade: false,
allowPrerelease: false,
autoDownload: true,
Expand All @@ -17,15 +21,25 @@ const { autoUpdaterMock } = vi.hoisted(() => ({
quitAndInstall: vi.fn(),
removeListener: vi.fn(),
setFeedURL: vi.fn(),
},
}));
};
const updaterConstructorMock = vi.fn(function () {
return autoUpdaterMock;
});
return { autoUpdaterMock, updaterConstructorMock };
});

vi.mock("electron-updater", () => ({
autoUpdater: autoUpdaterMock,
AppImageUpdater: updaterConstructorMock,
MacUpdater: updaterConstructorMock,
NsisUpdater: updaterConstructorMock,
}));

import * as ElectronUpdater from "./ElectronUpdater.ts";

const updaterLayer = ElectronUpdater.layer.pipe(
Layer.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "linux"))),
);

describe("ElectronUpdater", () => {
beforeEach(() => {
autoUpdaterMock.allowDowngrade = false;
Expand Down Expand Up @@ -56,9 +70,9 @@ describe("ElectronUpdater", () => {
}),
);

assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]);
assert.deepEqual(autoUpdaterMock.on.mock.calls.at(-1), ["update-available", listener]);
assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]);
}).pipe(Effect.provide(ElectronUpdater.layer)),
}).pipe(Effect.provide(updaterLayer)),
);

it.effect("wraps rejected update checks in the method-specific typed error", () =>
Expand All @@ -76,7 +90,7 @@ describe("ElectronUpdater", () => {
assert.strictEqual(error.cause, cause);
assert.equal(error.message, "Electron updater failed to check for updates on channel beta.");
assert.notInclude(error.message, cause.message);
}).pipe(Effect.provide(ElectronUpdater.layer)),
}).pipe(Effect.provide(updaterLayer)),
);

it.effect("preserves the execution-time channel on download failures", () =>
Expand All @@ -97,7 +111,7 @@ describe("ElectronUpdater", () => {
"Electron updater failed to download the update on channel nightly.",
);
assert.notInclude(error.message, cause.message);
}).pipe(Effect.provide(ElectronUpdater.layer)),
}).pipe(Effect.provide(updaterLayer)),
);

it.effect("sets full changelog mode", () =>
Expand All @@ -109,7 +123,7 @@ describe("ElectronUpdater", () => {

yield* updater.setFullChangelog(false);
assert.equal(autoUpdaterMock.fullChangelog, false);
}).pipe(Effect.provide(ElectronUpdater.layer)),
}).pipe(Effect.provide(updaterLayer)),
);

it.effect("preserves quit-and-install flags and the execution-time channel", () =>
Expand Down Expand Up @@ -137,6 +151,6 @@ describe("ElectronUpdater", () => {
);
assert.notInclude(error.message, cause.message);
assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]);
}).pipe(Effect.provide(ElectronUpdater.layer)),
}).pipe(Effect.provide(updaterLayer)),
);
});
223 changes: 138 additions & 85 deletions apps/desktop/src/electron/ElectronUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,31 @@ import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";

import { autoUpdater } from "electron-updater";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import {
AppImageUpdater,
MacUpdater,
NsisUpdater,
type AppUpdater,
type UpdateDownloadedEvent,
} from "electron-updater";

type AutoUpdater = typeof autoUpdater;
import { makeInstallUnsignedMacUpdate } from "./installUnsignedMacUpdate.ts";

export type ElectronUpdaterFeedUrl = Parameters<AutoUpdater["setFeedURL"]>[0];
export type ElectronUpdaterFeedUrl = Parameters<AppUpdater["setFeedURL"]>[0];

function createUpdater(platform: NodeJS.Platform): AppUpdater {
switch (platform) {
case "linux":
return new AppImageUpdater();
case "darwin":
return new MacUpdater();
case "win32":
return new NsisUpdater();
default:
throw new Error(`Unsupported desktop update platform: ${platform}`);
}
}

export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass<ElectronUpdaterCheckForUpdatesError>()(
"ElectronUpdaterCheckForUpdatesError",
Expand Down Expand Up @@ -81,92 +101,125 @@ export class ElectronUpdater extends Context.Service<
}
>()("@t3tools/desktop/electron/ElectronUpdater") {}

export const make = ElectronUpdater.of({
setFeedURL: (options) =>
Effect.suspend(() => {
autoUpdater.setFeedURL(options);
return Effect.void;
}),
setAutoDownload: (value) =>
Effect.suspend(() => {
autoUpdater.autoDownload = value;
return Effect.void;
}),
setAutoInstallOnAppQuit: (value) =>
Effect.suspend(() => {
autoUpdater.autoInstallOnAppQuit = value;
return Effect.void;
}),
setChannel: (channel) =>
Effect.suspend(() => {
autoUpdater.channel = channel;
return Effect.void;
}),
setAllowPrerelease: (value) =>
Effect.suspend(() => {
autoUpdater.allowPrerelease = value;
return Effect.void;
}),
allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade),
setAllowDowngrade: (value) =>
Effect.suspend(() => {
autoUpdater.allowDowngrade = value;
return Effect.void;
}),
setFullChangelog: (value) =>
Effect.suspend(() => {
autoUpdater.fullChangelog = value;
return Effect.void;
}),
setDisableDifferentialDownload: (value) =>
Effect.suspend(() => {
autoUpdater.disableDifferentialDownload = value;
return Effect.void;
export const make = Effect.gen(function* () {
const installUnsignedMacUpdate = yield* makeInstallUnsignedMacUpdate();
const platform = yield* HostProcessPlatform;
const updater = createUpdater(platform);
let downloadedUpdatePath: string | undefined;
updater.on("update-downloaded", (event: UpdateDownloadedEvent) => {
downloadedUpdatePath = event.downloadedFile;
});

return ElectronUpdater.of({
setFeedURL: (options) =>
Effect.suspend(() => {
updater.setFeedURL(options);
return Effect.void;
}),
setAutoDownload: (value) =>
Effect.suspend(() => {
updater.autoDownload = value;
return Effect.void;
}),
setAutoInstallOnAppQuit: (value) =>
Effect.suspend(() => {
updater.autoInstallOnAppQuit = value;
return Effect.void;
}),
setChannel: (channel) =>
Effect.suspend(() => {
updater.channel = channel;
return Effect.void;
}),
setAllowPrerelease: (value) =>
Effect.suspend(() => {
updater.allowPrerelease = value;
return Effect.void;
}),
allowDowngrade: Effect.sync(() => updater.allowDowngrade),
setAllowDowngrade: (value) =>
Effect.suspend(() => {
updater.allowDowngrade = value;
return Effect.void;
}),
setFullChangelog: (value) =>
Effect.suspend(() => {
updater.fullChangelog = value;
return Effect.void;
}),
setDisableDifferentialDownload: (value) =>
Effect.suspend(() => {
updater.disableDifferentialDownload = value;
return Effect.void;
}),
checkForUpdates: Effect.suspend(() => {
const channel = updater.channel;
return Effect.tryPromise({
try: () => updater.checkForUpdates(),
catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }),
}).pipe(Effect.asVoid);
}),
checkForUpdates: Effect.suspend(() => {
const channel = autoUpdater.channel;
return Effect.tryPromise({
try: () => autoUpdater.checkForUpdates(),
catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }),
}).pipe(Effect.asVoid);
}),
downloadUpdate: Effect.suspend(() => {
const channel = autoUpdater.channel;
return Effect.tryPromise({
try: () => autoUpdater.downloadUpdate(),
catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }),
}).pipe(Effect.asVoid);
}),
quitAndInstall: ({ isSilent, isForceRunAfter }) =>
Effect.suspend(() => {
const channel = autoUpdater.channel;
return Effect.try({
try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter),
catch: (cause) =>
new ElectronUpdaterQuitAndInstallError({
channel,
isSilent,
isForceRunAfter,
cause,
}),
});
downloadUpdate: Effect.suspend(() => {
const channel = updater.channel;
return Effect.tryPromise({
try: () => updater.downloadUpdate(),
catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }),
}).pipe(Effect.asVoid);
}),
on: (eventName, listener) => {
const eventTarget = autoUpdater as unknown as {
on: (eventName: string, listener: (...args: Array<unknown>) => void) => void;
removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => void;
};
const untypedListener = listener as unknown as (...args: Array<unknown>) => void;
return Effect.acquireRelease(
Effect.sync(() => {
eventTarget.on(eventName, untypedListener);
quitAndInstall: ({ isSilent, isForceRunAfter }) =>
Effect.suspend(() => {
const channel = updater.channel;
if (platform === "darwin") {
if (downloadedUpdatePath === undefined) {
return Effect.fail(
new ElectronUpdaterQuitAndInstallError({
channel,
isSilent,
isForceRunAfter,
cause: new Error("Downloaded macOS update path is unavailable."),
}),
);
}
return installUnsignedMacUpdate(downloadedUpdatePath).pipe(
Effect.mapError(
(cause) =>
new ElectronUpdaterQuitAndInstallError({
channel,
isSilent,
isForceRunAfter,
cause,
}),
),
);
}
return Effect.try({
try: () => updater.quitAndInstall(isSilent, isForceRunAfter),
catch: (cause) =>
new ElectronUpdaterQuitAndInstallError({
channel,
isSilent,
isForceRunAfter,
cause,
}),
});
}),
() =>
on: (eventName, listener) => {
const eventTarget = updater as unknown as {
on: (eventName: string, listener: (...args: Array<unknown>) => void) => void;
removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => void;
};
const untypedListener = listener as unknown as (...args: Array<unknown>) => void;
return Effect.acquireRelease(
Effect.sync(() => {
eventTarget.removeListener(eventName, untypedListener);
eventTarget.on(eventName, untypedListener);
}),
).pipe(Effect.asVoid);
},
() =>
Effect.sync(() => {
eventTarget.removeListener(eventName, untypedListener);
}),
).pipe(Effect.asVoid);
},
});
});

export const layer = Layer.succeed(ElectronUpdater, make);
export const layer = Layer.effect(ElectronUpdater, make);
Loading
Loading