Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"effect": "catalog:",
"electron": "43.4.1",
"electron": "44.1.1",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"playwright-core": "1.60.0",
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,29 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("copies text to the system clipboard", () =>
Effect.gen(function* () {
writeTextMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const result = yield* electronShell.copyText("https://example.com/path");

assert.equal(result, true);
assert.deepEqual(writeTextMock.mock.calls, [["https://example.com/path"]]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("reports a refused clipboard write instead of failing", () =>
Effect.gen(function* () {
writeTextMock.mockRejectedValue(new Error("clipboard unavailable"));

const electronShell = yield* ElectronShell.ElectronShell;
const result = yield* electronShell.copyText("https://example.com/path");

assert.equal(result, false);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens remote SSH editor URLs", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export class ElectronShell extends Context.Service<
ElectronShell,
{
readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
readonly copyText: (text: string) => Effect.Effect<void>;
/** Writes to the system clipboard. Resolves false when the platform refuses the write. */
readonly copyText: (text: string) => Effect.Effect<boolean>;
}
>()("@t3tools/desktop/electron/ElectronShell") {}

Expand All @@ -60,9 +61,12 @@ export const make = ElectronShell.of({
),
}),
copyText: (text) =>
Effect.sync(() => {
Electron.clipboard.writeText(text);
}),
Effect.promise(() =>
Electron.clipboard.writeText(text).then(
() => true,
() => false,
),
),
});

export const layer = Layer.succeed(ElectronShell, make);
44 changes: 37 additions & 7 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,30 +113,36 @@ describe("previewWindowOpenAction", () => {

const {
browserWindowConstructor,
clipboardItemConstructor,
createFromPath,
fromId,
getFocusedWebContents,
mkdir,
showItemInFolder,
webviewSend,
writeFile,
writeImage,
writeClipboard,
} = vi.hoisted(() => ({
browserWindowConstructor: vi.fn(),
createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })),
clipboardItemConstructor: vi.fn<(data: Record<string, unknown>) => void>(),
createFromPath: vi.fn(() => ({
isEmpty: (): boolean => false,
toPNG: () => Buffer.from("png"),
})),
fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null),
getFocusedWebContents: vi.fn(() => null),
mkdir: vi.fn((_path: string) => undefined),
showItemInFolder: vi.fn(),
webviewSend: vi.fn(),
writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),
writeImage: vi.fn(),
writeClipboard: vi.fn(async () => undefined),
}));

vi.mock("electron", () => ({
BrowserWindow: browserWindowConstructor,
ClipboardItem: clipboardItemConstructor,
clipboard: {
writeImage,
write: writeClipboard,
},
nativeImage: {
createFromPath,
Expand Down Expand Up @@ -469,7 +475,8 @@ describe("PreviewManager", () => {
mkdir.mockClear();
writeFile.mockClear();
showItemInFolder.mockClear();
writeImage.mockClear();
clipboardItemConstructor.mockClear();
writeClipboard.mockClear();
createFromPath.mockClear();
webviewSend.mockClear();
});
Expand Down Expand Up @@ -3232,7 +3239,15 @@ describe("PreviewManager", () => {
yield* manager.copyArtifactToClipboard(artifactPath);

expect(createFromPath).toHaveBeenCalledWith(artifactPath);
expect(writeImage).toHaveBeenCalledOnce();
const pngBlob = clipboardItemConstructor.mock.calls[0]?.[0]["image/png"];
if (!(pngBlob instanceof Blob)) {
throw new Error("expected a PNG Blob in the ClipboardItem");
}
expect(pngBlob.type).toBe("image/png");
expect(Buffer.from(yield* Effect.promise(() => pngBlob.arrayBuffer())).toString()).toBe(
"png",
);
expect(writeClipboard).toHaveBeenCalledOnce();
const exit = yield* Effect.exit(
manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"),
);
Expand All @@ -3246,14 +3261,29 @@ describe("PreviewManager", () => {
});
expect("cause" in error).toBe(false);

createFromPath.mockReturnValueOnce({ isEmpty: () => true });
createFromPath.mockReturnValueOnce({
isEmpty: () => true,
toPNG: () => Buffer.from("invalid"),
});
const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath));
expect(Exit.isFailure(invalidImageExit)).toBe(true);
if (Exit.isSuccess(invalidImageExit)) return;
expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({
_tag: "PreviewArtifactImageLoadError",
artifactPath,
});

const writeCause = new Error("clipboard write failed");
writeClipboard.mockRejectedValueOnce(writeCause);
const writeExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath));
expect(Exit.isFailure(writeExit)).toBe(true);
if (Exit.isSuccess(writeExit)) return;
expect(Option.getOrThrow(Cause.findErrorOption(writeExit.cause))).toMatchObject({
_tag: "PreviewOperationError",
operation: "copyArtifactToClipboard.write",
artifactPath,
cause: writeCause,
});
}),
),
);
Expand Down
20 changes: 17 additions & 3 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,15 @@ import type {
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { normalizePreviewUrl } from "@t3tools/shared/preview";
import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron";
import {
BrowserWindow,
ClipboardItem,
type Session,
clipboard,
nativeImage,
shell,
webContents,
} from "electron";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -4031,8 +4039,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
if (image.isEmpty()) {
return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath });
}
yield* attempt({ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () =>
clipboard.writeImage(image),
yield* attemptPromise(
{ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath },
() =>
clipboard.write([
new ClipboardItem({
"image/png": new Blob([Uint8Array.from(image.toPNG())], { type: "image/png" }),
}),
]),
);
});

Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ function makeTestLayer(input: {
input.openedExternalUrls?.push(url);
return true;
}),
copyText: () => Effect.void,
copyText: () => Effect.succeed(true),
} satisfies ElectronShell.ElectronShell["Service"]),
electronThemeLayer,
electronWindowLayer,
Expand Down Expand Up @@ -380,7 +380,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: () => Effect.succeed(true),
copyText: () => Effect.void,
copyText: () => Effect.succeed(true),
} satisfies ElectronShell.ElectronShell["Service"]),
electronThemeLayer,
Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape),
Expand Down
1 change: 1 addition & 0 deletions docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ available.
- macOS metadata note:
- `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon.
- The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release.
- The desktop artifact build stamps `minimumSystemVersion: '22.0.0'` (Darwin 22, macOS 13 Ventura) into every mac manifest. `electron-updater` compares that field against `os.release()`, which reports the Darwin kernel version rather than the macOS product version, and skips the update on older systems instead of installing an app that cannot launch there.

### Windows payload topology and update validation

Expand Down
2 changes: 2 additions & 0 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Node.js `^22.16 || ^23.11 || >=24.10` on the machine that runs the T3 Code serve

At least one provider CLI, installed and authenticated. See [Providers](#providers) below.

The desktop app needs macOS 13 (Ventura) or newer on Mac.

## Run Without Installing

```bash
Expand Down
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
createStageWorkspaceConfig,
createStagePatchedDependencies,
createBuildConfig,
isMacUpdateManifestName,
stampMacUpdateManifest,
DESKTOP_ELECTRON_LANGUAGES,
DESKTOP_FILE_EXCLUSIONS,
DESKTOP_EXTRA_RESOURCES,
Expand Down Expand Up @@ -563,6 +565,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
]);
});

