1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Ref from "effect/Ref";
8import * as Schema from "effect/Schema";
9
10import * as ElectronApp from "../electron/ElectronApp.ts";
11import * as DesktopAssets from "./DesktopAssets.ts";
12import * as DesktopEnvironment from "./DesktopEnvironment.ts";
13import * as DesktopUserData from "./DesktopUserData.ts";
14
15const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
16const COMMIT_HASH_DISPLAY_LENGTH = 12;
17
18const AppPackageMetadata = Schema.Struct({
19 t3codeCommitHash: Schema.optional(Schema.String),
20});
21const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata));
22
23export class DesktopAppIdentity extends Context.Service<
24 DesktopAppIdentity,
25 {
26 readonly resolveUserDataPath: Effect.Effect<
27 string,
28 DesktopUserData.DesktopUserDataInitializationError
29 >;
30 readonly configure: Effect.Effect<void>;
31 }
32>()("@t3tools/desktop/app/DesktopAppIdentity") {}
33
34const normalizeCommitHash = (value: string): Option.Option<string> => {
35 const trimmed = value.trim();
36 return COMMIT_HASH_PATTERN.test(trimmed)
37 ? Option.some(trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase())
38 : Option.none();
39};
40
41/** @public Service construction is part of the canonical Effect module API. */
42export const make = Effect.gen(function* () {
43 const assets = yield* DesktopAssets.DesktopAssets;
44 const electronApp = yield* ElectronApp.ElectronApp;
45 const environment = yield* DesktopEnvironment.DesktopEnvironment;
46 const fileSystem = yield* FileSystem.FileSystem;
47 const userDataContext = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
48 const commitHashCache = yield* Ref.make<Option.Option<Option.Option<string>>>(Option.none());
49
50 const resolveEmbeddedCommitHash = Effect.gen(function* () {
51 const packageJsonPath = environment.path.join(environment.appRoot, "package.json");
52 const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option);
53 return yield* Option.match(raw, {
54 onNone: () => Effect.succeed(Option.none<string>()),
55 onSome: (value) =>
56 decodeAppPackageMetadata(value).pipe(
57 Effect.map((parsed) =>
58 Option.fromNullishOr(parsed.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)),
59 ),
60 Effect.orElseSucceed(() => Option.none<string>()),
61 ),
62 });
63 });
64
65 const resolveAboutCommitHash = Effect.gen(function* () {
66 const cached = yield* Ref.get(commitHashCache);
67 if (Option.isSome(cached)) {
68 return cached.value;
69 }
70
71 const override = Option.flatMap(environment.commitHashOverride, normalizeCommitHash);
72 if (Option.isSome(override)) {
73 yield* Ref.set(commitHashCache, Option.some(override));
74 return override;
75 }
76
77 if (!environment.isPackaged) {
78 const empty = Option.none<string>();
79 yield* Ref.set(commitHashCache, Option.some(empty));
80 return empty;
81 }
82
83 const commitHash = yield* resolveEmbeddedCommitHash;
84 yield* Ref.set(commitHashCache, Option.some(commitHash));
85 return commitHash;
86 });
87
88 const userDataPath = DesktopUserData.resolveUserDataPath(environment).pipe(
89 Effect.provide(userDataContext),
90 );
91
92 const configure = Effect.gen(function* () {
93 const commitHash = yield* resolveAboutCommitHash;
94 // Electron's runtime name also identifies the shipped Safe Storage/keyring entry.
95 yield* electronApp.setName(`T3 Code ${environment.branding.stageLabel}`);
96 yield* electronApp.setAboutPanelOptions({
97 applicationName: environment.displayName,
98 applicationVersion: environment.appVersion,
99 version: Option.getOrElse(commitHash, () => "unknown"),
100 });
101
102 if (environment.platform === "win32") {
103 yield* electronApp.setAppUserModelId(environment.appUserModelId);
104 }
105
106 // Unpackaged runs only. A packaged bundle already carries its icon in
107 // Info.plist, so setting the dock tile again changes nothing except to
108 // overwrite a custom icon the user attached to the app themselves.
109 if (environment.platform === "darwin" && !environment.isPackaged) {
110 const iconPaths = yield* assets.iconPaths;
111 yield* Option.match(iconPaths.png, {
112 onNone: () => Effect.void,
113 onSome: electronApp.setDockIcon,
114 });
115 }
116 }).pipe(Effect.withSpan("desktop.appIdentity.configure"));
117
118 return DesktopAppIdentity.of({
119 resolveUserDataPath: userDataPath,
120 configure,
121 });
122});
123
124export const layer = Layer.effect(DesktopAppIdentity, make);