1import { activeForkRuntime } from "../updates/forkRuntime.ts";
2// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings, prepares the Linux desktop entry, and asks Hyprland for its version synchronously before app services are available.
3import * as NodeChildProcess from "node:child_process";
4import * as NodeFS from "node:fs";
5import * as NodePath from "node:path";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9
10import * as Electron from "electron";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12
13import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts";
14import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts";
15import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts";
16import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
17
18export interface DesktopPreReadyCommandLineReader {
19 readonly hasSwitch: (switchName: string) => boolean;
20 readonly getSwitchValue: (switchName: string) => string;
21}
22
23function readCommandLineSwitchValue(
24 commandLine: DesktopPreReadyCommandLineReader,
25 switchName: string,
26): string | null {
27 if (!commandLine.hasSwitch(switchName)) {
28 return null;
29 }
30
31 const value = commandLine.getSwitchValue(switchName).trim();
32 return value.length > 0 ? value : null;
33}
34
35export const resolveEarlyLinuxElectronOptionsFromProcess = (
36 homeDirectory: string,
37): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions =>
38 DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({
39 env: process.env,
40 homeDirectory,
41 joinPath: NodePath.posix.join,
42 readFileString: (path) => NodeFS.readFileSync(path, "utf8"),
43 });
44
45export class DesktopPreReadyElectronOptions extends Context.Service<
46 DesktopPreReadyElectronOptions,
47 {
48 readonly linux: DesktopEarlyElectronStartup.EarlyLinuxElectronOptions | null;
49 readonly linuxPasswordStoreCommandLine: string | null;
50 }
51>()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {}
52
53/** @public Service construction is part of the canonical Effect module API. */
54export const make = Effect.gen(function* () {
55 const platform = yield* HostProcess.Platform;
56 const homeDirectory = yield* HostProcess.HomeDirectory;
57 return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => {
58 const linuxPasswordStoreCommandLine =
59 platform === "linux"
60 ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store")
61 : null;
62 const linux =
63 platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess(homeDirectory) : null;
64
65 if (linux !== null) {
66 // The portal also requires a valid desktop entry. An AppImage update may
67 // have removed the executable referenced by the previous launch's entry.
68 try {
69 const applicationsDir = NodePath.posix.join(
70 process.env.XDG_DATA_HOME?.trim() ||
71 NodePath.posix.join(homeDirectory, ".local", "share"),
72 "applications",
73 );
74 NodeFS.mkdirSync(applicationsDir, { recursive: true });
75 const iconPath = Electron.app.isPackaged
76 ? NodePath.posix.join(
77 applicationsDir,
78 "..",
79 "icons",
80 `${linux.linuxDesktopEntryName}.png`,
81 )
82 : undefined;
83 if (iconPath !== undefined) {
84 try {
85 NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true });
86 NodeFS.copyFileSync(
87 NodePath.posix.join(
88 activeForkRuntime()?.directory ?? Electron.app.getAppPath(),
89 "apps/desktop/prod-resources/icon.png",
90 ),
91 iconPath,
92 );
93 } catch {
94 // Icon installation is optional; registration retries after readiness.
95 }
96 }
97 NodeFS.writeFileSync(
98 NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName),
99 renderUrlHandlerDesktopEntry({
100 displayName: resolveDesktopAppBranding({
101 isDevelopment: linux.isDevelopment,
102 appVersion: activeForkRuntime()?.version ?? Electron.app.getVersion(),
103 }).displayName,
104 execTarget: process.env.APPIMAGE?.trim() || process.execPath,
105 scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment),
106 ...(iconPath === undefined ? {} : { iconPath }),
107 }),
108 "utf8",
109 );
110 } catch {
111 // The URL handler retries with the full environment and logs failures.
112 }
113 // Chromium caches its portal registration during startup. Set the identity
114 // before any asynchronous work can initialize it with Electron's default.
115 Electron.app.setDesktopName(linux.linuxDesktopEntryName);
116 Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass);
117 if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) {
118 Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore);
119 }
120 // Chromium keeps only the last --disable-features value, so pass the merged list.
121 const disableFeatures = DesktopEarlyElectronStartup.resolveLinuxDisableFeaturesSwitch({
122 env: process.env,
123 ozonePlatform: readCommandLineSwitchValue(Electron.app.commandLine, "ozone-platform"),
124 enableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "enable-features"),
125 disableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "disable-features"),
126 // Runs only on Hyprland Wayland sessions without an explicit feature override.
127 readHyprctlVersionJson: () =>
128 NodeChildProcess.execFileSync("hyprctl", ["version", "-j"], {
129 encoding: "utf8",
130 stdio: ["ignore", "pipe", "ignore"],
131 timeout: 1000,
132 maxBuffer: 64 * 1024,
133 }),
134 });
135 if (disableFeatures !== null) {
136 Electron.app.commandLine.appendSwitch("disable-features", disableFeatures);
137 }
138 }
139
140 return { linux, linuxPasswordStoreCommandLine };
141 });
142}).pipe(Effect.withSpan("desktop.electron.configureBeforeReady"));
143
144// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot
145// observe app readiness before scheme privileges and command-line switches exist.
146export const layer = Layer.mergeAll(
147 ElectronProtocol.layerSchemePrivileges,
148 Layer.effect(DesktopPreReadyElectronOptions, make),
149);