it("stamps the macOS floor into updater manifests", () => {
const stamped = stampMacUpdateManifest(
`version: 1.2.3
files:
- url: T3-Code-1.2.3-arm64.zip
sha512: zipsha
size: 1
- url: T3-Code-1.2.3-arm64.dmg
sha512: dmgsha
size: 2
path: T3-Code-1.2.3-arm64.zip
sha512: zipsha
releaseDate: '2026-09-03T10:00:00.000Z'
`,
"latest-mac.yml",
);

assert.equal(
stamped,
`version: '1.2.3'
files:
- url: T3-Code-1.2.3-arm64.zip
sha512: zipsha
size: 1
- url: T3-Code-1.2.3-arm64.dmg
sha512: dmgsha
size: 2
minimumSystemVersion: '22.0.0'
releaseDate: '2026-09-03T10:00:00.000Z'
`,
);
assert.isTrue(isMacUpdateManifestName("latest-mac.yml"));
assert.isTrue(isMacUpdateManifestName("nightly-mac.yml"));
assert.isFalse(isMacUpdateManifestName("builder-debug.yml"));
assert.isFalse(isMacUpdateManifestName("latest.yml"));
});

it.effect("applies platform-specific packaging to the build config", () =>
Effect.gen(function* () {
const mac = yield* createBuildConfig(
Expand Down Expand Up @@ -610,6 +649,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
assert.notProperty(mac, "asarUnpack");
assert.notProperty(linux, "asarUnpack");
assert.notProperty(win, "asarUnpack");
assert.nestedPropertyVal(mac, "mac.minimumSystemVersion", "13.0");
assert.deepStrictEqual(win.extraResources, [
{
from: "apps/desktop/prod-resources/resource-monitor",
Expand Down
Loading
Loading