1import * as NodePath from "@effect/platform-node/NodePath";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as PlatformError from "effect/PlatformError";
9
10import type * as Electron from "electron";
11
12import * as ElectronApp from "../electron/ElectronApp.ts";
13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14import * as DesktopAssets from "./DesktopAssets.ts";
15import * as DesktopConfig from "./DesktopConfig.ts";
16import * as DesktopEnvironment from "./DesktopEnvironment.ts";
17import * as DesktopUserData from "./DesktopUserData.ts";
18
19const defaultEnvironmentInput = {
20 dirname: "/repo/apps/desktop/dist-electron",
21 homeDirectory: "/Users/alice",
22 platform: "darwin",
23 processArch: "arm64",
24 appVersion: "1.2.3",
25 appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar",
26 isPackaged: true,
27 resourcesPath: "/Applications/T3 Code.app/Contents/Resources",
28 runningUnderArm64Translation: false,
29} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
30
31type TestEnvironmentInput = Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> & {
32 readonly env?: Record<string, string | undefined>;
33};
34
35interface ElectronAppCalls {
36 readonly setAboutPanelOptions: Array<Electron.AboutPanelOptionsOptions>;
37 readonly setDockIcon: string[];
38 readonly setName: string[];
39}
40
41const layerElectronApp = (calls: ElectronAppCalls) =>
42 Layer.succeed(ElectronApp.ElectronApp, {
43 metadata: Effect.die("unexpected metadata read"),
44 name: Effect.succeed("T3 Code"),
45 systemLocale: Effect.succeed("en-US"),
46 whenReady: Effect.void,
47 quit: Effect.void,
48 exit: () => Effect.void,
49 relaunch: () => Effect.void,
50 setPath: () => Effect.void,
51 setName: (name) =>
52 Effect.sync(() => {
53 calls.setName.push(name);
54 }),
55 setAboutPanelOptions: (options) =>
56 Effect.sync(() => {
57 calls.setAboutPanelOptions.push(options);
58 }),
59 setAppUserModelId: () => Effect.void,
60 getAppMetrics: Effect.succeed([]),
61 setAsDefaultProtocolClient: () => Effect.succeed(true),
62 setDesktopName: () => Effect.void,
63 setDockIcon: (iconPath) =>
64 Effect.sync(() => {
65 calls.setDockIcon.push(iconPath);
66 }),
67 appendCommandLineSwitch: () => Effect.void,
68 onBeforeQuitForUpdate: () => Effect.void,
69 removeCommandLineSwitch: () => Effect.void,
70 on: () => Effect.void,
71 } satisfies ElectronApp.ElectronApp["Service"]);
72
73const layerAssets = (png: Option.Option<string>) =>
74 Layer.succeed(DesktopAssets.DesktopAssets, {
75 iconPaths: Effect.succeed({
76 ico: Option.none(),
77 icns: Option.none(),
78 png,
79 }),
80 resolveResourcePath: () => Effect.succeedNone,
81 } satisfies DesktopAssets.DesktopAssets["Service"]);
82
83const layerEnvironment = (overrides: TestEnvironmentInput = {}) => {
84 const { env, ...environmentOverrides } = overrides;
85 return DesktopEnvironment.layer({
86 ...defaultEnvironmentInput,
87 ...environmentOverrides,
88 }).pipe(
89 Layer.provide(
90 Layer.mergeAll(
91 NodeServices.layer,
92 NodePath.layerPosix,
93 DesktopConfig.layerTest({
94 ...env,
95 }),
96 ),
97 ),
98 );
99};
100
101const withIdentity = <A, E, R>(
102 effect: Effect.Effect<
103 A,
104 E,
105 | R
106 | DesktopAppIdentity.DesktopAppIdentity
107 | DesktopEnvironment.DesktopEnvironment
108 | FileSystem.FileSystem
109 >,
110 input: {
111 readonly calls?: ElectronAppCalls;
112 readonly environment?: TestEnvironmentInput;
113 readonly legacyPathExists?: boolean;
114 readonly legacyPathProbeError?: PlatformError.PlatformError;
115 readonly packageJson?: string;
116 readonly pngIconPath?: Option.Option<string>;
117 } = {},
118) => {
119 const calls: ElectronAppCalls = input.calls ?? {
120 setAboutPanelOptions: [],
121 setDockIcon: [],
122 setName: [],
123 };
124
125 return effect.pipe(
126 Effect.provide(
127 DesktopAppIdentity.layer.pipe(
128 Layer.provide(NodePath.layerPosix),
129 Layer.provideMerge(
130 FileSystem.layerNoop({
131 exists: (path) =>
132 input.legacyPathProbeError
133 ? Effect.fail(input.legacyPathProbeError)
134 : Effect.succeed(
135 input.legacyPathExists === true && /T3 Code \((Alpha|Dev)\)/.test(path),
136 ),
137 readFileString: () =>
138 Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'),
139 }),
140 ),
141 Layer.provideMerge(layerAssets(input.pngIconPath ?? Option.none())),
142 Layer.provideMerge(layerElectronApp(calls)),
143 Layer.provideMerge(layerEnvironment(input.environment)),
144 ),
145 ),
146 );
147};
148
149describe("DesktopAppIdentity", () => {
150 it.effect("isolates the V2 profile even when the legacy V1 profile exists", () =>
151 withIdentity(
152 Effect.gen(function* () {
153 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
154 const userDataPath = yield* identity.resolveUserDataPath;
155
156 assert.equal(userDataPath, "/Users/alice/Library/Application Support/t3code-v2");
157 }),
158 { legacyPathExists: true },
159 ),
160 );
161
162 it.effect("keeps using the legacy development profile", () =>
163 withIdentity(
164 Effect.gen(function* () {
165 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
166 assert.equal(
167 yield* identity.resolveUserDataPath,
168 "/Users/alice/Library/Application Support/T3 Code (Dev)",
169 );
170 }),
171 {
172 legacyPathExists: true,
173 environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
174 },
175 ),
176 );
177
178 it.effect("preserves failures while inspecting the legacy userData path", () => {
179 const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Dev)";
180 const cause = PlatformError.systemError({
181 _tag: "PermissionDenied",
182 module: "FileSystem",
183 method: "exists",
184 description: "permission denied",
185 pathOrDescriptor: legacyPath,
186 });
187
188 return withIdentity(
189 Effect.gen(function* () {
190 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
191 const error = yield* identity.resolveUserDataPath.pipe(Effect.flip);
192
193 assert.instanceOf(error, DesktopUserData.DesktopUserDataInitializationError);
194 assert.equal(error.resourcePath, legacyPath);
195 assert.strictEqual(error.cause, cause);
196 assert.equal(
197 error.message,
198 `Could not initialize Electron user data during inspect at ${legacyPath} (PermissionDenied).`,
199 );
200 }),
201 {
202 legacyPathProbeError: cause,
203 environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
204 },
205 );
206 });
207
208 it.effect("configures app identity from the environment commit override", () => {
209 const calls: ElectronAppCalls = {
210 setAboutPanelOptions: [],
211 setDockIcon: [],
212 setName: [],
213 };
214
215 return withIdentity(
216 Effect.gen(function* () {
217 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
218 yield* identity.configure;
219
220 assert.deepEqual(calls.setName, ["T3 Code Alpha"]);
221 assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "The Knife");
222 assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
223 assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
224 // Packaged: the bundle's own icon stands, so a custom one the user
225 // attached survives.
226 assert.deepEqual(calls.setDockIcon, []);
227 }),
228 {
229 calls,
230 environment: {
231 env: {
232 T3CODE_COMMIT_HASH: "0123456789abcdef",
233 },
234 },
235 pngIconPath: Option.some("/icon.png"),
236 },
237 );
238 });
239
240 it.effect.each([
241 { stage: "Alpha", environment: {} },
242 {
243 stage: "Nightly",
244 environment: { appVersion: "0.0.43-nightly.20260929.2428" },
245 },
246 {
247 stage: "Dev",
248 environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
249 },
250 ])(
251 "preserves the credential identity and valid native User-Agent for $stage",
252 ({ stage, environment }) => {
253 const calls: ElectronAppCalls = {
254 setAboutPanelOptions: [],
255 setDockIcon: [],
256 setName: [],
257 };
258
259 return withIdentity(
260 Effect.gen(function* () {
261 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
262 yield* identity.configure;
263
264 const runtimeName = calls.setName[0];
265 assert.isDefined(runtimeName);
266 assert.equal(runtimeName, `T3 Code ${stage}`);
267 // RFC 9110's token grammar, after Electron removes ASCII spaces.
268 assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/);
269 assert.equal(
270 calls.setAboutPanelOptions[0]?.applicationName,
271 stage === "Alpha" ? "The Knife" : `The Knife (${stage})`,
272 );
273 }),
274 { calls, environment },
275 );
276 },
277 );
278
279 it.effect("sets the dock icon only when running unpackaged", () => {
280 const calls: ElectronAppCalls = {
281 setAboutPanelOptions: [],
282 setDockIcon: [],
283 setName: [],
284 };
285
286 return withIdentity(
287 Effect.gen(function* () {
288 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
289 yield* identity.configure;
290
291 // Electron shows a generic icon for an unpackaged run, which is the
292 // reason this call exists at all.
293 assert.deepEqual(calls.setDockIcon, ["/icon.png"]);
294 }),
295 {
296 calls,
297 environment: { isPackaged: false },
298 pngIconPath: Option.some("/icon.png"),
299 },
300 );
301 });
302});