1import type {
2 DesktopAppBranding,
3 DesktopAppStageLabel,
4 DesktopRuntimeArch,
5 DesktopRuntimeInfo,
6} from "@t3tools/contracts";
7import * as Config from "effect/Config";
8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13
14import { activeForkRuntime } from "../updates/forkRuntime.ts";
15
16import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
17import * as DesktopConfig from "./DesktopConfig.ts";
18import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts";
19import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts";
20import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
21import type { OtlpProtocol } from "@t3tools/shared/observability";
22
23export interface MakeDesktopEnvironmentInput {
24 readonly dirname: string;
25 readonly homeDirectory: string;
26 readonly platform: NodeJS.Platform;
27 readonly processArch: string;
28 readonly appVersion: string;
29 readonly appPath: string;
30 readonly isPackaged: boolean;
31 readonly resourcesPath: string;
32 readonly runningUnderArm64Translation: boolean;
33}
34
35export class DesktopEnvironment extends Context.Service<
36 DesktopEnvironment,
37 {
38 readonly path: Path.Path;
39 readonly dirname: string;
40 readonly platform: NodeJS.Platform;
41 readonly processArch: string;
42 readonly isPackaged: boolean;
43 readonly isDevelopment: boolean;
44 readonly appVersion: string;
45 readonly appPath: string;
46 readonly resourcesPath: string;
47 readonly homeDirectory: string;
48 readonly appDataDirectory: string;
49 readonly baseDir: string;
50 readonly stateDir: string;
51 readonly desktopSettingsPath: string;
52 readonly clientSettingsPath: string;
53 readonly savedEnvironmentRegistryPath: string;
54 readonly serverSettingsPath: string;
55 readonly logDir: string;
56 readonly browserArtifactsDir: string;
57 readonly rootDir: string;
58 readonly appRoot: string;
59 // Windows keeps separate desktop and server dependency trees.
60 readonly serverRoot: string;
61 readonly backendEntryPath: string;
62 // Built web client the packaged renderer is served from over t3code://app.
63 readonly clientAssetsDir: string;
64 readonly backendCwd: string;
65 readonly preloadPath: string;
66 // Preload that turns on the V8 compile cache for the local backend.
67 readonly compileCachePath: string;
68 readonly appUpdateYmlPath: string;
69 readonly devServerUrl: Option.Option<URL>;
70 readonly devRemoteT3ServerEntryPath: Option.Option<string>;
71 readonly configuredBackendPort: Option.Option<number>;
72 readonly commitHashOverride: Option.Option<string>;
73 readonly otlpTracesUrl: Option.Option<string>;
74 readonly otlpMetricsUrl: Option.Option<string>;
75 readonly otlpLogsUrl: Option.Option<string>;
76 readonly otlpExportIntervalMs: number;
77 readonly otlpHeaders: Option.Option<Record<string, string>>;
78 readonly otlpProtocol: OtlpProtocol;
79 readonly branding: DesktopAppBranding;
80 readonly displayName: string;
81 readonly appUserModelId: string;
82 readonly linuxDesktopEntryName: string;
83 readonly linuxWmClass: string;
84 readonly linuxApplicationsDir: string;
85 readonly appImagePath: Option.Option<string>;
86 readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings;
87 readonly runtimeInfo: DesktopRuntimeInfo;
88 readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;
89 readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
90 }
91>()("@t3tools/desktop/app/DesktopEnvironment") {}
92
93const APP_BASE_NAME = "The Knife";
94
95function resolveDesktopAppStageLabel(input: {
96 readonly isDevelopment: boolean;
97 readonly appVersion: string;
98}): DesktopAppStageLabel {
99 if (input.isDevelopment) {
100 return "Dev";
101 }
102
103 return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
104}
105
106export function resolveDesktopAppBranding(input: {
107 readonly isDevelopment: boolean;
108 readonly appVersion: string;
109}): DesktopAppBranding {
110 const stageLabel = resolveDesktopAppStageLabel(input);
111 return {
112 baseName: APP_BASE_NAME,
113 stageLabel,
114 displayName: stageLabel === "Alpha" ? APP_BASE_NAME : `${APP_BASE_NAME} (${stageLabel})`,
115 };
116}
117
118function normalizeDesktopArch(arch: string): DesktopRuntimeArch {
119 if (arch === "arm64") return "arm64";
120 if (arch === "x64") return "x64";
121 return "other";
122}
123
124function resolveDesktopRuntimeInfo(input: {
125 readonly platform: NodeJS.Platform;
126 readonly processArch: string;
127 readonly runningUnderArm64Translation: boolean;
128}): DesktopRuntimeInfo {
129 const appArch = normalizeDesktopArch(input.processArch);
130
131 if (input.platform !== "darwin") {
132 return {
133 hostArch: appArch,
134 appArch,
135 runningUnderArm64Translation: false,
136 };
137 }
138
139 const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
140
141 return {
142 hostArch,
143 appArch,
144 runningUnderArm64Translation: input.runningUnderArm64Translation,
145 };
146}
147
148const make = Effect.fn("desktop.environment.make")(function* (
149 input: MakeDesktopEnvironmentInput,
150): Effect.fn.Return<DesktopEnvironment["Service"], Config.ConfigError, Path.Path> {
151 const path = yield* Path.Path;
152 const config = yield* DesktopConfig.DesktopConfig;
153 const homeDirectory = input.homeDirectory;
154 const devServerUrl = config.devServerUrl;
155 const isDevelopment = Option.isSome(devServerUrl);
156 const appDataDirectory =
157 input.platform === "win32"
158 ? Option.getOrElse(config.appDataDirectory, () =>
159 path.join(homeDirectory, "AppData", "Roaming"),
160 )
161 : input.platform === "darwin"
162 ? path.join(homeDirectory, "Library", "Application Support")
163 : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
164 const baseDir = resolveDesktopBaseDir({
165 homeDirectory,
166 joinPath: path.join,
167 t3Home: config.t3Home,
168 });
169 const rootDir = path.resolve(input.dirname, "../../..");
170 const appRoot = input.isPackaged ? input.appPath : rootDir;
171 const serverRoot =
172 input.isPackaged && input.platform === "win32"
173 ? activeForkRuntime() === undefined
174 ? path.join(input.resourcesPath, "server.asar")
175 : path.join(appRoot, "server")
176 : appRoot;
177 const branding = resolveDesktopAppBranding({
178 isDevelopment,
179 appVersion: input.appVersion,
180 });
181 const displayName = branding.displayName;
182 const stateDir = resolveDesktopStateDir({
183 baseDir,
184 isDevelopment,
185 joinPath: path.join,
186 t3Home: config.t3Home,
187 });
188 const linuxApplicationsDir = path.join(
189 Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")),
190 "applications",
191 );
192 const resourcesPath = input.resourcesPath;
193
194 return DesktopEnvironment.of({
195 path,
196 dirname: input.dirname,
197 platform: input.platform,
198 processArch: input.processArch,
199 isPackaged: input.isPackaged,
200 isDevelopment,
201 appVersion: input.appVersion,
202 appPath: input.appPath,
203 resourcesPath,
204 homeDirectory,
205 appDataDirectory,
206 baseDir,
207 stateDir,
208 desktopSettingsPath: path.join(stateDir, "desktop-settings.json"),
209 clientSettingsPath: path.join(stateDir, "client-settings.json"),
210 savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"),
211 serverSettingsPath: path.join(stateDir, "settings.json"),
212 logDir: path.join(stateDir, "logs"),
213 browserArtifactsDir: path.join(stateDir, "browser-artifacts"),
214 rootDir,
215 appRoot,
216 serverRoot,
217 backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"),
218 clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"),
219 backendCwd: input.isPackaged ? homeDirectory : appRoot,
220 preloadPath: path.join(input.dirname, "preload.cjs"),
221 compileCachePath: path.join(input.dirname, "compileCache.cjs"),
222 appUpdateYmlPath: input.isPackaged
223 ? path.join(resourcesPath, "app-update.yml")
224 : path.join(input.appPath, "dev-app-update.yml"),
225 devServerUrl,
226 devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath,
227 configuredBackendPort: config.configuredBackendPort,
228 commitHashOverride: config.commitHashOverride,
229 otlpTracesUrl: config.otlpTracesUrl,
230 otlpMetricsUrl: config.otlpMetricsUrl,
231 otlpLogsUrl: config.otlpLogsUrl,
232 otlpExportIntervalMs: config.otlpExportIntervalMs,
233 otlpHeaders: config.otlpHeaders,
234 otlpProtocol: config.otlpProtocol,
235 branding,
236 displayName,
237 appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
238 isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code",
239 ),
240 linuxDesktopEntryName: resolveLinuxDesktopEntryName(isDevelopment),
241 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
242 linuxApplicationsDir,
243 appImagePath: config.appImagePath,
244 defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion),
245 runtimeInfo: resolveDesktopRuntimeInfo({
246 platform: input.platform,
247 processArch: input.processArch,
248 runningUnderArm64Translation: input.runningUnderArm64Translation,
249 }),
250 resolvePickFolderDefaultPath: (rawOptions) => {
251 if (typeof rawOptions !== "object" || rawOptions === null) {
252 return Option.none();
253 }
254
255 const { initialPath } = rawOptions as { initialPath?: unknown };
256 if (typeof initialPath !== "string") {
257 return Option.none();
258 }
259
260 const trimmedPath = initialPath.trim();
261 if (trimmedPath.length === 0) {
262 return Option.none();
263 }
264
265 if (trimmedPath === "~") {
266 return Option.some(homeDirectory);
267 }
268
269 if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {
270 return Option.some(path.join(homeDirectory, trimmedPath.slice(2)));
271 }
272
273 return Option.some(path.resolve(trimmedPath));
274 },
275 resolveResourcePathCandidates: (fileName) => [
276 path.join(input.dirname, "../resources", fileName),
277 path.join(input.dirname, "../prod-resources", fileName),
278 path.join(resourcesPath, "resources", fileName),
279 path.join(resourcesPath, fileName),
280 ],
281 });
282});
283
284export const layer = (input: MakeDesktopEnvironmentInput) =>
285 Layer.effect(DesktopEnvironment, make(input));