authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 19:42:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 19:42:41-07:00
log60b16d1f28104c95a3ab8e673cb439124a4a8668
tree981268d6b7fa59a6ae0c9c445034fd1174b9d919
parentab0baaa88614905722a6638aa56ec858caf9c563
signature Signed by PGP key B5690EEEBB952194

[codex] Refactor desktop app Effect services (#3185)

Co-authored-by: codex <codex@users.noreply.github.com>

17 files changed, 521 insertions(+), 520 deletions(-)

apps/desktop/src/app/DesktopAppIdentity.test.ts+2-2
......@@ -63,7 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
6363 }),
6464 appendCommandLineSwitch: () => Effect.void,
6565 on: () => Effect.void,
66 } satisfies ElectronApp.ElectronAppShape);
66 } satisfies ElectronApp.ElectronApp["Service"]);
6767
6868const makeAssetsLayer = (png: Option.Option<string>) =>
6969 Layer.succeed(DesktopAssets.DesktopAssets, {
......@@ -73,7 +73,7 @@ const makeAssetsLayer = (png: Option.Option<string>) =>
7373 png,
7474 }),
7575 resolveResourcePath: () => Effect.succeed(Option.none()),
76 } satisfies DesktopAssets.DesktopAssetsShape);
76 } satisfies DesktopAssets.DesktopAssets["Service"]);
7777
7878const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => {
7979 const { env, ...environmentOverrides } = overrides;
apps/desktop/src/app/DesktopAppIdentity.ts+4-6
......@@ -18,14 +18,12 @@ const AppPackageMetadata = Schema.Struct({
1818});
1919const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata));
2020
21export interface DesktopAppIdentityShape {
22 readonly resolveUserDataPath: Effect.Effect<string>;
23 readonly configure: Effect.Effect<void>;
24}
25
2621export class DesktopAppIdentity extends Context.Service<
2722 DesktopAppIdentity,
28 DesktopAppIdentityShape
23 {
24 readonly resolveUserDataPath: Effect.Effect<string>;
25 readonly configure: Effect.Effect<void>;
26 }
2927>()("@t3tools/desktop/app/DesktopAppIdentity") {}
3028
3129const normalizeCommitHash = (value: string): Option.Option<string> => {
apps/desktop/src/app/DesktopAssets.ts+7-8
......@@ -12,14 +12,13 @@ export interface DesktopIconPaths {
1212 readonly png: Option.Option<string>;
1313}
1414
15export interface DesktopAssetsShape {
16 readonly iconPaths: Effect.Effect<DesktopIconPaths>;
17 readonly resolveResourcePath: (fileName: string) => Effect.Effect<Option.Option<string>>;
18}
19
20export class DesktopAssets extends Context.Service<DesktopAssets, DesktopAssetsShape>()(
21 "@t3tools/desktop/app/DesktopAssets",
22) {}
15export class DesktopAssets extends Context.Service<
16 DesktopAssets,
17 {
18 readonly iconPaths: Effect.Effect<DesktopIconPaths>;
19 readonly resolveResourcePath: (fileName: string) => Effect.Effect<Option.Option<string>>;
20 }
21>()("@t3tools/desktop/app/DesktopAssets") {}
2322
2423const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* (
2524 fileName: string,
apps/desktop/src/app/DesktopBackendOutputLog.ts created+280
......@@ -0,0 +1,280 @@
1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9import * as References from "effect/References";
10import * as Ref from "effect/Ref";
11import * as Schema from "effect/Schema";
12import * as Semaphore from "effect/Semaphore";
13
14import * as DesktopEnvironment from "./DesktopEnvironment.ts";
15
16export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
17export const DESKTOP_LOG_FILE_MAX_FILES = 10;
18
19const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";
20
21interface RotatingLogFileWriter {
22 readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;
23 readonly writeText: (chunk: string) => Effect.Effect<void>;
24}
25
26export class DesktopBackendOutputLog extends Context.Service<
27 DesktopBackendOutputLog,
28 {
29 readonly writeSessionBoundary: (input: {
30 readonly phase: "START" | "END";
31 readonly details: string;
32 }) => Effect.Effect<void>;
33 readonly writeOutputChunk: (
34 streamName: "stdout" | "stderr",
35 chunk: Uint8Array,
36 ) => Effect.Effect<void>;
37 }
38>()("@t3tools/desktop/app/DesktopBackendOutputLog") {}
39
40class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass<DesktopLogFileWriterConfigurationError>()(
41 "DesktopLogFileWriterConfigurationError",
42 {
43 option: Schema.Literals(["maxBytes", "maxFiles"]),
44 value: Schema.Number,
45 },
46) {
47 override get message(): string {
48 return `${this.option} must be >= 1 (received ${this.value})`;
49 }
50}
51
52type DesktopLogFileWriterError =
53 | DesktopLogFileWriterConfigurationError
54 | PlatformError.PlatformError;
55
56const DesktopBackendChildLogRecord = Schema.Struct({
57 message: Schema.String,
58 level: Schema.Literals(["INFO", "ERROR"]),
59 timestamp: Schema.String,
60 annotations: Schema.Record(Schema.String, Schema.Unknown),
61 spans: Schema.Record(Schema.String, Schema.Unknown),
62 fiberId: Schema.String,
63});
64
65const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(
66 Schema.fromJsonString(DesktopBackendChildLogRecord),
67);
68
69const DesktopBackendOutputLogNoop: DesktopBackendOutputLog["Service"] = {
70 writeSessionBoundary: () => Effect.void,
71 writeOutputChunk: () => Effect.void,
72};
73
74const textEncoder = new TextEncoder();
75const textDecoder = new TextDecoder();
76
77const currentDesktopRunId = Effect.gen(function* () {
78 const annotations = yield* References.CurrentLogAnnotations;
79 const runId = annotations.runId;
80 return typeof runId === "string" && runId.length > 0 ? runId : "unknown";
81});
82
83const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim();
84
85const refreshFileSize = (
86 fileSystem: FileSystem.FileSystem,
87 filePath: string,
88): Effect.Effect<number, never> =>
89 fileSystem.stat(filePath).pipe(
90 Effect.map((stat) => Number(stat.size)),
91 Effect.orElseSucceed(() => 0),
92 );
93
94const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: {
95 readonly filePath: string;
96 readonly maxBytes?: number;
97 readonly maxFiles?: number;
98}): Effect.fn.Return<
99 RotatingLogFileWriter,
100 DesktopLogFileWriterError,
101 FileSystem.FileSystem | Path.Path
102> {
103 const fileSystem = yield* FileSystem.FileSystem;
104 const path = yield* Path.Path;
105 const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES;
106 const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES;
107 const directory = path.dirname(input.filePath);
108 const baseName = path.basename(input.filePath);
109
110 if (maxBytes < 1) {
111 return yield* new DesktopLogFileWriterConfigurationError({
112 option: "maxBytes",
113 value: maxBytes,
114 });
115 }
116 if (maxFiles < 1) {
117 return yield* new DesktopLogFileWriterConfigurationError({
118 option: "maxFiles",
119 value: maxFiles,
120 });
121 }
122
123 yield* fileSystem.makeDirectory(directory, { recursive: true });
124
125 const withSuffix = (index: number) => `${input.filePath}.${index}`;
126 const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath));
127 const mutex = yield* Semaphore.make(1);
128
129 const pruneOverflowBackups = Effect.gen(function* () {
130 const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
131 for (const entry of entries) {
132 if (!entry.startsWith(`${baseName}.`)) continue;
133 const suffix = Number(entry.slice(baseName.length + 1));
134 if (!Number.isInteger(suffix) || suffix <= maxFiles) continue;
135 yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore);
136 }
137 });
138
139 const rotate = Effect.gen(function* () {
140 yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore);
141 for (let index = maxFiles - 1; index >= 1; index -= 1) {
142 const source = withSuffix(index);
143 const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false));
144 if (sourceExists) {
145 yield* fileSystem.rename(source, withSuffix(index + 1));
146 }
147 }
148 const currentExists = yield* fileSystem
149 .exists(input.filePath)
150 .pipe(Effect.orElseSucceed(() => false));
151 if (currentExists) {
152 yield* fileSystem.rename(input.filePath, withSuffix(1));
153 }
154 yield* Ref.set(currentSize, 0);
155 }).pipe(
156 Effect.catch(() =>
157 refreshFileSize(fileSystem, input.filePath).pipe(
158 Effect.flatMap((size) => Ref.set(currentSize, size)),
159 ),
160 ),
161 );
162
163 const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => {
164 if (chunk.byteLength === 0) return Effect.void;
165
166 return mutex.withPermits(1)(
167 Effect.gen(function* () {
168 const beforeSize = yield* Ref.get(currentSize);
169 if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) {
170 yield* rotate;
171 }
172
173 yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" });
174 const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength;
175 yield* Ref.set(currentSize, afterSize);
176
177 if (afterSize > maxBytes) {
178 yield* rotate;
179 }
180 }).pipe(
181 Effect.catch(() =>
182 refreshFileSize(fileSystem, input.filePath).pipe(
183 Effect.flatMap((size) => Ref.set(currentSize, size)),
184 ),
185 ),
186 ),
187 );
188 };
189
190 yield* pruneOverflowBackups;
191
192 return {
193 writeBytes,
194 writeText: (chunk) => writeBytes(textEncoder.encode(chunk)),
195 } satisfies RotatingLogFileWriter;
196});
197
198const writeDevelopmentConsoleOutput = (
199 streamName: "stdout" | "stderr",
200 chunk: Uint8Array,
201): Effect.Effect<void> =>
202 Effect.sync(() => {
203 const output = streamName === "stderr" ? process.stderr : process.stdout;
204 output.write(chunk);
205 }).pipe(Effect.ignore);
206
207const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")(
208 function* (
209 logFile: RotatingLogFileWriter,
210 input: {
211 readonly message: string;
212 readonly level: "INFO" | "ERROR";
213 readonly annotations: Record<string, unknown>;
214 },
215 ): Effect.fn.Return<void> {
216 return yield* Effect.gen(function* () {
217 const timestamp = DateTime.formatIso(yield* DateTime.now);
218 const encoded = yield* encodeDesktopBackendChildLogRecord({
219 message: input.message,
220 level: input.level,
221 timestamp,
222 annotations: input.annotations,
223 spans: {},
224 fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID,
225 });
226 yield* logFile.writeText(`${encoded}\n`);
227 }).pipe(Effect.ignore({ log: true }));
228 },
229);
230
231const make = Effect.gen(function* () {
232 const environment = yield* DesktopEnvironment.DesktopEnvironment;
233 const writer = yield* makeRotatingLogFileWriter({
234 filePath: environment.path.join(environment.logDir, "server-child.log"),
235 }).pipe(Effect.option);
236
237 const service = Option.match(writer, {
238 onNone: () => DesktopBackendOutputLogNoop,
239 onSome: (logFile) =>
240 ({
241 writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")(
242 function* ({ phase, details }) {
243 const runId = yield* currentDesktopRunId;
244 yield* writeBackendChildLogRecord(logFile, {
245 message: `backend child process session ${phase.toLowerCase()}`,
246 level: "INFO",
247 annotations: {
248 component: "desktop-backend-child",
249 runId,
250 phase,
251 details: sanitizeLogValue(details),
252 },
253 });
254 },
255 ),
256 writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")(
257 function* (streamName, chunk) {
258 if (environment.isDevelopment) {
259 yield* writeDevelopmentConsoleOutput(streamName, chunk);
260 }
261 const runId = yield* currentDesktopRunId;
262 yield* writeBackendChildLogRecord(logFile, {
263 message: "backend child process output",
264 level: streamName === "stderr" ? "ERROR" : "INFO",
265 annotations: {
266 component: "desktop-backend-child",
267 runId,
268 stream: streamName,
269 text: textDecoder.decode(chunk),
270 },
271 });
272 },
273 ),
274 }) satisfies DesktopBackendOutputLog["Service"],
275 });
276
277 return DesktopBackendOutputLog.of(service);
278});
279
280export const layer = Layer.effect(DesktopBackendOutputLog, make);
apps/desktop/src/app/DesktopClerk.test.ts+8-9
......@@ -21,10 +21,6 @@ vi.mock("@clerk/electron/storage", () => ({
2121 storage: storageMock,
2222}));
2323
24import {
25 createDesktopClerkBridge,
26 resolveDesktopClerkFrontendApiHostname,
27} from "./DesktopClerk.ts";
2824import * as DesktopClerk from "./DesktopClerk.ts";
2925import * as DesktopEnvironment from "./DesktopEnvironment.ts";
3026
......@@ -32,9 +28,12 @@ describe("DesktopClerk", () => {
3228 it("derives the Clerk Frontend API hostname used by the desktop CSP", () => {
3329 const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`;
3430
35 assert.equal(resolveDesktopClerkFrontendApiHostname(publishableKey), "clerk.t3.codes");
36 assert.equal(resolveDesktopClerkFrontendApiHostname(""), undefined);
37 assert.equal(resolveDesktopClerkFrontendApiHostname("invalid"), undefined);
31 assert.equal(
32 DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey),
33 "clerk.t3.codes",
34 );
35 assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined);
36 assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined);
3837 });
3938
4039 it.effect("acquires and releases the SDK bridge with the layer", () => {
......@@ -44,7 +43,7 @@ describe("DesktopClerk", () => {
4443 const environment = DesktopEnvironment.DesktopEnvironment.of({
4544 stateDir: "/tmp/t3-state",
4645 isDevelopment: true,
47 } as unknown as DesktopEnvironment.DesktopEnvironmentShape);
46 } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
4847
4948 return Effect.gen(function* () {
5049 yield* Effect.scoped(
......@@ -78,7 +77,7 @@ describe("DesktopClerk", () => {
7877 storageMock.mockReturnValue(storageAdapter);
7978 createClerkBridgeMock.mockReturnValue(bridge);
8079
81 assert.equal(createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge);
80 assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge);
8281 assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]);
8382 assert.deepEqual(createClerkBridgeMock.mock.calls, [
8483 [
apps/desktop/src/app/DesktopClerk.ts+10-11
......@@ -14,17 +14,16 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1414
1515declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
1616
17export interface DesktopClerkShape {
18 readonly configure: Effect.Effect<
19 void,
20 never,
21 ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope
22 >;
23}
24
25export class DesktopClerk extends Context.Service<DesktopClerk, DesktopClerkShape>()(
26 "@t3tools/desktop/app/DesktopClerk",
27) {}
17export class DesktopClerk extends Context.Service<
18 DesktopClerk,
19 {
20 readonly configure: Effect.Effect<
21 void,
22 never,
23 ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope
24 >;
25 }
26>()("@t3tools/desktop/app/DesktopClerk") {}
2827
2928export function resolveDesktopClerkFrontendApiHostname(
3029 publishableKey: string | undefined,
apps/desktop/src/app/DesktopEnvironment.ts+50-55
......@@ -11,10 +11,7 @@ import * as Layer from "effect/Layer";
1111import * as Option from "effect/Option";
1212import * as Path from "effect/Path";
1313
14import {
15 type DesktopSettings,
16 resolveDefaultDesktopSettings,
17} from "../settings/DesktopAppSettings.ts";
14import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1815import * as DesktopConfig from "./DesktopConfig.ts";
1916import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
2017
......@@ -30,55 +27,53 @@ export interface MakeDesktopEnvironmentInput {
3027 readonly runningUnderArm64Translation: boolean;
3128}
3229
33export interface DesktopEnvironmentShape {
34 readonly path: Path.Path;
35 readonly dirname: string;
36 readonly platform: NodeJS.Platform;
37 readonly processArch: string;
38 readonly isPackaged: boolean;
39 readonly isDevelopment: boolean;
40 readonly appVersion: string;
41 readonly appPath: string;
42 readonly resourcesPath: string;
43 readonly homeDirectory: string;
44 readonly appDataDirectory: string;
45 readonly baseDir: string;
46 readonly stateDir: string;
47 readonly desktopSettingsPath: string;
48 readonly clientSettingsPath: string;
49 readonly savedEnvironmentRegistryPath: string;
50 readonly serverSettingsPath: string;
51 readonly logDir: string;
52 readonly browserArtifactsDir: string;
53 readonly rootDir: string;
54 readonly appRoot: string;
55 readonly backendEntryPath: string;
56 readonly backendCwd: string;
57 readonly preloadPath: string;
58 readonly appUpdateYmlPath: string;
59 readonly devServerUrl: Option.Option<URL>;
60 readonly devRemoteT3ServerEntryPath: Option.Option<string>;
61 readonly configuredBackendPort: Option.Option<number>;
62 readonly commitHashOverride: Option.Option<string>;
63 readonly otlpTracesUrl: Option.Option<string>;
64 readonly otlpExportIntervalMs: number;
65 readonly branding: DesktopAppBranding;
66 readonly displayName: string;
67 readonly appUserModelId: string;
68 readonly linuxDesktopEntryName: string;
69 readonly linuxWmClass: string;
70 readonly userDataDirName: string;
71 readonly legacyUserDataDirName: string;
72 readonly defaultDesktopSettings: DesktopSettings;
73 readonly runtimeInfo: DesktopRuntimeInfo;
74 readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;
75 readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
76 readonly developmentDockIconPath: string;
77}
78
7930export class DesktopEnvironment extends Context.Service<
8031 DesktopEnvironment,
81 DesktopEnvironmentShape
32 {
33 readonly path: Path.Path;
34 readonly dirname: string;
35 readonly platform: NodeJS.Platform;
36 readonly processArch: string;
37 readonly isPackaged: boolean;
38 readonly isDevelopment: boolean;
39 readonly appVersion: string;
40 readonly appPath: string;
41 readonly resourcesPath: string;
42 readonly homeDirectory: string;
43 readonly appDataDirectory: string;
44 readonly baseDir: string;
45 readonly stateDir: string;
46 readonly desktopSettingsPath: string;
47 readonly clientSettingsPath: string;
48 readonly savedEnvironmentRegistryPath: string;
49 readonly serverSettingsPath: string;
50 readonly logDir: string;
51 readonly browserArtifactsDir: string;
52 readonly rootDir: string;
53 readonly appRoot: string;
54 readonly backendEntryPath: string;
55 readonly backendCwd: string;
56 readonly preloadPath: string;
57 readonly appUpdateYmlPath: string;
58 readonly devServerUrl: Option.Option<URL>;
59 readonly devRemoteT3ServerEntryPath: Option.Option<string>;
60 readonly configuredBackendPort: Option.Option<number>;
61 readonly commitHashOverride: Option.Option<string>;
62 readonly otlpTracesUrl: Option.Option<string>;
63 readonly otlpExportIntervalMs: number;
64 readonly branding: DesktopAppBranding;
65 readonly displayName: string;
66 readonly appUserModelId: string;
67 readonly linuxDesktopEntryName: string;
68 readonly linuxWmClass: string;
69 readonly userDataDirName: string;
70 readonly legacyUserDataDirName: string;
71 readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings;
72 readonly runtimeInfo: DesktopRuntimeInfo;
73 readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;
74 readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
75 readonly developmentDockIconPath: string;
76 }
8277>()("@t3tools/desktop/app/DesktopEnvironment") {}
8378
8479const APP_BASE_NAME = "T3 Code";
......@@ -136,9 +131,9 @@ function resolveDesktopRuntimeInfo(input: {
136131 };
137132}
138133
139const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
134const make = Effect.fn("desktop.environment.make")(function* (
140135 input: MakeDesktopEnvironmentInput,
141): Effect.fn.Return<DesktopEnvironmentShape, Config.ConfigError, Path.Path> {
136): Effect.fn.Return<DesktopEnvironment["Service"], Config.ConfigError, Path.Path> {
142137 const path = yield* Path.Path;
143138 const config = yield* DesktopConfig.DesktopConfig;
144139 const homeDirectory = input.homeDirectory;
......@@ -208,7 +203,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
208203 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
209204 userDataDirName,
210205 legacyUserDataDirName,
211 defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion),
206 defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion),
212207 runtimeInfo: resolveDesktopRuntimeInfo({
213208 platform: input.platform,
214209 processArch: input.processArch,
......@@ -250,4 +245,4 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
250245});
251246
252247export const layer = (input: MakeDesktopEnvironmentInput) =>
253 Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input));
248 Layer.effect(DesktopEnvironment, make(input));
apps/desktop/src/app/DesktopLifecycle.ts+89-120
......@@ -1,7 +1,6 @@
11import * as Cause from "effect/Cause";
22import * as Context from "effect/Context";
33import * as Effect from "effect/Effect";
4import * as Deferred from "effect/Deferred";
54import * as Layer from "effect/Layer";
65import * as Ref from "effect/Ref";
76import * as Scope from "effect/Scope";
......@@ -10,63 +9,34 @@ import type * as Electron from "electron";
109
1110import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1211import * as DesktopObservability from "./DesktopObservability.ts";
12import * as DesktopShutdownModule from "./DesktopShutdown.ts";
1313import * as ElectronApp from "../electron/ElectronApp.ts";
1414import * as ElectronTheme from "../electron/ElectronTheme.ts";
1515import * as DesktopState from "./DesktopState.ts";
1616import * as DesktopWindow from "../window/DesktopWindow.ts";
1717
18export interface DesktopShutdownShape {
19 readonly request: Effect.Effect<void>;
20 readonly awaitRequest: Effect.Effect<void>;
21 readonly markComplete: Effect.Effect<void>;
22 readonly awaitComplete: Effect.Effect<void>;
23 readonly isComplete: Effect.Effect<boolean>;
24}
25
26export class DesktopShutdown extends Context.Service<DesktopShutdown, DesktopShutdownShape>()(
27 "@t3tools/desktop/app/DesktopLifecycle/DesktopShutdown",
28) {}
29
30const makeShutdown = Effect.gen(function* () {
31 const requested = yield* Deferred.make<void>();
32 const completed = yield* Deferred.make<void>();
33 const completedRef = yield* Ref.make(false);
34
35 return DesktopShutdown.of({
36 request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid),
37 awaitRequest: Deferred.await(requested),
38 markComplete: Ref.set(completedRef, true).pipe(
39 Effect.andThen(Deferred.succeed(completed, undefined)),
40 Effect.asVoid,
41 ),
42 awaitComplete: Deferred.await(completed),
43 isComplete: Ref.get(completedRef),
44 });
45});
46
47export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown);
18export { DesktopShutdown, layer as layerShutdown } from "./DesktopShutdown.ts";
4819
4920export type DesktopLifecycleRuntimeServices =
5021 | DesktopEnvironment.DesktopEnvironment
51 | DesktopShutdown
22 | DesktopShutdownModule.DesktopShutdown
5223 | DesktopState.DesktopState
5324 | DesktopWindow.DesktopWindow
5425 | ElectronApp.ElectronApp
5526 | ElectronTheme.ElectronTheme;
5627
57export interface DesktopLifecycleShape {
58 readonly relaunch: (
59 reason: string,
60 ) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>;
61 readonly register: Effect.Effect<void, never, Scope.Scope | DesktopLifecycleRuntimeServices>;
62}
63
6428/**
6529 * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme
6630 */
67export class DesktopLifecycle extends Context.Service<DesktopLifecycle, DesktopLifecycleShape>()(
68 "@t3tools/desktop/app/DesktopLifecycle",
69) {}
31export class DesktopLifecycle extends Context.Service<
32 DesktopLifecycle,
33 {
34 readonly relaunch: (
35 reason: string,
36 ) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>;
37 readonly register: Effect.Effect<void, never, Scope.Scope | DesktopLifecycleRuntimeServices>;
38 }
39>()("@t3tools/desktop/app/DesktopLifecycle") {}
7040
7141const { logInfo: logLifecycleInfo, logError: logLifecycleError } =
7242 DesktopObservability.makeComponentLogger("desktop-lifecycle");
......@@ -93,8 +63,8 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(
9363}
9464
9565const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
96 function* (): Effect.fn.Return<void, never, DesktopShutdown> {
97 const shutdown = yield* DesktopShutdown;
66 function* (): Effect.fn.Return<void, never, DesktopShutdownModule.DesktopShutdown> {
67 const shutdown = yield* DesktopShutdownModule.DesktopShutdown;
9868 yield* shutdown.request;
9969 yield* shutdown.awaitComplete;
10070 },
......@@ -154,83 +124,82 @@ function quitFromSignal(
154124 );
155125}
156126
157export const layer = Layer.succeed(
158 DesktopLifecycle,
159 DesktopLifecycle.of({
160 relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) {
161 const electronApp = yield* ElectronApp.ElectronApp;
162 const environment = yield* DesktopEnvironment.DesktopEnvironment;
163 const state = yield* DesktopState.DesktopState;
164 yield* logLifecycleInfo("desktop relaunch requested", { reason });
165 yield* Effect.gen(function* () {
166 yield* Effect.yieldNow;
167 yield* Ref.set(state.quitting, true);
168 yield* requestDesktopShutdownAndWait();
169 if (environment.isDevelopment) {
170 yield* electronApp.exit(75);
171 return;
172 }
173 yield* electronApp.relaunch({
174 execPath: process.execPath,
175 args: process.argv.slice(1),
176 });
177 yield* electronApp.exit(0);
178 }).pipe(
179 Effect.catchCause((cause) =>
180 logLifecycleError("desktop relaunch failed", {
181 cause: Cause.pretty(cause),
182 }),
183 ),
184 Effect.forkDetach,
185 Effect.asVoid,
186 );
187 }),
188 register: Effect.gen(function* () {
189 const desktopWindow = yield* DesktopWindow.DesktopWindow;
190 const electronApp = yield* ElectronApp.ElectronApp;
191 const electronTheme = yield* ElectronTheme.ElectronTheme;
192 const environment = yield* DesktopEnvironment.DesktopEnvironment;
193 const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
194 const runEffect = Effect.runPromiseWith(context);
195 let quitAllowed = false;
196 yield* electronTheme.onUpdated(() => {
197 void runEffect(
198 desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
199 );
200 });
201 yield* electronApp.on("before-quit", (event: Electron.Event) => {
202 handleBeforeQuit(
203 event,
204 runEffect,
205 () => quitAllowed,
206 () => {
207 quitAllowed = true;
208 },
209 );
127const make = DesktopLifecycle.of({
128 relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) {
129 const electronApp = yield* ElectronApp.ElectronApp;
130 const environment = yield* DesktopEnvironment.DesktopEnvironment;
131 const state = yield* DesktopState.DesktopState;
132 yield* logLifecycleInfo("desktop relaunch requested", { reason });
133 yield* Effect.gen(function* () {
134 yield* Effect.yieldNow;
135 yield* Ref.set(state.quitting, true);
136 yield* requestDesktopShutdownAndWait();
137 if (environment.isDevelopment) {
138 yield* electronApp.exit(75);
139 return;
140 }
141 yield* electronApp.relaunch({
142 execPath: process.execPath,
143 args: process.argv.slice(1),
210144 });
211 yield* electronApp.on("activate", () => {
212 void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate")));
145 yield* electronApp.exit(0);
146 }).pipe(
147 Effect.catchCause((cause) =>
148 logLifecycleError("desktop relaunch failed", {
149 cause: Cause.pretty(cause),
150 }),
151 ),
152 Effect.forkDetach,
153 Effect.asVoid,
154 );
155 }),
156 register: Effect.gen(function* () {
157 const desktopWindow = yield* DesktopWindow.DesktopWindow;
158 const electronApp = yield* ElectronApp.ElectronApp;
159 const electronTheme = yield* ElectronTheme.ElectronTheme;
160 const environment = yield* DesktopEnvironment.DesktopEnvironment;
161 const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
162 const runEffect = Effect.runPromiseWith(context);
163 let quitAllowed = false;
164 yield* electronTheme.onUpdated(() => {
165 void runEffect(
166 desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
167 );
168 });
169 yield* electronApp.on("before-quit", (event: Electron.Event) => {
170 handleBeforeQuit(
171 event,
172 runEffect,
173 () => quitAllowed,
174 () => {
175 quitAllowed = true;
176 },
177 );
178 });
179 yield* electronApp.on("activate", () => {
180 void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate")));
181 });
182 yield* electronApp.on("window-all-closed", () => {
183 void runEffect(
184 Effect.gen(function* () {
185 const app = yield* ElectronApp.ElectronApp;
186 const state = yield* DesktopState.DesktopState;
187 if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) {
188 yield* app.quit;
189 }
190 }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")),
191 );
192 });
193
194 if (environment.platform !== "win32") {
195 yield* addScopedListener(process, "SIGINT", () => {
196 quitFromSignal("SIGINT", runEffect);
213197 });
214 yield* electronApp.on("window-all-closed", () => {
215 void runEffect(
216 Effect.gen(function* () {
217 const app = yield* ElectronApp.ElectronApp;
218 const state = yield* DesktopState.DesktopState;
219 if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) {
220 yield* app.quit;
221 }
222 }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")),
223 );
198 yield* addScopedListener(process, "SIGTERM", () => {
199 quitFromSignal("SIGTERM", runEffect);
224200 });
201 }
202 }).pipe(Effect.withSpan("desktop.lifecycle.register")),
203});
225204
226 if (environment.platform !== "win32") {
227 yield* addScopedListener(process, "SIGINT", () => {
228 quitFromSignal("SIGINT", runEffect);
229 });
230 yield* addScopedListener(process, "SIGTERM", () => {
231 quitFromSignal("SIGTERM", runEffect);
232 });
233 }
234 }).pipe(Effect.withSpan("desktop.lifecycle.register")),
235 }),
236);
205export const layer = Layer.succeed(DesktopLifecycle, make);
apps/desktop/src/app/DesktopObservability.ts+7-277
......@@ -1,52 +1,20 @@
11import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
22import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as DateTime from "effect/DateTime";
63import * as Effect from "effect/Effect";
74import * as FileSystem from "effect/FileSystem";
85import * as Layer from "effect/Layer";
96import * as Logger from "effect/Logger";
107import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
138import * as References from "effect/References";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
179import * as Tracer from "effect/Tracer";
1810import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
1911
12import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts";
2013import * as DesktopEnvironment from "./DesktopEnvironment.ts";
2114
22const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
23const DESKTOP_LOG_FILE_MAX_FILES = 10;
24const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";
2515const DESKTOP_TRACE_BATCH_WINDOW_MS = 200;
2616
27export interface RotatingLogFileWriter {
28 readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;
29 readonly writeText: (chunk: string) => Effect.Effect<void>;
30}
31
32export interface DesktopBackendOutputLogShape {
33 readonly writeSessionBoundary: (input: {
34 readonly phase: "START" | "END";
35 readonly details: string;
36 }) => Effect.Effect<void>;
37 readonly writeOutputChunk: (
38 streamName: "stdout" | "stderr",
39 chunk: Uint8Array,
40 ) => Effect.Effect<void>;
41}
42
43export class DesktopBackendOutputLog extends Context.Service<
44 DesktopBackendOutputLog,
45 DesktopBackendOutputLogShape
46>()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLog") {}
47
48const textEncoder = new TextEncoder();
49const textDecoder = new TextDecoder();
17export { DesktopBackendOutputLog } from "./DesktopBackendOutputLog.ts";
5018
5119export type DesktopLogAnnotations = Record<string, unknown>;
5220
......@@ -82,160 +50,6 @@ export function makeComponentLogger(component: string): DesktopComponentLogger {
8250 };
8351}
8452
85class DesktopLogFileWriterConfigurationError extends Data.TaggedError(
86 "DesktopLogFileWriterConfigurationError",
87)<{
88 readonly option: "maxBytes" | "maxFiles";
89 readonly value: number;
90}> {
91 override get message() {
92 return `${this.option} must be >= 1 (received ${this.value})`;
93 }
94}
95
96type DesktopLogFileWriterError =
97 | DesktopLogFileWriterConfigurationError
98 | PlatformError.PlatformError;
99
100const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim();
101
102const DesktopBackendChildLogRecord = Schema.Struct({
103 message: Schema.String,
104 level: Schema.Literals(["INFO", "ERROR"]),
105 timestamp: Schema.String,
106 annotations: Schema.Record(Schema.String, Schema.Unknown),
107 spans: Schema.Record(Schema.String, Schema.Unknown),
108 fiberId: Schema.String,
109});
110
111const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(
112 Schema.fromJsonString(DesktopBackendChildLogRecord),
113);
114
115const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = {
116 writeSessionBoundary: () => Effect.void,
117 writeOutputChunk: () => Effect.void,
118};
119
120const currentDesktopRunId = Effect.gen(function* () {
121 const annotations = yield* References.CurrentLogAnnotations;
122 const runId = annotations.runId;
123 return typeof runId === "string" && runId.length > 0 ? runId : "unknown";
124});
125
126const refreshFileSize = (
127 fileSystem: FileSystem.FileSystem,
128 filePath: string,
129): Effect.Effect<number, never> =>
130 fileSystem.stat(filePath).pipe(
131 Effect.map((stat) => Number(stat.size)),
132 Effect.orElseSucceed(() => 0),
133 );
134
135const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: {
136 readonly filePath: string;
137 readonly maxBytes?: number;
138 readonly maxFiles?: number;
139}): Effect.fn.Return<
140 RotatingLogFileWriter,
141 DesktopLogFileWriterError,
142 FileSystem.FileSystem | Path.Path
143> {
144 const fileSystem = yield* FileSystem.FileSystem;
145 const path = yield* Path.Path;
146 const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES;
147 const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES;
148 const directory = path.dirname(input.filePath);
149 const baseName = path.basename(input.filePath);
150
151 if (maxBytes < 1) {
152 return yield* new DesktopLogFileWriterConfigurationError({
153 option: "maxBytes",
154 value: maxBytes,
155 });
156 }
157 if (maxFiles < 1) {
158 return yield* new DesktopLogFileWriterConfigurationError({
159 option: "maxFiles",
160 value: maxFiles,
161 });
162 }
163
164 yield* fileSystem.makeDirectory(directory, { recursive: true });
165
166 const withSuffix = (index: number) => `${input.filePath}.${index}`;
167 const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath));
168 const mutex = yield* Semaphore.make(1);
169
170 const pruneOverflowBackups = Effect.gen(function* () {
171 const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
172 for (const entry of entries) {
173 if (!entry.startsWith(`${baseName}.`)) continue;
174 const suffix = Number(entry.slice(baseName.length + 1));
175 if (!Number.isInteger(suffix) || suffix <= maxFiles) continue;
176 yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore);
177 }
178 });
179
180 const rotate = Effect.gen(function* () {
181 yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore);
182 for (let index = maxFiles - 1; index >= 1; index -= 1) {
183 const source = withSuffix(index);
184 const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false));
185 if (sourceExists) {
186 yield* fileSystem.rename(source, withSuffix(index + 1));
187 }
188 }
189 const currentExists = yield* fileSystem
190 .exists(input.filePath)
191 .pipe(Effect.orElseSucceed(() => false));
192 if (currentExists) {
193 yield* fileSystem.rename(input.filePath, withSuffix(1));
194 }
195 yield* Ref.set(currentSize, 0);
196 }).pipe(
197 Effect.catch(() =>
198 refreshFileSize(fileSystem, input.filePath).pipe(
199 Effect.flatMap((size) => Ref.set(currentSize, size)),
200 ),
201 ),
202 );
203
204 const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => {
205 if (chunk.byteLength === 0) return Effect.void;
206
207 return mutex.withPermits(1)(
208 Effect.gen(function* () {
209 const beforeSize = yield* Ref.get(currentSize);
210 if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) {
211 yield* rotate;
212 }
213
214 yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" });
215 const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength;
216 yield* Ref.set(currentSize, afterSize);
217
218 if (afterSize > maxBytes) {
219 yield* rotate;
220 }
221 }).pipe(
222 Effect.catch(() =>
223 refreshFileSize(fileSystem, input.filePath).pipe(
224 Effect.flatMap((size) => Ref.set(currentSize, size)),
225 ),
226 ),
227 ),
228 );
229 };
230
231 yield* pruneOverflowBackups;
232
233 return {
234 writeBytes,
235 writeText: (chunk) => writeBytes(textEncoder.encode(chunk)),
236 } satisfies RotatingLogFileWriter;
237});
238
23953const readPersistedOtlpTracesUrl: Effect.Effect<
24054 Option.Option<string>,
24155 never,
......@@ -260,90 +74,6 @@ const resolveOtlpTracesUrl = Effect.gen(function* () {
26074 return yield* readPersistedOtlpTracesUrl;
26175});
26276
263const writeDevelopmentConsoleOutput = (
264 streamName: "stdout" | "stderr",
265 chunk: Uint8Array,
266): Effect.Effect<void> =>
267 Effect.sync(() => {
268 const output = streamName === "stderr" ? process.stderr : process.stdout;
269 output.write(chunk);
270 }).pipe(Effect.ignore);
271
272const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")(
273 function* (
274 logFile: RotatingLogFileWriter,
275 input: {
276 readonly message: string;
277 readonly level: "INFO" | "ERROR";
278 readonly annotations: Record<string, unknown>;
279 },
280 ): Effect.fn.Return<void> {
281 return yield* Effect.gen(function* () {
282 const timestamp = DateTime.formatIso(yield* DateTime.now);
283 const encoded = yield* encodeDesktopBackendChildLogRecord({
284 message: input.message,
285 level: input.level,
286 timestamp,
287 annotations: input.annotations,
288 spans: {},
289 fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID,
290 });
291 yield* logFile.writeText(`${encoded}\n`);
292 }).pipe(Effect.ignore({ log: true }));
293 },
294);
295
296const backendOutputLogLayer = Layer.effect(
297 DesktopBackendOutputLog,
298 Effect.gen(function* () {
299 const environment = yield* DesktopEnvironment.DesktopEnvironment;
300
301 const writer = yield* makeRotatingLogFileWriter({
302 filePath: environment.path.join(environment.logDir, "server-child.log"),
303 }).pipe(Effect.option);
304
305 return Option.match(writer, {
306 onNone: () => DesktopBackendOutputLogNoop,
307 onSome: (logFile) =>
308 ({
309 writeSessionBoundary: Effect.fn(
310 "desktop.observability.backendOutput.writeSessionBoundary",
311 )(function* ({ phase, details }) {
312 const runId = yield* currentDesktopRunId;
313 yield* writeBackendChildLogRecord(logFile, {
314 message: `backend child process session ${phase.toLowerCase()}`,
315 level: "INFO",
316 annotations: {
317 component: "desktop-backend-child",
318 runId,
319 phase,
320 details: sanitizeLogValue(details),
321 },
322 });
323 }),
324 writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")(
325 function* (streamName, chunk) {
326 if (environment.isDevelopment) {
327 yield* writeDevelopmentConsoleOutput(streamName, chunk);
328 }
329 const runId = yield* currentDesktopRunId;
330 yield* writeBackendChildLogRecord(logFile, {
331 message: "backend child process output",
332 level: streamName === "stderr" ? "ERROR" : "INFO",
333 annotations: {
334 component: "desktop-backend-child",
335 runId,
336 stream: streamName,
337 text: textDecoder.decode(chunk),
338 },
339 });
340 },
341 ),
342 }) satisfies DesktopBackendOutputLogShape,
343 });
344 }),
345);
346
34777const desktopLoggerLayer = Layer.mergeAll(
34878 Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }),
34979 Layer.succeed(References.MinimumLogLevel, "Info"),
......@@ -356,8 +86,8 @@ const tracerLayer = Layer.unwrap(
35686 const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson");
35787 const sink = yield* makeTraceSink({
35888 filePath: tracePath,
359 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
360 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
89 maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES,
90 maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES,
36191 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
36292 });
36393 const delegate = Option.isNone(otlpTracesUrl)
......@@ -375,8 +105,8 @@ const tracerLayer = Layer.unwrap(
375105 });
376106 const tracer = yield* makeLocalFileTracer({
377107 filePath: tracePath,
378 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
379 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
108 maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES,
109 maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES,
380110 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
381111 sink,
382112 ...(delegate ? { delegate } : {}),
......@@ -387,7 +117,7 @@ const tracerLayer = Layer.unwrap(
387117).pipe(Layer.provideMerge(OtlpSerialization.layerJson));
388118
389119export const layer = Layer.mergeAll(
390 backendOutputLogLayer,
120 DesktopBackendOutputLogModule.layer,
391121 desktopLoggerLayer,
392122 tracerLayer,
393123 Layer.succeed(Tracer.MinimumTraceLevel, "Info"),
apps/desktop/src/app/DesktopShutdown.ts created+35
......@@ -0,0 +1,35 @@
1import * as Context from "effect/Context";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6
7export class DesktopShutdown extends Context.Service<
8 DesktopShutdown,
9 {
10 readonly request: Effect.Effect<void>;
11 readonly awaitRequest: Effect.Effect<void>;
12 readonly markComplete: Effect.Effect<void>;
13 readonly awaitComplete: Effect.Effect<void>;
14 readonly isComplete: Effect.Effect<boolean>;
15 }
16>()("@t3tools/desktop/app/DesktopShutdown") {}
17
18const make = Effect.gen(function* () {
19 const requested = yield* Deferred.make<void>();
20 const completed = yield* Deferred.make<void>();
21 const completedRef = yield* Ref.make(false);
22
23 return DesktopShutdown.of({
24 request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid),
25 awaitRequest: Deferred.await(requested),
26 markComplete: Ref.set(completedRef, true).pipe(
27 Effect.andThen(Deferred.succeed(completed, undefined)),
28 Effect.asVoid,
29 ),
30 awaitComplete: Deferred.await(completed),
31 isComplete: Ref.get(completedRef),
32 });
33});
34
35export const layer = Layer.effect(DesktopShutdown, make);
apps/desktop/src/app/DesktopState.ts+12-14
......@@ -3,19 +3,17 @@ import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import * as Ref from "effect/Ref";
55
6export interface DesktopStateShape {
7 readonly backendReady: Ref.Ref<boolean>;
8 readonly quitting: Ref.Ref<boolean>;
9}
6export class DesktopState extends Context.Service<
7 DesktopState,
8 {
9 readonly backendReady: Ref.Ref<boolean>;
10 readonly quitting: Ref.Ref<boolean>;
11 }
12>()("@t3tools/desktop/app/DesktopState") {}
1013
11export class DesktopState extends Context.Service<DesktopState, DesktopStateShape>()(
12 "@t3tools/desktop/app/DesktopState",
13) {}
14const make = Effect.all({
15 backendReady: Ref.make(false),
16 quitting: Ref.make(false),
17});
1418
15export const layer = Layer.effect(
16 DesktopState,
17 Effect.all({
18 backendReady: Ref.make(false),
19 quitting: Ref.make(false),
20 }),
21);
19export const layer = Layer.effect(DesktopState, make);
apps/desktop/src/backend/DesktopBackendManager.test.ts+5-5
......@@ -104,9 +104,9 @@ function decodeBootstrap(raw: string) {
104104function makeManagerLayer(input: {
105105 readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
106106 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
107 readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>;
108 readonly desktopState?: DesktopState.DesktopStateShape;
109 readonly desktopWindow?: Partial<DesktopWindow.DesktopWindowShape>;
107 readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLog["Service"]>;
108 readonly desktopState?: DesktopState.DesktopState["Service"];
109 readonly desktopWindow?: Partial<DesktopWindow.DesktopWindow["Service"]>;
110110 readonly config?: DesktopBackendManager.DesktopBackendStartConfig;
111111}) {
112112 return DesktopBackendManager.layer.pipe(
......@@ -127,7 +127,7 @@ function makeManagerLayer(input: {
127127 writeSessionBoundary: () => Effect.void,
128128 writeOutputChunk: () => Effect.void,
129129 ...input.backendOutputLog,
130 } satisfies DesktopObservability.DesktopBackendOutputLogShape),
130 } satisfies DesktopObservability.DesktopBackendOutputLog["Service"]),
131131 Layer.succeed(DesktopWindow.DesktopWindow, {
132132 createMain: Effect.die("unexpected createMain"),
133133 ensureMain: Effect.die("unexpected ensureMain"),
......@@ -138,7 +138,7 @@ function makeManagerLayer(input: {
138138 dispatchMenuAction: () => Effect.void,
139139 syncAppearance: Effect.void,
140140 ...input.desktopWindow,
141 } satisfies DesktopWindow.DesktopWindowShape),
141 } satisfies DesktopWindow.DesktopWindow["Service"]),
142142 ),
143143 ),
144144 );
apps/desktop/src/main.ts+2-3
......@@ -14,7 +14,6 @@ import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
1414import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
1515import serverPackageJson from "../../server/package.json" with { type: "json" };
1616
17import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";
1817import * as DesktopIpc from "./ipc/DesktopIpc.ts";
1918import * as ElectronApp from "./electron/ElectronApp.ts";
2019import * as ElectronDialog from "./electron/ElectronDialog.ts";
......@@ -68,8 +67,8 @@ const desktopEnvironmentLayer = Layer.unwrap(
6867);
6968
7069const resolveDesktopSshCliRunner = (
71 environment: DesktopEnvironment.DesktopEnvironmentShape,
72 settings: DesktopSettingsValue,
70 environment: DesktopEnvironment.DesktopEnvironment["Service"],
71 settings: DesktopAppSettings.DesktopSettings,
7372): RemoteT3RunnerOptions => {
7473 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
7574 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
apps/desktop/src/preview/Manager.test.ts+2-2
......@@ -59,7 +59,7 @@ const environmentLayer = Layer.succeed(
5959 DesktopEnvironment.DesktopEnvironment,
6060 DesktopEnvironment.DesktopEnvironment.of({
6161 browserArtifactsDir: "/tmp/t3/dev/browser-artifacts",
62 } as DesktopEnvironment.DesktopEnvironmentShape),
62 } as DesktopEnvironment.DesktopEnvironment["Service"]),
6363);
6464
6565const fileSystemLayer = FileSystem.layerNoop({
......@@ -82,7 +82,7 @@ const layer = PreviewManager.layer.pipe(
8282
8383const withManager = <A>(
8484 use: (
85 manager: PreviewManager.PreviewManagerShape,
85 manager: PreviewManager.PreviewManager["Service"],
8686 ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>,
8787) =>
8888 Effect.gen(function* () {
apps/desktop/src/shell/DesktopShellEnvironment.test.ts+1-1
......@@ -64,7 +64,7 @@ function runShellEnvironment(input: {
6464 DesktopEnvironment.DesktopEnvironment,
6565 DesktopEnvironment.DesktopEnvironment.of({
6666 platform: input.platform,
67 } as DesktopEnvironment.DesktopEnvironmentShape),
67 } as DesktopEnvironment.DesktopEnvironment["Service"]),
6868 );
6969 const spawnerLayer = Layer.succeed(
7070 ChildProcessSpawner.ChildProcessSpawner,
apps/desktop/src/updates/DesktopUpdates.ts+1-1
......@@ -127,7 +127,7 @@ function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYm
127127function createBaseUpdateState(
128128 channel: DesktopUpdateChannel,
129129 enabled: boolean,
130 environment: DesktopEnvironment.DesktopEnvironmentShape,
130 environment: DesktopEnvironment.DesktopEnvironment["Service"],
131131): DesktopUpdateState {
132132 return {
133133 ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel),
apps/desktop/src/window/DesktopWindow.test.ts+6-6
......@@ -91,7 +91,7 @@ const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
9191 png: Option.none<string>(),
9292 }),
9393 resolveResourcePath: () => Effect.succeed(Option.none<string>()),
94} satisfies DesktopAssets.DesktopAssetsShape);
94} satisfies DesktopAssets.DesktopAssets["Service"]);
9595
9696const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, {
9797 getState: Effect.die("unexpected getState"),
......@@ -106,19 +106,19 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe
106106 setMode: () => Effect.die("unexpected setMode"),
107107 setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
108108 getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"),
109} satisfies DesktopServerExposure.DesktopServerExposureShape);
109} satisfies DesktopServerExposure.DesktopServerExposure["Service"]);
110110
111111const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, {
112112 setApplicationMenu: () => Effect.void,
113113 popupTemplate: () => Effect.void,
114114 showContextMenu: () => Effect.succeed(Option.none()),
115} satisfies ElectronMenu.ElectronMenuShape);
115} satisfies ElectronMenu.ElectronMenu["Service"]);
116116
117117const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
118118 shouldUseDarkColors: Effect.succeed(false),
119119 setSource: () => Effect.void,
120120 onUpdated: () => Effect.void,
121} satisfies ElectronTheme.ElectronThemeShape);
121} satisfies ElectronTheme.ElectronTheme["Service"]);
122122
123123const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(
124124 Layer.provide(
......@@ -156,7 +156,7 @@ function makeTestLayer(input: {
156156 sendAll: () => Effect.void,
157157 destroyAll: Effect.void,
158158 syncAllAppearance: (sync) => sync(input.window),
159 } satisfies ElectronWindow.ElectronWindowShape);
159 } satisfies ElectronWindow.ElectronWindow["Service"]);
160160
161161 return DesktopWindow.layer.pipe(
162162 Layer.provide(
......@@ -173,7 +173,7 @@ function makeTestLayer(input: {
173173 return true;
174174 }),
175175 copyText: () => Effect.void,
176 } satisfies ElectronShell.ElectronShellShape),
176 } satisfies ElectronShell.ElectronShell["Service"]),
177177 electronThemeLayer,
178178 electronWindowLayer,
179179 Layer.mock(PreviewManager.PreviewManager)({