Open History to see the last commit
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 linuxDesktopEntryName: string;
30 readonly passwordStore: LinuxPasswordStoreSwitch | null;
31}
32
33export const resolveLinuxDesktopEntryName = (isDevelopment: boolean): string =>
34 isDevelopment ? "com.t3tools.T3Code.Development.desktop" : "com.t3tools.T3Code.desktop";
35
36const trimNonEmpty = (value: string | undefined): string | null => {
37 const trimmed = value?.trim();
38 return trimmed && trimmed.length > 0 ? trimmed : null;
39};
40
41const EarlyDesktopSettingsJson = fromLenientJson(
42 Schema.Struct({
43 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
44 }),
45);
46const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson);
47
48const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean =>
49 trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null;
50
51function resolveEarlyDesktopSettingsPath(input: {
52 readonly env: NodeJS.ProcessEnv;
53 readonly homeDirectory: string;
54 readonly joinPath: JoinPath;
55}): string {
56 const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME);
57 const baseDir = resolveDesktopBaseDir({
58 homeDirectory: input.homeDirectory,
59 joinPath: input.joinPath,
60 t3Home,
61 });
62 const stateDir = resolveDesktopStateDir({
63 baseDir,
64 isDevelopment: isDevelopmentEnvironment(input.env),
65 joinPath: input.joinPath,
66 t3Home,
67 });
68 return input.joinPath(stateDir, "desktop-settings.json");
69}
70
71export function resolveEarlyLinuxPasswordStorePreference(
72 input: EarlyDesktopSettingsInput,
73): LinuxPasswordStorePreference {
74 const settingsPath = resolveEarlyDesktopSettingsPath(input);
75 try {
76 const parsed = decodeEarlyDesktopSettingsJson(input.readFileString(settingsPath));
77 return normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore);
78 } catch {
79 return DEFAULT_LINUX_PASSWORD_STORE;
80 }
81}
82
83const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1";
84
85const featureListNames = (value: string | null): ReadonlyArray<string> =>
86 (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []);
87
88// HYPRLAND_INSTANCE_SIGNATURE can be stale; trust only the session's desktop names.
89const isHyprlandSession = (env: NodeJS.ProcessEnv): boolean =>
90 (trimNonEmpty(env.XDG_CURRENT_DESKTOP) ?? trimNonEmpty(env.XDG_SESSION_DESKTOP) ?? "")
91 .split(":")
92 .some((name) => name.trim().toLowerCase() === "hyprland");
93
94const HyprctlVersionJson = fromLenientJson(
95 Schema.Struct({
96 version: Schema.String,
97 tag: Schema.String,
98 commit: Schema.String,
99 dirty: Schema.Boolean,
100 }),
101);
102const decodeHyprctlVersionJson = Schema.decodeSync(HyprctlVersionJson);
103
104// Hyprland 0.48.0 rejects advertised set_primaries (hyprwm/Hyprland#9444); 0.51.0 accepts it (hyprwm/Hyprland#11499).
105// Check commits because dev and Nix builds can retain release versions and tags.
106const AFFECTED_HYPRLAND_RELEASE_COMMITS: ReadonlyMap<string, string> = new Map([
107 ["0.48.0", "5ee35f914f921e5696030698e74fb5566a804768"],
108 ["0.48.1", "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc"],
109 ["0.49.0", "9958d297641b5c84dcff93f9039d80a5ad37ab00"],
110 ["0.50.0", "c4a4c341568944bd4fb9cd503558b2de602c0213"],
111 ["0.50.1", "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc"],
112]);
113
114const hasBrokenColorManager = (readHyprctlVersionJson: () => string): boolean => {
115 try {
116 const { version, tag, commit, dirty } = decodeHyprctlVersionJson(readHyprctlVersionJson());
117 return (
118 AFFECTED_HYPRLAND_RELEASE_COMMITS.get(version) === commit && tag === `v${version}` && !dirty
119 );
120 } catch {
121 return false;
122 }
123};
124
125export function resolveLinuxDisableFeaturesSwitch(input: {
126 readonly env: NodeJS.ProcessEnv;
127 readonly ozonePlatform: string | null;
128 readonly enableFeatures: string | null;
129 readonly disableFeatures: string | null;
130 readonly readHyprctlVersionJson: () => string;
131}): string | null {
132 if (input.ozonePlatform !== "wayland" || !isHyprlandSession(input.env)) return null;
133 if (featureListNames(input.enableFeatures).includes(WAYLAND_COLOR_MANAGER_FEATURE)) return null;
134 if (featureListNames(input.disableFeatures).includes(WAYLAND_COLOR_MANAGER_FEATURE)) return null;
135 if (!hasBrokenColorManager(input.readHyprctlVersionJson)) return null;
136 return input.disableFeatures === null
137 ? WAYLAND_COLOR_MANAGER_FEATURE
138 : `${input.disableFeatures},${WAYLAND_COLOR_MANAGER_FEATURE}`;
139}
140
141export function resolveEarlyLinuxElectronOptions(
142 input: EarlyLinuxElectronOptionsInput,
143): EarlyLinuxElectronOptions {
144 const preference = resolveEarlyLinuxPasswordStorePreference(input);
145 const isDevelopment = isDevelopmentEnvironment(input.env);
146 return {
147 isDevelopment,
148 linuxDesktopEntryName: resolveLinuxDesktopEntryName(isDevelopment),
149 passwordStore: resolveLinuxPasswordStoreSwitch({
150 preference,
151 env: input.env,
152 }),
153 };
154}
155
156/** Remember the explicit Chromium scale across AppImage updates, which drop launch arguments. */
157export function restoreEarlyLinuxDeviceScaleFactor(
158 input: EarlyDesktopSettingsInput & {
159 readonly commandLine: {
160 readonly hasSwitch: (name: string) => boolean;
161 readonly getSwitchValue: (name: string) => string;
162 readonly appendSwitch: (name: string, value: string) => void;
163 };
164 readonly writeFileString: (path: string, value: string) => void;
165 },
166): void {
167 // Keep this separate from desktop-settings.json: the settings service rewrites
168 // that document, and Chromium needs the value before that service is available.
169 const path = input.joinPath(
170 resolveEarlyDesktopSettingsPath(input),
171 "..",
172 "linux-device-scale-factor",
173 );
174 const switchName = "force-device-scale-factor";
175 const isValid = (value: string) =>
176 value.trim().length > 0 && Number.isFinite(Number(value)) && Number(value) > 0;
177 if (input.commandLine.hasSwitch(switchName)) {
178 const value = input.commandLine.getSwitchValue(switchName);
179 if (isValid(value)) {
180 try {
181 input.writeFileString(path, value);
182 } catch {
183 // A read-only state directory must not prevent startup with the explicit switch.
184 }
185 }
186 return;
187 }
188 try {
189 const value = input.readFileString(path).trim();
190 if (isValid(value)) input.commandLine.appendSwitch(switchName, value);
191 } catch {
192 // First launch, missing state, or unreadable state: keep Chromium's default.
193 }
194}