1import { assert, describe, it } from "@effect/vitest";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { beforeEach, vi } from "vite-plus/test";
7
8const {
9 appendSwitchMock,
10 getSwitchValueMock,
11 hasSwitchMock,
12 registerSchemesMock,
13 setDesktopNameMock,
14 mkdirSyncMock,
15 writeFileSyncMock,
16 copyFileSyncMock,
17 execFileSyncMock,
18} = vi.hoisted(() => ({
19 appendSwitchMock: vi.fn(),
20 getSwitchValueMock: vi.fn(),
21 hasSwitchMock: vi.fn(),
22 registerSchemesMock: vi.fn(),
23 setDesktopNameMock: vi.fn(),
24 mkdirSyncMock: vi.fn(),
25 writeFileSyncMock: vi.fn(),
26 copyFileSyncMock: vi.fn(),
27 execFileSyncMock: vi.fn(),
28}));
29
30vi.mock("electron", () => ({
31 app: {
32 setDesktopName: setDesktopNameMock,
33 getVersion: () => "0.0.37",
34 isPackaged: true,
35 getAppPath: () => "/tmp/.mount_T3/resources/app.asar",
36 commandLine: {
37 appendSwitch: appendSwitchMock,
38 getSwitchValue: getSwitchValueMock,
39 hasSwitch: hasSwitchMock,
40 },
41 },
42 protocol: {
43 registerSchemesAsPrivileged: registerSchemesMock,
44 },
45}));
46
47vi.mock("node:fs", () => ({
48 readFileSync: () => "{}",
49 mkdirSync: mkdirSyncMock,
50 writeFileSync: writeFileSyncMock,
51 copyFileSync: copyFileSyncMock,
52}));
53
54vi.mock("node:child_process", () => ({
55 execFileSync: execFileSyncMock,
56}));
57
58import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
59
60describe("DesktopPreReadyPlatform", () => {
61 beforeEach(() => {
62 appendSwitchMock.mockReset();
63 getSwitchValueMock.mockReset();
64 hasSwitchMock.mockReset();
65 registerSchemesMock.mockReset();
66 setDesktopNameMock.mockReset();
67 mkdirSyncMock.mockReset();
68 writeFileSyncMock.mockReset();
69 copyFileSyncMock.mockReset();
70 execFileSyncMock.mockReset();
71 });
72
73 it.effect("preserves an explicit Linux password-store switch", () => {
74 hasSwitchMock.mockImplementation((switchName) => switchName === "password-store");
75 getSwitchValueMock.mockReturnValue(" basic ");
76
77 return Effect.gen(function* () {
78 const options = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions;
79
80 assert.equal(options.linuxPasswordStoreCommandLine, "basic");
81 assert.isFalse(appendSwitchMock.mock.calls.some(([name]) => name === "password-store"));
82 }).pipe(
83 Effect.provide(
84 DesktopPreReadyPlatform.layer.pipe(
85 Layer.provide(Layer.succeed(HostProcess.Platform, "linux")),
86 ),
87 ),
88 );
89 });
90
91 it.effect.each([
92 {
93 label: "merges the color-manager fallback into disabled features on Hyprland 0.48.1",
94 switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" },
95 hyprlandVersion: "0.48.1",
96 hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc",
97 expected: [["disable-features", "Vulkan,WaylandWpColorManagerV1"]],
98 },
99 {
100 label: "preserves color management on Hyprland 0.51.0",
101 switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" },
102 hyprlandVersion: "0.51.0",
103 hyprlandCommit: "0123456789abcdef0123456789abcdef01234567",
104 expected: [],
105 },
106 {
107 label: "preserves color management when Electron resolved X11",
108 switches: { "ozone-platform": "x11", "disable-features": "Vulkan" },
109 hyprlandVersion: "0.48.1",
110 hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc",
111 expected: [],
112 },
113 ])("$label", ({ switches, hyprlandVersion, hyprlandCommit, expected }) => {
114 vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland");
115 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
116 const commandLine = new Map<string, string>(Object.entries(switches));
117 hasSwitchMock.mockImplementation((switchName: string) => commandLine.has(switchName));
118 getSwitchValueMock.mockImplementation(
119 (switchName: string) => commandLine.get(switchName) ?? "",
120 );
121 execFileSyncMock.mockReturnValue(
122 JSON.stringify({
123 commit: hyprlandCommit,
124 version: hyprlandVersion,
125 dirty: false,
126 tag: `v${hyprlandVersion}`,
127 }),
128 );
129
130 return DesktopPreReadyPlatform.make.pipe(
131 Effect.provideService(HostProcess.Platform, "linux"),
132 Effect.map(() => {
133 const featureSwitches = appendSwitchMock.mock.calls.filter(([name]) =>
134 ["disable-features", "enable-features", "ozone-platform"].includes(name),
135 );
136 assert.deepEqual(featureSwitches, expected);
137 }),
138 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
139 );
140 });
141
142 it.effect.each([
143 { previousEntry: undefined, label: "missing" },
144 { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" },
145 ])("prepares a $label Linux desktop entry before startup yields", ({ previousEntry }) => {
146 vi.stubEnv("VITE_DEV_SERVER_URL", "");
147 vi.stubEnv("XDG_DATA_HOME", "/xdg");
148 vi.stubEnv("APPIMAGE", "/Applications/current.AppImage");
149 getSwitchValueMock.mockReturnValue("");
150 let desktopName = "t3code.desktop";
151 let desktopEntry = previousEntry;
152 let iconInstalled = false;
153 copyFileSyncMock.mockImplementation((_source: string, destination: string) => {
154 iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png";
155 });
156 setDesktopNameMock.mockImplementation((name: string) => {
157 desktopName = name;
158 });
159 writeFileSyncMock.mockImplementation((path: string, contents: string) => {
160 if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents;
161 });
162
163 return Effect.scoped(
164 Effect.gen(function* () {
165 const portalIdentity = Promise.resolve().then(() => ({
166 desktopName,
167 desktopEntry,
168 iconInstalled,
169 }));
170 yield* Layer.build(
171 DesktopPreReadyPlatform.layer.pipe(
172 Layer.provide(Layer.succeed(HostProcess.Platform, "linux")),
173 ),
174 );
175 const identity = yield* Effect.promise(() => portalIdentity);
176 assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop");
177 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');
178 assert.include(identity.desktopEntry ?? "", "Name=The Knife");
179 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");
180 assert.include(
181 identity.desktopEntry ?? "",
182 "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png",
183 );
184 assert.isTrue(identity.iconInstalled);
185 }),
186 ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
187 });
188
189 it.effect("keeps startup available when the early desktop entry cannot be written", () => {
190 getSwitchValueMock.mockReturnValue("");
191 mkdirSyncMock.mockImplementation(() => {
192 throw new Error("read-only filesystem");
193 });
194
195 return DesktopPreReadyPlatform.make.pipe(
196 Effect.provideService(HostProcess.Platform, "linux"),
197 Effect.asVoid,
198 );
199 });
200
201 it.effect("still prepares the portal entry when the bundled icon cannot be copied", () => {
202 getSwitchValueMock.mockReturnValue("");
203 copyFileSyncMock.mockImplementation(() => {
204 throw new Error("missing bundled icon");
205 });
206 return Effect.gen(function* () {
207 yield* DesktopPreReadyPlatform.make;
208 const contents = writeFileSyncMock.mock.calls[0]?.[1];
209 assert.include(contents, "MimeType=x-scheme-handler/t3code;");
210 assert.include(contents, "Icon=");
211 assert.equal(setDesktopNameMock.mock.calls.length, 1);
212 }).pipe(Effect.provideService(HostProcess.Platform, "linux"));
213 });
214
215 it.effect(
216 "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer",
217 () =>
218 Effect.gen(function* () {
219 class ClerkShaped extends Context.Service<ClerkShaped, { readonly ready: true }>()(
220 "@t3tools/desktop/app/DesktopPreReadyPlatform.test/ClerkShaped",
221 ) {}
222
223 const events: Array<string> = [];
224 registerSchemesMock.mockImplementation(() => {
225 events.push("pre-ready");
226 });
227
228 const layerPreReady = DesktopPreReadyPlatform.layer.pipe(
229 Layer.provide(Layer.succeed(HostProcess.Platform, "darwin")),
230 );
231
232 const layerClerkShaped = Layer.effect(
233 ClerkShaped,
234 Effect.promise(() => Promise.resolve()).pipe(
235 Effect.map(() => {
236 events.push("clerk");
237 return { ready: true as const };
238 }),
239 ),
240 );
241
242 const layerRuntime = layerClerkShaped.pipe(
243 Layer.flatMap((clerkContext) => Layer.succeedContext(clerkContext)),
244 Layer.provideMerge(layerPreReady),
245 );
246
247 const result = yield* Effect.all({
248 clerk: ClerkShaped,
249 preReady: DesktopPreReadyPlatform.DesktopPreReadyElectronOptions,
250 }).pipe(Effect.provide(layerRuntime));
251
252 assert.deepEqual(result, {
253 clerk: { ready: true },
254 preReady: {
255 linux: null,
256 linuxPasswordStoreCommandLine: null,
257 },
258 });
259 assert.deepEqual(events, ["pre-ready", "clerk"]);
260 assert.equal(registerSchemesMock.mock.calls.length, 1);
261 assert.equal(appendSwitchMock.mock.calls.length, 0);
262 assert.equal(setDesktopNameMock.mock.calls.length, 0);
263 }),
264 );
265});