1import { fromLenientJson } from "@t3tools/shared/schemaJson";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5import {
6 DEFAULT_LINUX_PASSWORD_STORE,
7 normalizeLinuxPasswordStorePreference,
8 resolveLinuxPasswordStoreSwitch,
9 type LinuxPasswordStoreSwitch,
10 type LinuxPasswordStorePreference,
11} from "../linuxSecretStorage.ts";
12import {
13 resolveDesktopBaseDir,
14 resolveDesktopStateDir,
15 type JoinPath,
16} from "./DesktopStatePaths.ts";
17
18interface EarlyDesktopSettingsInput {
19 readonly env: NodeJS.ProcessEnv;
20 readonly homeDirectory: string;
21 readonly joinPath: JoinPath;
22 readonly readFileString: (path: string) => string;
23}
24
25type EarlyLinuxElectronOptionsInput = EarlyDesktopSettingsInput;
26
27export interface EarlyLinuxElectronOptions {
28 readonly isDevelopment: boolean;
29 readonly linuxWmClass: string;
30 readonly linuxDesktopEntryName: string;
31 readonly passwordStore: LinuxPasswordStoreSwitch | null;
32}
33
34export const resolveLinuxDesktopEntryName = (isDevelopment: boolean): string =>
35 isDevelopment ? "com.t3tools.T3Code.Development.desktop" : "com.t3tools.T3Code.desktop";
36
37const trimNonEmpty = (value: string | undefined): string | null => {
38 const trimmed = value?.trim();
39 return trimmed && trimmed.length > 0 ? trimmed : null;
40};
41
42const EarlyDesktopSettingsJson = fromLenientJson(
43 Schema.Struct({
44 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
45 }),
46);
47const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson);
48
49const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean =>
50 trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null;
51
52function resolveEarlyDesktopSettingsPath(input: {
53 readonly env: NodeJS.ProcessEnv;
54 readonly homeDirectory: string;
55 readonly joinPath: JoinPath;
56}): string {
57 const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME);
58 const baseDir = resolveDesktopBaseDir({
59 homeDirectory: input.homeDirectory,
60 joinPath: input.joinPath,
61 t3Home,
62 });
63 const stateDir = resolveDesktopStateDir({
64 baseDir,
65 isDevelopment: isDevelopmentEnvironment(input.env),
66 joinPath: input.joinPath,
67 t3Home,
68 });
69 return input.joinPath(stateDir, "desktop-settings.json");
70}
71
72export function resolveEarlyLinuxPasswordStorePreference(
73 input: EarlyDesktopSettingsInput,
74): LinuxPasswordStorePreference {
75 const settingsPath = resolveEarlyDesktopSettingsPath(input);
76 try {
77 const parsed = decodeEarlyDesktopSettingsJson(input.readFileString(settingsPath));
78 return normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore);
79 } catch {
80 return DEFAULT_LINUX_PASSWORD_STORE;
81 }
82}
83
84const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1";
85
86// Chromium feature entries look like `*Name<Trial.Group:param/value`.
87const featureListNames = (value: string | null): ReadonlyArray<string> =>
88 (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []);
89
90// A stale HYPRLAND_INSTANCE_SIGNATURE is often inherited into other desktops, so only
91// trust the session's desktop names.
92const isHyprlandSession = (env: NodeJS.ProcessEnv): boolean =>
93 (trimNonEmpty(env.XDG_CURRENT_DESKTOP) ?? trimNonEmpty(env.XDG_SESSION_DESKTOP) ?? "")
94 .split(":")
95 .some((name) => name.trim().toLowerCase() === "hyprland");
96
97const HyprctlVersionJson = fromLenientJson(
98 Schema.Struct({
99 version: Schema.String,
100 tag: Schema.String,
101 commit: Schema.String,
102 dirty: Schema.Boolean,
103 }),
104);
105const decodeHyprctlVersionJson = Schema.decodeSync(HyprctlVersionJson);
106
107// Hyprland 0.48.0 added wp-color-manager-v1 (hyprwm/Hyprland#9444) advertising
108// FEATURE_SET_PRIMARIES while rejecting set_primaries outside debug mode, which Chromium
109// treats as a fatal protocol error. 0.51.0 accepts them (hyprwm/Hyprland#11499).
110// Only stock builds of these releases count. Matching the commit as well as the tag
111// keeps later development builds that still report an old VERSION and tag (Nix
112// synthesizes `v${VERSION}`) on the default.
113const AFFECTED_HYPRLAND_RELEASE_COMMITS: ReadonlyMap<string, string> = new Map([
114 ["0.48.0", "5ee35f914f921e5696030698e74fb5566a804768"],
115 ["0.48.1", "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc"],
116 ["0.49.0", "9958d297641b5c84dcff93f9039d80a5ad37ab00"],
117 ["0.50.0", "c4a4c341568944bd4fb9cd503558b2de602c0213"],
118 ["0.50.1", "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc"],
119]);
120
121const hasBrokenColorManager = (readHyprctlVersionJson: () => string): boolean => {
122 try {
123 const { version, tag, commit, dirty } = decodeHyprctlVersionJson(readHyprctlVersionJson());
124 return (
125 AFFECTED_HYPRLAND_RELEASE_COMMITS.get(version) === commit && tag === `v${version}` && !dirty
126 );
127 } catch {
128 return false;
129 }
130};
131
132/**
133 * Returns the merged `--disable-features` value to apply before Electron is ready,
134 * or null to leave the command line alone. `ozonePlatform` is the resolved
135 * `--ozone-platform` value; Electron fills it in before app code runs.
136 *
137 * Color management stays on unless `hyprctl version -j` identifies a stock Hyprland
138 * release known to kill the native Wayland connection right after the first window opens.
139 */
140export function resolveLinuxDisableFeaturesSwitch(input: {
141 readonly env: NodeJS.ProcessEnv;
142 readonly ozonePlatform: string | null;
143 readonly enableFeatures: string | null;
144 readonly disableFeatures: string | null;
145 readonly readHyprctlVersionJson: () => string;
146}): string | null {
147 if (input.ozonePlatform !== "wayland" || !isHyprlandSession(input.env)) return null;
148 if (featureListNames(input.enableFeatures).includes(WAYLAND_COLOR_MANAGER_FEATURE)) return null;
149 if (featureListNames(input.disableFeatures).includes(WAYLAND_COLOR_MANAGER_FEATURE)) return null;
150 if (!hasBrokenColorManager(input.readHyprctlVersionJson)) return null;
151 return input.disableFeatures === null
152 ? WAYLAND_COLOR_MANAGER_FEATURE
153 : `${input.disableFeatures},${WAYLAND_COLOR_MANAGER_FEATURE}`;
154}
155
156export function resolveEarlyLinuxElectronOptions(
157 input: EarlyLinuxElectronOptionsInput,
158): EarlyLinuxElectronOptions {
159 const preference = resolveEarlyLinuxPasswordStorePreference(input);
160 const isDevelopment = isDevelopmentEnvironment(input.env);
161 return {
162 isDevelopment,
163 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
164 linuxDesktopEntryName: resolveLinuxDesktopEntryName(isDevelopment),
165 passwordStore: resolveLinuxPasswordStoreSwitch({
166 preference,
167 env: input.env,
168 }),
169 };
170}