Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
32 changes: 30 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,42 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build unsigned macOS packages
run: pnpm package:mac:unsigned
- name: Restore notarization API key
shell: bash
env:
T4_MACOS_NOTARIZATION_KEY_BASE64: ${{ secrets.T4_MACOS_NOTARIZATION_KEY_BASE64 }}
run: |
set -euo pipefail
if [[ -z "${T4_MACOS_NOTARIZATION_KEY_BASE64:-}" ]]; then
echo "required GitHub Actions secret T4_MACOS_NOTARIZATION_KEY_BASE64 is not configured" >&2
exit 1
fi
key_path="$RUNNER_TEMP/AuthKey.p8"
printf '%s' "$T4_MACOS_NOTARIZATION_KEY_BASE64" | base64 --decode > "$key_path"
Comment thread
wolfiesch marked this conversation as resolved.
Outdated
chmod 600 "$key_path"

- name: Build signed and notarized macOS packages
env:
CSC_LINK: ${{ secrets.T4_MACOS_CERTIFICATE_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.T4_MACOS_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8
APPLE_API_KEY_ID: ${{ secrets.T4_MACOS_NOTARIZATION_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.T4_MACOS_NOTARIZATION_ISSUER_ID }}
run: pnpm package:mac

- name: Inspect macOS packages
run: |
pnpm inspect:package -- release/*.zip
pnpm inspect:dmg -- release/*.dmg

- name: Verify Developer ID signature and notarization
env:
VERSION: ${{ needs.verify.outputs.version }}
run: >-
node scripts/verify-macos-signature.mjs
"release/T4-Code-${VERSION}-mac-arm64.zip"
"release/T4-Code-${VERSION}-mac-arm64.dmg"

- name: Stage macOS artifacts
shell: bash
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ target/
dist/
dist-electron/
/release/
/.artifacts/
.vite-plus/
*.tsbuildinfo
/.worktrees/
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
67 changes: 67 additions & 0 deletions apps/desktop/src/bundled-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createHash, randomUUID } from "node:crypto";
import { chmod, copyFile, mkdir, readFile, rename, stat, unlink } from "node:fs/promises";
import { join } from "node:path";

export interface BundledRuntimeManifest {
readonly version: 1;
readonly tag: string;
readonly platform: "darwin";
readonly arch: "arm64";
readonly executable: "omp";
readonly size: number;
readonly sha256: string;
}

function decodeManifest(value: unknown): BundledRuntimeManifest {
const record = value as Partial<BundledRuntimeManifest> | null;
if (
record?.version !== 1 ||
record.platform !== "darwin" ||
record.arch !== "arm64" ||
record.executable !== "omp" ||
typeof record.tag !== "string" ||
!/^t4code-[0-9]+\.[0-9]+\.[0-9]+-appserver-[1-9][0-9]*$/u.test(record.tag) ||
!Number.isSafeInteger(record.size) ||
(record.size ?? 0) < 1 ||
typeof record.sha256 !== "string" ||
!/^[0-9a-f]{64}$/u.test(record.sha256)
) throw new Error("bundled OMP runtime manifest is invalid");
return record as BundledRuntimeManifest;
}

async function matches(path: string, manifest: BundledRuntimeManifest): Promise<boolean> {
try {
if ((await stat(path)).size !== manifest.size) return false;
const hash = createHash("sha256").update(await readFile(path)).digest("hex");
return hash === manifest.sha256;
} catch {
return false;
}
}

export async function installBundledOmpRuntime(options: {
readonly resourcesPath: string;
readonly applicationSupportPath: string;
}): Promise<string> {
const sourceRoot = join(options.resourcesPath, "runtime");
const manifest = decodeManifest(JSON.parse(await readFile(join(sourceRoot, "manifest.json"), "utf8")));
const source = join(sourceRoot, manifest.executable);
if (!(await matches(source, manifest))) throw new Error("bundled OMP runtime failed its integrity check");
const destinationRoot = join(options.applicationSupportPath, "runtime", manifest.tag);
const destination = join(destinationRoot, "omp");
if (await matches(destination, manifest)) {
await chmod(destination, 0o755);
return destination;
}
await mkdir(destinationRoot, { recursive: true, mode: 0o700 });
const temporary = join(destinationRoot, `.omp-${randomUUID()}.partial`);
try {
await copyFile(source, temporary);
await chmod(temporary, 0o755);
if (!(await matches(temporary, manifest))) throw new Error("installed OMP runtime failed its integrity check");
await rename(temporary, destination);
} finally {
await unlink(temporary).catch(() => {});
}
return destination;
}
22 changes: 22 additions & 0 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
decodeDesktopUpdateState,
decodeProjectionCacheLoadResult,
decodeProjectionCacheSaveResult,
decodePhoneSetupState,
type BootstrapResult,
type CommandRequest,
type CommandResult,
Expand All @@ -30,6 +31,7 @@ import {
type PairLinkEvent,
type PairRequest,
type PairResult,
type PhoneSetupState,
type PairLinksDrainResult,
type ProjectionCacheLoadResult,
type ProjectionCacheSaveRequest,
Expand Down Expand Up @@ -81,6 +83,10 @@ export interface IpcRuntime {
readonly load: () => ProjectionCacheLoadResult | Promise<ProjectionCacheLoadResult>;
readonly save: (value: string) => ProjectionCacheSaveResult | Promise<ProjectionCacheSaveResult>;
};
readonly phoneSetup?: {
readonly inspect: () => Promise<PhoneSetupState>;
readonly configure: () => Promise<PhoneSetupState>;
};
}
export class RemotePairingUnavailableError extends Error {
readonly code = "remote_pairing_unavailable" as const;
Expand Down Expand Up @@ -258,6 +264,16 @@ export class DesktopIpcRegistry {
decodeRequest("app:update:get-state", payload);
return decodeDesktopUpdateState(this.updateController().getState());
});
this.ipc.handle("app:phone-setup:inspect", async (event, payload: unknown): Promise<PhoneSetupState> => {
this.assertSender(event);
decodeRequest("app:phone-setup:inspect", payload);
return decodePhoneSetupState(await this.phoneSetup().inspect());
});
this.ipc.handle("app:phone-setup:configure", async (event, payload: unknown): Promise<PhoneSetupState> => {
this.assertSender(event);
decodeRequest("app:phone-setup:configure", payload);
return decodePhoneSetupState(await this.phoneSetup().configure());
});
this.ipc.handle("app:update:check", async (event, payload: unknown): Promise<DesktopUpdateState> => {
this.assertSender(event);
decodeRequest("app:update:check", payload);
Expand Down Expand Up @@ -296,6 +312,11 @@ export class DesktopIpcRegistry {
this.emit("app:update:state", state);
});
}

private phoneSetup(): NonNullable<IpcRuntime["phoneSetup"]> {
if (!this.runtime.phoneSetup) throw new Error("Phone setup is unavailable in this build.");
return this.runtime.phoneSetup;
}
uninstall(): void {
this.updateUnsubscribe?.();
this.updateUnsubscribe = undefined;
Expand All @@ -311,6 +332,7 @@ export class DesktopIpcRegistry {
"app:update:get-state", "app:update:check", "app:update:download", "app:update:restart",
"app:update:renderer-ready",
"app:projection-cache:load", "app:projection-cache:save",
"app:phone-setup:inspect", "app:phone-setup:configure",
] as const) this.ipc.removeHandler(channel);
this.installed = false;
}
Expand Down
25 changes: 22 additions & 3 deletions apps/desktop/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { installApplicationMenu, type ApplicationMenuOptions } from "./menu.ts";
import { DesktopUpdateController } from "./update-controller.ts";
import { LocalProfileRegistry } from "./local-profiles.ts";
import { LocalProfileRuntime } from "./profile-runtime.ts";
import { installBundledOmpRuntime } from "./bundled-runtime.ts";
import { PhoneSetupService } from "./phone-setup.ts";

type ProjectionCacheRuntime = NonNullable<IpcRuntime["projectionCache"]>;

Expand Down Expand Up @@ -101,6 +103,7 @@ export class DesktopLifecycle {
private readonly serviceRecoveryPromises = new Map<string, Promise<ServiceManager | undefined>>();
private readonly serviceAvailabilityIssues = new Map<string, ServiceAvailabilityIssue>();
private updateController: DesktopUpdateController | undefined;
private phoneSetup: PhoneSetupService | undefined;
private pendingUpdateOpen = false;
private rendererLoaded = false;
private updateRendererReady = false;
Expand All @@ -127,7 +130,15 @@ export class DesktopLifecycle {
() => new LocalProfileRegistry(new ElectronLocalProfileStore())
);
this.projectionCacheFactory = options.createProjectionCache ?? (() => new ElectronProjectionCacheStore());
this.executableFactory = options.discoverExecutable ?? (() => discoverOmpExecutable());
this.executableFactory = options.discoverExecutable ?? (async () => {
if (this.electronApp.isPackaged && process.platform === "darwin" && process.arch === "arm64") {
return installBundledOmpRuntime({
resourcesPath: process.resourcesPath,
applicationSupportPath: this.electronApp.getPath("userData"),
});
}
return discoverOmpExecutable();
});
this.appserverProbe = options.probeAppserver ?? ((executable) => probeOmpAppserver(executable));
this.serviceFactory = options.createServiceManager ?? createAppserverServiceManager;
this.targetManagerFactory = options.createTargetManager ?? ((managerOptions) => new LocalTargetManager(managerOptions));
Expand Down Expand Up @@ -174,13 +185,17 @@ export class DesktopLifecycle {
this.projectionCache = this.projectionCacheFactory();
if (process.platform === "darwin") this.electronApp.setAsDefaultProtocolClient("t4-code");
this.updateController = this.updateControllerFactory();
if (this.electronApp.isPackaged) {
this.phoneSetup = new PhoneSetupService({
resourcesPath: process.resourcesPath,
electronExecutable: process.execPath,
});
}
this.menuInstaller({ onOpenUpdates: () => this.openUpdatesFromMenu() });
const identity = this.identityFactory();
const remoteRegistry = this.remoteRegistryFactory();
const credentials = this.credentialsFactory();
this.localProfileRegistry = this.localProfileRegistryFactory();
await this.acquireServiceManager();
if (this.stopping) return;
this.manager = this.targetManagerFactory({
cursorStore: this.cursorStoreFactory(),
registry: remoteRegistry,
Expand All @@ -205,6 +220,8 @@ export class DesktopLifecycle {
discoverExecutable: () => this.discoverServiceExecutable(),
});
this.bindWindow(this.windowFactory());
await this.acquireServiceManager();
if (this.stopping) return;
await this.profileRuntime.startAutomaticProfiles((profileId, error) => {
this.ipc?.emitRuntimeError(runtimeError(error, `local:${profileId}`));
});
Expand Down Expand Up @@ -234,6 +251,7 @@ export class DesktopLifecycle {
this.mainWindow = undefined;
this.updateController?.dispose();
this.updateController = undefined;
this.phoneSetup = undefined;
this.projectionCache = undefined;
const manager = this.manager;
this.manager = undefined;
Expand Down Expand Up @@ -435,6 +453,7 @@ export class DesktopLifecycle {
drainPairLinks: () => this.pendingPairs.drain(),
drainPendingUpdateOpen: () => this.markUpdateRendererReady(),
...(this.updateController === undefined ? {} : { updateController: this.updateController }),
...(this.phoneSetup === undefined ? {} : { phoneSetup: this.phoneSetup }),
});
this.ipc.install();
handle.window.webContents.on("did-start-loading", () => {
Expand Down
Loading
Loading