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) =>...@@ -63,7 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
63 }),63 }),
64 appendCommandLineSwitch: () => Effect.void,64 appendCommandLineSwitch: () => Effect.void,
65 on: () => Effect.void,65 on: () => Effect.void,
66 } satisfies ElectronApp.ElectronAppShape);66 } satisfies ElectronApp.ElectronApp["Service"]);
6767
68const makeAssetsLayer = (png: Option.Option<string>) =>68const makeAssetsLayer = (png: Option.Option<string>) =>
69 Layer.succeed(DesktopAssets.DesktopAssets, {69 Layer.succeed(DesktopAssets.DesktopAssets, {
...@@ -73,7 +73,7 @@ const makeAssetsLayer = (png: Option.Option<string>) =>...@@ -73,7 +73,7 @@ const makeAssetsLayer = (png: Option.Option<string>) =>
73 png,73 png,
74 }),74 }),
75 resolveResourcePath: () => Effect.succeed(Option.none()),75 resolveResourcePath: () => Effect.succeed(Option.none()),
76 } satisfies DesktopAssets.DesktopAssetsShape);76 } satisfies DesktopAssets.DesktopAssets["Service"]);
7777
78const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => {78const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => {
79 const { env, ...environmentOverrides } = overrides;79 const { env, ...environmentOverrides } = overrides;
apps/desktop/src/app/DesktopAppIdentity.ts+4-6
...@@ -18,14 +18,12 @@ const AppPackageMetadata = Schema.Struct({...@@ -18,14 +18,12 @@ const AppPackageMetadata = Schema.Struct({
18});18});
19const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata));19const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata));
2020
21export interface DesktopAppIdentityShape {
22 readonly resolveUserDataPath: Effect.Effect<string>;
23 readonly configure: Effect.Effect<void>;
24}
25
26export class DesktopAppIdentity extends Context.Service<21export class DesktopAppIdentity extends Context.Service<
27 DesktopAppIdentity,22 DesktopAppIdentity,
28 DesktopAppIdentityShape23 {
24 readonly resolveUserDataPath: Effect.Effect<string>;
25 readonly configure: Effect.Effect<void>;
26 }
29>()("@t3tools/desktop/app/DesktopAppIdentity") {}27>()("@t3tools/desktop/app/DesktopAppIdentity") {}
3028
31const normalizeCommitHash = (value: string): Option.Option<string> => {29const normalizeCommitHash = (value: string): Option.Option<string> => {
apps/desktop/src/app/DesktopAssets.ts+7-8
...@@ -12,14 +12,13 @@ export interface DesktopIconPaths {...@@ -12,14 +12,13 @@ export interface DesktopIconPaths {
12 readonly png: Option.Option<string>;12 readonly png: Option.Option<string>;
13}13}
1414
15export interface DesktopAssetsShape {15export class DesktopAssets extends Context.Service<
16 readonly iconPaths: Effect.Effect<DesktopIconPaths>;16 DesktopAssets,
17 readonly resolveResourcePath: (fileName: string) => Effect.Effect<Option.Option<string>>;17 {
18}18 readonly iconPaths: Effect.Effect<DesktopIconPaths>;
1919 readonly resolveResourcePath: (fileName: string) => Effect.Effect<Option.Option<string>>;
20export class DesktopAssets extends Context.Service<DesktopAssets, DesktopAssetsShape>()(20 }
21 "@t3tools/desktop/app/DesktopAssets",21>()("@t3tools/desktop/app/DesktopAssets") {}
22) {}
2322
24const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* (23const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* (
25 fileName: string,24 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", () => ({...@@ -21,10 +21,6 @@ vi.mock("@clerk/electron/storage", () => ({
21 storage: storageMock,21 storage: storageMock,
22}));22}));
2323
24import {
25 createDesktopClerkBridge,
26 resolveDesktopClerkFrontendApiHostname,
27} from "./DesktopClerk.ts";
28import * as DesktopClerk from "./DesktopClerk.ts";24import * as DesktopClerk from "./DesktopClerk.ts";
29import * as DesktopEnvironment from "./DesktopEnvironment.ts";25import * as DesktopEnvironment from "./DesktopEnvironment.ts";
3026
...@@ -32,9 +28,12 @@ describe("DesktopClerk", () => {...@@ -32,9 +28,12 @@ describe("DesktopClerk", () => {
32 it("derives the Clerk Frontend API hostname used by the desktop CSP", () => {28 it("derives the Clerk Frontend API hostname used by the desktop CSP", () => {
33 const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`;29 const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`;
3430
35 assert.equal(resolveDesktopClerkFrontendApiHostname(publishableKey), "clerk.t3.codes");31 assert.equal(
36 assert.equal(resolveDesktopClerkFrontendApiHostname(""), undefined);32 DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey),
37 assert.equal(resolveDesktopClerkFrontendApiHostname("invalid"), undefined);33 "clerk.t3.codes",
34 );
35 assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined);
36 assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined);
38 });37 });
3938
40 it.effect("acquires and releases the SDK bridge with the layer", () => {39 it.effect("acquires and releases the SDK bridge with the layer", () => {
...@@ -44,7 +43,7 @@ describe("DesktopClerk", () => {...@@ -44,7 +43,7 @@ describe("DesktopClerk", () => {
44 const environment = DesktopEnvironment.DesktopEnvironment.of({43 const environment = DesktopEnvironment.DesktopEnvironment.of({
45 stateDir: "/tmp/t3-state",44 stateDir: "/tmp/t3-state",
46 isDevelopment: true,45 isDevelopment: true,
47 } as unknown as DesktopEnvironment.DesktopEnvironmentShape);46 } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
4847
49 return Effect.gen(function* () {48 return Effect.gen(function* () {
50 yield* Effect.scoped(49 yield* Effect.scoped(
...@@ -78,7 +77,7 @@ describe("DesktopClerk", () => {...@@ -78,7 +77,7 @@ describe("DesktopClerk", () => {
78 storageMock.mockReturnValue(storageAdapter);77 storageMock.mockReturnValue(storageAdapter);
79 createClerkBridgeMock.mockReturnValue(bridge);78 createClerkBridgeMock.mockReturnValue(bridge);
8079
81 assert.equal(createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge);80 assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge);
82 assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]);81 assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]);
83 assert.deepEqual(createClerkBridgeMock.mock.calls, [82 assert.deepEqual(createClerkBridgeMock.mock.calls, [
84 [83 [
apps/desktop/src/app/DesktopClerk.ts+10-11
...@@ -14,17 +14,16 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";...@@ -14,17 +14,16 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1414
15declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;15declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
1616
17export interface DesktopClerkShape {17export class DesktopClerk extends Context.Service<
18 readonly configure: Effect.Effect<18 DesktopClerk,
19 void,19 {
20 never,20 readonly configure: Effect.Effect<
21 ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope21 void,
22 >;22 never,
23}23 ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope
2424 >;
25export class DesktopClerk extends Context.Service<DesktopClerk, DesktopClerkShape>()(25 }
26 "@t3tools/desktop/app/DesktopClerk",26>()("@t3tools/desktop/app/DesktopClerk") {}
27) {}
2827
29export function resolveDesktopClerkFrontendApiHostname(28export function resolveDesktopClerkFrontendApiHostname(
30 publishableKey: string | undefined,29 publishableKey: string | undefined,
apps/desktop/src/app/DesktopEnvironment.ts+50-55
...@@ -11,10 +11,7 @@ import * as Layer from "effect/Layer";...@@ -11,10 +11,7 @@ import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as Path from "effect/Path";12import * as Path from "effect/Path";
1313
14import {14import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
15 type DesktopSettings,
16 resolveDefaultDesktopSettings,
17} from "../settings/DesktopAppSettings.ts";
18import * as DesktopConfig from "./DesktopConfig.ts";15import * as DesktopConfig from "./DesktopConfig.ts";
19import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";16import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
2017
...@@ -30,55 +27,53 @@ export interface MakeDesktopEnvironmentInput {...@@ -30,55 +27,53 @@ export interface MakeDesktopEnvironmentInput {
30 readonly runningUnderArm64Translation: boolean;27 readonly runningUnderArm64Translation: boolean;
31}28}
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
79export class DesktopEnvironment extends Context.Service<30export class DesktopEnvironment extends Context.Service<
80 DesktopEnvironment,31 DesktopEnvironment,
81 DesktopEnvironmentShape32 {
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 }
82>()("@t3tools/desktop/app/DesktopEnvironment") {}77>()("@t3tools/desktop/app/DesktopEnvironment") {}
8378
84const APP_BASE_NAME = "T3 Code";79const APP_BASE_NAME = "T3 Code";
...@@ -136,9 +131,9 @@ function resolveDesktopRuntimeInfo(input: {...@@ -136,9 +131,9 @@ function resolveDesktopRuntimeInfo(input: {
136 };131 };
137}132}
138133
139const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (134const make = Effect.fn("desktop.environment.make")(function* (
140 input: MakeDesktopEnvironmentInput,135 input: MakeDesktopEnvironmentInput,
141): Effect.fn.Return<DesktopEnvironmentShape, Config.ConfigError, Path.Path> {136): Effect.fn.Return<DesktopEnvironment["Service"], Config.ConfigError, Path.Path> {
142 const path = yield* Path.Path;137 const path = yield* Path.Path;
143 const config = yield* DesktopConfig.DesktopConfig;138 const config = yield* DesktopConfig.DesktopConfig;
144 const homeDirectory = input.homeDirectory;139 const homeDirectory = input.homeDirectory;
...@@ -208,7 +203,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (...@@ -208,7 +203,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
208 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",203 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
209 userDataDirName,204 userDataDirName,
210 legacyUserDataDirName,205 legacyUserDataDirName,
211 defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion),206 defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion),
212 runtimeInfo: resolveDesktopRuntimeInfo({207 runtimeInfo: resolveDesktopRuntimeInfo({
213 platform: input.platform,208 platform: input.platform,
214 processArch: input.processArch,209 processArch: input.processArch,
...@@ -250,4 +245,4 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (...@@ -250,4 +245,4 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
250});245});
251246
252export const layer = (input: MakeDesktopEnvironmentInput) =>247export 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 @@...@@ -1,7 +1,6 @@
1import * as Cause from "effect/Cause";1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Deferred from "effect/Deferred";
5import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
7import * as Scope from "effect/Scope";6import * as Scope from "effect/Scope";
...@@ -10,63 +9,34 @@ import type * as Electron from "electron";...@@ -10,63 +9,34 @@ import type * as Electron from "electron";
109
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";10import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12import * as DesktopObservability from "./DesktopObservability.ts";11import * as DesktopObservability from "./DesktopObservability.ts";
12import * as DesktopShutdownModule from "./DesktopShutdown.ts";
13import * as ElectronApp from "../electron/ElectronApp.ts";13import * as ElectronApp from "../electron/ElectronApp.ts";
14import * as ElectronTheme from "../electron/ElectronTheme.ts";14import * as ElectronTheme from "../electron/ElectronTheme.ts";
15import * as DesktopState from "./DesktopState.ts";15import * as DesktopState from "./DesktopState.ts";
16import * as DesktopWindow from "../window/DesktopWindow.ts";16import * as DesktopWindow from "../window/DesktopWindow.ts";
1717
18export interface DesktopShutdownShape {18export { DesktopShutdown, layer as layerShutdown } from "./DesktopShutdown.ts";
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);
4819
49export type DesktopLifecycleRuntimeServices =20export type DesktopLifecycleRuntimeServices =
50 | DesktopEnvironment.DesktopEnvironment21 | DesktopEnvironment.DesktopEnvironment
51 | DesktopShutdown22 | DesktopShutdownModule.DesktopShutdown
52 | DesktopState.DesktopState23 | DesktopState.DesktopState
53 | DesktopWindow.DesktopWindow24 | DesktopWindow.DesktopWindow
54 | ElectronApp.ElectronApp25 | ElectronApp.ElectronApp
55 | ElectronTheme.ElectronTheme;26 | 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
64/**28/**
65 * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme29 * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme
66 */30 */
67export class DesktopLifecycle extends Context.Service<DesktopLifecycle, DesktopLifecycleShape>()(31export class DesktopLifecycle extends Context.Service<
68 "@t3tools/desktop/app/DesktopLifecycle",32 DesktopLifecycle,
69) {}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
71const { logInfo: logLifecycleInfo, logError: logLifecycleError } =41const { logInfo: logLifecycleInfo, logError: logLifecycleError } =
72 DesktopObservability.makeComponentLogger("desktop-lifecycle");42 DesktopObservability.makeComponentLogger("desktop-lifecycle");
...@@ -93,8 +63,8 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(...@@ -93,8 +63,8 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(
93}63}
9464
95const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(65const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
96 function* (): Effect.fn.Return<void, never, DesktopShutdown> {66 function* (): Effect.fn.Return<void, never, DesktopShutdownModule.DesktopShutdown> {
97 const shutdown = yield* DesktopShutdown;67 const shutdown = yield* DesktopShutdownModule.DesktopShutdown;
98 yield* shutdown.request;68 yield* shutdown.request;
99 yield* shutdown.awaitComplete;69 yield* shutdown.awaitComplete;
100 },70 },
...@@ -154,83 +124,82 @@ function quitFromSignal(...@@ -154,83 +124,82 @@ function quitFromSignal(
154 );124 );
155}125}
156126
157export const layer = Layer.succeed(127const make = DesktopLifecycle.of({
158 DesktopLifecycle,128 relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) {
159 DesktopLifecycle.of({129 const electronApp = yield* ElectronApp.ElectronApp;
160 relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) {130 const environment = yield* DesktopEnvironment.DesktopEnvironment;
161 const electronApp = yield* ElectronApp.ElectronApp;131 const state = yield* DesktopState.DesktopState;
162 const environment = yield* DesktopEnvironment.DesktopEnvironment;132 yield* logLifecycleInfo("desktop relaunch requested", { reason });
163 const state = yield* DesktopState.DesktopState;133 yield* Effect.gen(function* () {
164 yield* logLifecycleInfo("desktop relaunch requested", { reason });134 yield* Effect.yieldNow;
165 yield* Effect.gen(function* () {135 yield* Ref.set(state.quitting, true);
166 yield* Effect.yieldNow;136 yield* requestDesktopShutdownAndWait();
167 yield* Ref.set(state.quitting, true);137 if (environment.isDevelopment) {
168 yield* requestDesktopShutdownAndWait();138 yield* electronApp.exit(75);
169 if (environment.isDevelopment) {139 return;
170 yield* electronApp.exit(75);140 }
171 return;141 yield* electronApp.relaunch({
172 }142 execPath: process.execPath,
173 yield* electronApp.relaunch({143 args: process.argv.slice(1),
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 );
210 });144 });
211 yield* electronApp.on("activate", () => {145 yield* electronApp.exit(0);
212 void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate")));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);
213 });197 });
214 yield* electronApp.on("window-all-closed", () => {198 yield* addScopedListener(process, "SIGTERM", () => {
215 void runEffect(199 quitFromSignal("SIGTERM", 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 );
224 });200 });
201 }
202 }).pipe(Effect.withSpan("desktop.lifecycle.register")),
203});
225204
226 if (environment.platform !== "win32") {205export const layer = Layer.succeed(DesktopLifecycle, make);
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);
apps/desktop/src/app/DesktopObservability.ts+7-277
...@@ -1,52 +1,20 @@...@@ -1,52 +1,20 @@
1import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";1import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";6import * as Logger from "effect/Logger";
10import * as Option from "effect/Option";7import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
13import * as References from "effect/References";8import * as References from "effect/References";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import * as Tracer from "effect/Tracer";9import * as Tracer from "effect/Tracer";
18import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";10import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
1911
12import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts";
20import * as DesktopEnvironment from "./DesktopEnvironment.ts";13import * 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";
25const DESKTOP_TRACE_BATCH_WINDOW_MS = 200;15const DESKTOP_TRACE_BATCH_WINDOW_MS = 200;
2616
27export interface RotatingLogFileWriter {17export { DesktopBackendOutputLog } from "./DesktopBackendOutputLog.ts";
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();
5018
51export type DesktopLogAnnotations = Record<string, unknown>;19export type DesktopLogAnnotations = Record<string, unknown>;
5220
...@@ -82,160 +50,6 @@ export function makeComponentLogger(component: string): DesktopComponentLogger {...@@ -82,160 +50,6 @@ export function makeComponentLogger(component: string): DesktopComponentLogger {
82 };50 };
83}51}
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
239const readPersistedOtlpTracesUrl: Effect.Effect<53const readPersistedOtlpTracesUrl: Effect.Effect<
240 Option.Option<string>,54 Option.Option<string>,
241 never,55 never,
...@@ -260,90 +74,6 @@ const resolveOtlpTracesUrl = Effect.gen(function* () {...@@ -260,90 +74,6 @@ const resolveOtlpTracesUrl = Effect.gen(function* () {
260 return yield* readPersistedOtlpTracesUrl;74 return yield* readPersistedOtlpTracesUrl;
261});75});
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
347const desktopLoggerLayer = Layer.mergeAll(77const desktopLoggerLayer = Layer.mergeAll(
348 Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }),78 Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }),
349 Layer.succeed(References.MinimumLogLevel, "Info"),79 Layer.succeed(References.MinimumLogLevel, "Info"),
...@@ -356,8 +86,8 @@ const tracerLayer = Layer.unwrap(...@@ -356,8 +86,8 @@ const tracerLayer = Layer.unwrap(
356 const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson");86 const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson");
357 const sink = yield* makeTraceSink({87 const sink = yield* makeTraceSink({
358 filePath: tracePath,88 filePath: tracePath,
359 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,89 maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES,
360 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,90 maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES,
361 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,91 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
362 });92 });
363 const delegate = Option.isNone(otlpTracesUrl)93 const delegate = Option.isNone(otlpTracesUrl)
...@@ -375,8 +105,8 @@ const tracerLayer = Layer.unwrap(...@@ -375,8 +105,8 @@ const tracerLayer = Layer.unwrap(
375 });105 });
376 const tracer = yield* makeLocalFileTracer({106 const tracer = yield* makeLocalFileTracer({
377 filePath: tracePath,107 filePath: tracePath,
378 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,108 maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES,
379 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,109 maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES,
380 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,110 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
381 sink,111 sink,
382 ...(delegate ? { delegate } : {}),112 ...(delegate ? { delegate } : {}),
...@@ -387,7 +117,7 @@ const tracerLayer = Layer.unwrap(...@@ -387,7 +117,7 @@ const tracerLayer = Layer.unwrap(
387).pipe(Layer.provideMerge(OtlpSerialization.layerJson));117).pipe(Layer.provideMerge(OtlpSerialization.layerJson));
388118
389export const layer = Layer.mergeAll(119export const layer = Layer.mergeAll(
390 backendOutputLogLayer,120 DesktopBackendOutputLogModule.layer,
391 desktopLoggerLayer,121 desktopLoggerLayer,
392 tracerLayer,122 tracerLayer,
393 Layer.succeed(Tracer.MinimumTraceLevel, "Info"),123 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";...@@ -3,19 +3,17 @@ import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Ref from "effect/Ref";4import * as Ref from "effect/Ref";
55
6export interface DesktopStateShape {6export class DesktopState extends Context.Service<
7 readonly backendReady: Ref.Ref<boolean>;7 DesktopState,
8 readonly quitting: Ref.Ref<boolean>;8 {
9}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>()(14const make = Effect.all({
12 "@t3tools/desktop/app/DesktopState",15 backendReady: Ref.make(false),
13) {}16 quitting: Ref.make(false),
17});
1418
15export const layer = Layer.effect(19export const layer = Layer.effect(DesktopState, make);
16 DesktopState,
17 Effect.all({
18 backendReady: Ref.make(false),
19 quitting: Ref.make(false),
20 }),
21);
apps/desktop/src/backend/DesktopBackendManager.test.ts+5-5
...@@ -104,9 +104,9 @@ function decodeBootstrap(raw: string) {...@@ -104,9 +104,9 @@ function decodeBootstrap(raw: string) {
104function makeManagerLayer(input: {104function makeManagerLayer(input: {
105 readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;105 readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
106 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;106 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
107 readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>;107 readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLog["Service"]>;
108 readonly desktopState?: DesktopState.DesktopStateShape;108 readonly desktopState?: DesktopState.DesktopState["Service"];
109 readonly desktopWindow?: Partial<DesktopWindow.DesktopWindowShape>;109 readonly desktopWindow?: Partial<DesktopWindow.DesktopWindow["Service"]>;
110 readonly config?: DesktopBackendManager.DesktopBackendStartConfig;110 readonly config?: DesktopBackendManager.DesktopBackendStartConfig;
111}) {111}) {
112 return DesktopBackendManager.layer.pipe(112 return DesktopBackendManager.layer.pipe(
...@@ -127,7 +127,7 @@ function makeManagerLayer(input: {...@@ -127,7 +127,7 @@ function makeManagerLayer(input: {
127 writeSessionBoundary: () => Effect.void,127 writeSessionBoundary: () => Effect.void,
128 writeOutputChunk: () => Effect.void,128 writeOutputChunk: () => Effect.void,
129 ...input.backendOutputLog,129 ...input.backendOutputLog,
130 } satisfies DesktopObservability.DesktopBackendOutputLogShape),130 } satisfies DesktopObservability.DesktopBackendOutputLog["Service"]),
131 Layer.succeed(DesktopWindow.DesktopWindow, {131 Layer.succeed(DesktopWindow.DesktopWindow, {
132 createMain: Effect.die("unexpected createMain"),132 createMain: Effect.die("unexpected createMain"),
133 ensureMain: Effect.die("unexpected ensureMain"),133 ensureMain: Effect.die("unexpected ensureMain"),
...@@ -138,7 +138,7 @@ function makeManagerLayer(input: {...@@ -138,7 +138,7 @@ function makeManagerLayer(input: {
138 dispatchMenuAction: () => Effect.void,138 dispatchMenuAction: () => Effect.void,
139 syncAppearance: Effect.void,139 syncAppearance: Effect.void,
140 ...input.desktopWindow,140 ...input.desktopWindow,
141 } satisfies DesktopWindow.DesktopWindowShape),141 } satisfies DesktopWindow.DesktopWindow["Service"]),
142 ),142 ),
143 ),143 ),
144 );144 );
apps/desktop/src/main.ts+2-3
...@@ -14,7 +14,6 @@ import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";...@@ -14,7 +14,6 @@ import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
14import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";14import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
15import serverPackageJson from "../../server/package.json" with { type: "json" };15import serverPackageJson from "../../server/package.json" with { type: "json" };
1616
17import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";
18import * as DesktopIpc from "./ipc/DesktopIpc.ts";17import * as DesktopIpc from "./ipc/DesktopIpc.ts";
19import * as ElectronApp from "./electron/ElectronApp.ts";18import * as ElectronApp from "./electron/ElectronApp.ts";
20import * as ElectronDialog from "./electron/ElectronDialog.ts";19import * as ElectronDialog from "./electron/ElectronDialog.ts";
...@@ -68,8 +67,8 @@ const desktopEnvironmentLayer = Layer.unwrap(...@@ -68,8 +67,8 @@ const desktopEnvironmentLayer = Layer.unwrap(
68);67);
6968
70const resolveDesktopSshCliRunner = (69const resolveDesktopSshCliRunner = (
71 environment: DesktopEnvironment.DesktopEnvironmentShape,70 environment: DesktopEnvironment.DesktopEnvironment["Service"],
72 settings: DesktopSettingsValue,71 settings: DesktopAppSettings.DesktopSettings,
73): RemoteT3RunnerOptions => {72): RemoteT3RunnerOptions => {
74 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);73 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
75 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {74 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
apps/desktop/src/preview/Manager.test.ts+2-2
...@@ -59,7 +59,7 @@ const environmentLayer = Layer.succeed(...@@ -59,7 +59,7 @@ const environmentLayer = Layer.succeed(
59 DesktopEnvironment.DesktopEnvironment,59 DesktopEnvironment.DesktopEnvironment,
60 DesktopEnvironment.DesktopEnvironment.of({60 DesktopEnvironment.DesktopEnvironment.of({
61 browserArtifactsDir: "/tmp/t3/dev/browser-artifacts",61 browserArtifactsDir: "/tmp/t3/dev/browser-artifacts",
62 } as DesktopEnvironment.DesktopEnvironmentShape),62 } as DesktopEnvironment.DesktopEnvironment["Service"]),
63);63);
6464
65const fileSystemLayer = FileSystem.layerNoop({65const fileSystemLayer = FileSystem.layerNoop({
...@@ -82,7 +82,7 @@ const layer = PreviewManager.layer.pipe(...@@ -82,7 +82,7 @@ const layer = PreviewManager.layer.pipe(
8282
83const withManager = <A>(83const withManager = <A>(
84 use: (84 use: (
85 manager: PreviewManager.PreviewManagerShape,85 manager: PreviewManager.PreviewManager["Service"],
86 ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>,86 ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>,
87) =>87) =>
88 Effect.gen(function* () {88 Effect.gen(function* () {
apps/desktop/src/shell/DesktopShellEnvironment.test.ts+1-1
...@@ -64,7 +64,7 @@ function runShellEnvironment(input: {...@@ -64,7 +64,7 @@ function runShellEnvironment(input: {
64 DesktopEnvironment.DesktopEnvironment,64 DesktopEnvironment.DesktopEnvironment,
65 DesktopEnvironment.DesktopEnvironment.of({65 DesktopEnvironment.DesktopEnvironment.of({
66 platform: input.platform,66 platform: input.platform,
67 } as DesktopEnvironment.DesktopEnvironmentShape),67 } as DesktopEnvironment.DesktopEnvironment["Service"]),
68 );68 );
69 const spawnerLayer = Layer.succeed(69 const spawnerLayer = Layer.succeed(
70 ChildProcessSpawner.ChildProcessSpawner,70 ChildProcessSpawner.ChildProcessSpawner,
apps/desktop/src/updates/DesktopUpdates.ts+1-1
...@@ -127,7 +127,7 @@ function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYm...@@ -127,7 +127,7 @@ function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYm
127function createBaseUpdateState(127function createBaseUpdateState(
128 channel: DesktopUpdateChannel,128 channel: DesktopUpdateChannel,
129 enabled: boolean,129 enabled: boolean,
130 environment: DesktopEnvironment.DesktopEnvironmentShape,130 environment: DesktopEnvironment.DesktopEnvironment["Service"],
131): DesktopUpdateState {131): DesktopUpdateState {
132 return {132 return {
133 ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel),133 ...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, {...@@ -91,7 +91,7 @@ const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
91 png: Option.none<string>(),91 png: Option.none<string>(),
92 }),92 }),
93 resolveResourcePath: () => Effect.succeed(Option.none<string>()),93 resolveResourcePath: () => Effect.succeed(Option.none<string>()),
94} satisfies DesktopAssets.DesktopAssetsShape);94} satisfies DesktopAssets.DesktopAssets["Service"]);
9595
96const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, {96const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, {
97 getState: Effect.die("unexpected getState"),97 getState: Effect.die("unexpected getState"),
...@@ -106,19 +106,19 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe...@@ -106,19 +106,19 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe
106 setMode: () => Effect.die("unexpected setMode"),106 setMode: () => Effect.die("unexpected setMode"),
107 setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),107 setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
108 getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"),108 getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"),
109} satisfies DesktopServerExposure.DesktopServerExposureShape);109} satisfies DesktopServerExposure.DesktopServerExposure["Service"]);
110110
111const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, {111const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, {
112 setApplicationMenu: () => Effect.void,112 setApplicationMenu: () => Effect.void,
113 popupTemplate: () => Effect.void,113 popupTemplate: () => Effect.void,
114 showContextMenu: () => Effect.succeed(Option.none()),114 showContextMenu: () => Effect.succeed(Option.none()),
115} satisfies ElectronMenu.ElectronMenuShape);115} satisfies ElectronMenu.ElectronMenu["Service"]);
116116
117const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {117const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
118 shouldUseDarkColors: Effect.succeed(false),118 shouldUseDarkColors: Effect.succeed(false),
119 setSource: () => Effect.void,119 setSource: () => Effect.void,
120 onUpdated: () => Effect.void,120 onUpdated: () => Effect.void,
121} satisfies ElectronTheme.ElectronThemeShape);121} satisfies ElectronTheme.ElectronTheme["Service"]);
122122
123const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(123const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(
124 Layer.provide(124 Layer.provide(
...@@ -156,7 +156,7 @@ function makeTestLayer(input: {...@@ -156,7 +156,7 @@ function makeTestLayer(input: {
156 sendAll: () => Effect.void,156 sendAll: () => Effect.void,
157 destroyAll: Effect.void,157 destroyAll: Effect.void,
158 syncAllAppearance: (sync) => sync(input.window),158 syncAllAppearance: (sync) => sync(input.window),
159 } satisfies ElectronWindow.ElectronWindowShape);159 } satisfies ElectronWindow.ElectronWindow["Service"]);
160160
161 return DesktopWindow.layer.pipe(161 return DesktopWindow.layer.pipe(
162 Layer.provide(162 Layer.provide(
...@@ -173,7 +173,7 @@ function makeTestLayer(input: {...@@ -173,7 +173,7 @@ function makeTestLayer(input: {
173 return true;173 return true;
174 }),174 }),
175 copyText: () => Effect.void,175 copyText: () => Effect.void,
176 } satisfies ElectronShell.ElectronShellShape),176 } satisfies ElectronShell.ElectronShell["Service"]),
177 electronThemeLayer,177 electronThemeLayer,
178 electronWindowLayer,178 electronWindowLayer,
179 Layer.mock(PreviewManager.PreviewManager)({179 Layer.mock(PreviewManager.PreviewManager)({