| 1 | import type { |
| 2 | DesktopAppBranding, |
| 3 | DesktopAppStageLabel, |
| 4 | DesktopRuntimeArch, |
| 5 | DesktopRuntimeInfo, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Config from "effect/Config"; |
| 8 | import * as Context from "effect/Context"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Option from "effect/Option"; |
| 12 | import * as Path from "effect/Path"; |
| 13 | |
| 14 | import { activeForkRuntime } from "../updates/forkRuntime.ts"; |
| 15 | |
| 16 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 17 | import * as DesktopConfig from "./DesktopConfig.ts"; |
| 18 | import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; |
| 19 | import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; |
| 20 | import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; |
| 21 | import type { OtlpProtocol } from "@t3tools/shared/observability"; |
| 22 | |
| 23 | export 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 | |
| 35 | export 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 | |
| 93 | const APP_BASE_NAME = "The Knife"; |
| 94 | |
| 95 | function 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 | |
| 106 | export 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 | |
| 118 | function normalizeDesktopArch(arch: string): DesktopRuntimeArch { |
| 119 | if (arch === "arm64") return "arm64"; |
| 120 | if (arch === "x64") return "x64"; |
| 121 | return "other"; |
| 122 | } |
| 123 | |
| 124 | function 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 | |
| 148 | const 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 | |
| 284 | export const layer = (input: MakeDesktopEnvironmentInput) => |
| 285 | Layer.effect(DesktopEnvironment, make(input)); |