| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import { beforeEach, vi } from "vite-plus/test"; |
| 7 | |
| 8 | const { |
| 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 | |
| 30 | vi.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 | |
| 47 | vi.mock("node:fs", () => ({ |
| 48 | readFileSync: () => "{}", |
| 49 | mkdirSync: mkdirSyncMock, |
| 50 | writeFileSync: writeFileSyncMock, |
| 51 | copyFileSync: copyFileSyncMock, |
| 52 | })); |
| 53 | |
| 54 | vi.mock("node:child_process", () => ({ |
| 55 | execFileSync: execFileSyncMock, |
| 56 | })); |
| 57 | |
| 58 | import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; |
| 59 | |
| 60 | describe("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 | }); |