| author | |
| committer | |
| log | 60b16d1f28104c95a3ab8e673cb439124a4a8668 |
| tree | 981268d6b7fa59a6ae0c9c445034fd1174b9d919 |
| parent | ab0baaa88614905722a6638aa56ec858caf9c563 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"]); |
| 67 | 67 | ||
| 68 | const makeAssetsLayer = (png: Option.Option<string>) => | 68 | const 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"]); |
| 77 | 77 | ||
| 78 | const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { | 78 | const 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 | }); |
| 19 | const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); | 19 | const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); |
| 20 | 20 | ||
| 21 | export interface DesktopAppIdentityShape { | ||
| 22 | readonly resolveUserDataPath: Effect.Effect<string>; | ||
| 23 | readonly configure: Effect.Effect<void>; | ||
| 24 | } | ||
| 25 | |||
| 26 | export class DesktopAppIdentity extends Context.Service< | 21 | export class DesktopAppIdentity extends Context.Service< |
| 27 | DesktopAppIdentity, | 22 | DesktopAppIdentity, |
| 28 | DesktopAppIdentityShape | 23 | { |
| 24 | readonly resolveUserDataPath: Effect.Effect<string>; | ||
| 25 | readonly configure: Effect.Effect<void>; | ||
| 26 | } | ||
| 29 | >()("@t3tools/desktop/app/DesktopAppIdentity") {} | 27 | >()("@t3tools/desktop/app/DesktopAppIdentity") {} |
| 30 | 28 | ||
| 31 | const normalizeCommitHash = (value: string): Option.Option<string> => { | 29 | const 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 | } |
| 14 | 14 | ||
| 15 | export interface DesktopAssetsShape { | 15 | export 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>; |
| 19 | 19 | readonly resolveResourcePath: (fileName: string) => Effect.Effect<Option.Option<string>>; | |
| 20 | export class DesktopAssets extends Context.Service<DesktopAssets, DesktopAssetsShape>()( | 20 | } |
| 21 | "@t3tools/desktop/app/DesktopAssets", | 21 | >()("@t3tools/desktop/app/DesktopAssets") {} |
| 22 | ) {} | ||
| 23 | 22 | ||
| 24 | const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* ( | 23 | const 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 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as PlatformError from "effect/PlatformError"; | ||
| 9 | import * as References from "effect/References"; | ||
| 10 | import * as Ref from "effect/Ref"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as Semaphore from "effect/Semaphore"; | ||
| 13 | |||
| 14 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 15 | |||
| 16 | export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; | ||
| 17 | export const DESKTOP_LOG_FILE_MAX_FILES = 10; | ||
| 18 | |||
| 19 | const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; | ||
| 20 | |||
| 21 | interface RotatingLogFileWriter { | ||
| 22 | readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; | ||
| 23 | readonly writeText: (chunk: string) => Effect.Effect<void>; | ||
| 24 | } | ||
| 25 | |||
| 26 | export 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 | |||
| 40 | class 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 | |||
| 52 | type DesktopLogFileWriterError = | ||
| 53 | | DesktopLogFileWriterConfigurationError | ||
| 54 | | PlatformError.PlatformError; | ||
| 55 | |||
| 56 | const 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 | |||
| 65 | const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( | ||
| 66 | Schema.fromJsonString(DesktopBackendChildLogRecord), | ||
| 67 | ); | ||
| 68 | |||
| 69 | const DesktopBackendOutputLogNoop: DesktopBackendOutputLog["Service"] = { | ||
| 70 | writeSessionBoundary: () => Effect.void, | ||
| 71 | writeOutputChunk: () => Effect.void, | ||
| 72 | }; | ||
| 73 | |||
| 74 | const textEncoder = new TextEncoder(); | ||
| 75 | const textDecoder = new TextDecoder(); | ||
| 76 | |||
| 77 | const 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 | |||
| 83 | const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); | ||
| 84 | |||
| 85 | const 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 | |||
| 94 | const 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 | |||
| 198 | const 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 | |||
| 207 | const 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 | |||
| 231 | const 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 | |||
| 280 | export 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 | })); |
| 23 | 23 | ||
| 24 | import { | ||
| 25 | createDesktopClerkBridge, | ||
| 26 | resolveDesktopClerkFrontendApiHostname, | ||
| 27 | } from "./DesktopClerk.ts"; | ||
| 28 | import * as DesktopClerk from "./DesktopClerk.ts"; | 24 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| 29 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 25 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 30 | 26 | ||
| ... | @@ -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$")}`; |
| 34 | 30 | ||
| 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 | }); |
| 39 | 38 | ||
| 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"]); |
| 48 | 47 | ||
| 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); |
| 80 | 79 | ||
| 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"; |
| 14 | 14 | ||
| 15 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; | 15 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; |
| 16 | 16 | ||
| 17 | export interface DesktopClerkShape { | 17 | export 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.Scope | 21 | void, |
| 22 | >; | 22 | never, |
| 23 | } | 23 | ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope |
| 24 | 24 | >; | |
| 25 | export class DesktopClerk extends Context.Service<DesktopClerk, DesktopClerkShape>()( | 25 | } |
| 26 | "@t3tools/desktop/app/DesktopClerk", | 26 | >()("@t3tools/desktop/app/DesktopClerk") {} |
| 27 | ) {} | ||
| 28 | 27 | ||
| 29 | export function resolveDesktopClerkFrontendApiHostname( | 28 | export 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"; |
| 11 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 12 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| 13 | 13 | ||
| 14 | import { | 14 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 15 | type DesktopSettings, | ||
| 16 | resolveDefaultDesktopSettings, | ||
| 17 | } from "../settings/DesktopAppSettings.ts"; | ||
| 18 | import * as DesktopConfig from "./DesktopConfig.ts"; | 15 | import * as DesktopConfig from "./DesktopConfig.ts"; |
| 19 | import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; | 16 | import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; |
| 20 | 17 | ||
| ... | @@ -30,55 +27,53 @@ export interface MakeDesktopEnvironmentInput { | ... | @@ -30,55 +27,53 @@ export interface MakeDesktopEnvironmentInput { |
| 30 | readonly runningUnderArm64Translation: boolean; | 27 | readonly runningUnderArm64Translation: boolean; |
| 31 | } | 28 | } |
| 32 | 29 | ||
| 33 | export 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 | |||
| 79 | export class DesktopEnvironment extends Context.Service< | 30 | export class DesktopEnvironment extends Context.Service< |
| 80 | DesktopEnvironment, | 31 | 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 | } | ||
| 82 | >()("@t3tools/desktop/app/DesktopEnvironment") {} | 77 | >()("@t3tools/desktop/app/DesktopEnvironment") {} |
| 83 | 78 | ||
| 84 | const APP_BASE_NAME = "T3 Code"; | 79 | const APP_BASE_NAME = "T3 Code"; |
| ... | @@ -136,9 +131,9 @@ function resolveDesktopRuntimeInfo(input: { | ... | @@ -136,9 +131,9 @@ function resolveDesktopRuntimeInfo(input: { |
| 136 | }; | 131 | }; |
| 137 | } | 132 | } |
| 138 | 133 | ||
| 139 | const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( | 134 | const 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 | }); |
| 251 | 246 | ||
| 252 | export const layer = (input: MakeDesktopEnvironmentInput) => | 247 | export 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 @@ |
| 1 | import * as Cause from "effect/Cause"; | 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 6 | import * as Ref from "effect/Ref"; | 5 | import * as Ref from "effect/Ref"; |
| 7 | import * as Scope from "effect/Scope"; | 6 | import * 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"; |
| 10 | 9 | ||
| 11 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 10 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 12 | import * as DesktopObservability from "./DesktopObservability.ts"; | 11 | import * as DesktopObservability from "./DesktopObservability.ts"; |
| 12 | import * as DesktopShutdownModule from "./DesktopShutdown.ts"; | ||
| 13 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 13 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 14 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | 14 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 15 | import * as DesktopState from "./DesktopState.ts"; | 15 | import * as DesktopState from "./DesktopState.ts"; |
| 16 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 16 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 17 | 17 | ||
| 18 | export interface DesktopShutdownShape { | 18 | export { 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 | |||
| 26 | export class DesktopShutdown extends Context.Service<DesktopShutdown, DesktopShutdownShape>()( | ||
| 27 | "@t3tools/desktop/app/DesktopLifecycle/DesktopShutdown", | ||
| 28 | ) {} | ||
| 29 | |||
| 30 | const 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 | |||
| 47 | export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown); | ||
| 48 | 19 | ||
| 49 | export type DesktopLifecycleRuntimeServices = | 20 | export type DesktopLifecycleRuntimeServices = |
| 50 | | DesktopEnvironment.DesktopEnvironment | 21 | | DesktopEnvironment.DesktopEnvironment |
| 51 | | DesktopShutdown | 22 | | DesktopShutdownModule.DesktopShutdown |
| 52 | | DesktopState.DesktopState | 23 | | DesktopState.DesktopState |
| 53 | | DesktopWindow.DesktopWindow | 24 | | DesktopWindow.DesktopWindow |
| 54 | | ElectronApp.ElectronApp | 25 | | ElectronApp.ElectronApp |
| 55 | | ElectronTheme.ElectronTheme; | 26 | | ElectronTheme.ElectronTheme; |
| 56 | 27 | ||
| 57 | export 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 | ElectronTheme | 29 | * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme |
| 66 | */ | 30 | */ |
| 67 | export class DesktopLifecycle extends Context.Service<DesktopLifecycle, DesktopLifecycleShape>()( | 31 | export 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") {} | ||
| 70 | 40 | ||
| 71 | const { logInfo: logLifecycleInfo, logError: logLifecycleError } = | 41 | const { 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 | } |
| 94 | 64 | ||
| 95 | const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( | 65 | const 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 | } |
| 156 | 126 | ||
| 157 | export const layer = Layer.succeed( | 127 | const 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 | }); | ||
| 225 | 204 | ||
| 226 | if (environment.platform !== "win32") { | 205 | export 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 @@ |
| 1 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | 1 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; |
| 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Data from "effect/Data"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 9 | import * as Logger from "effect/Logger"; | 6 | import * as Logger from "effect/Logger"; |
| 10 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; | ||
| 12 | import * as PlatformError from "effect/PlatformError"; | ||
| 13 | import * as References from "effect/References"; | 8 | import * as References from "effect/References"; |
| 14 | import * as Ref from "effect/Ref"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Semaphore from "effect/Semaphore"; | ||
| 17 | import * as Tracer from "effect/Tracer"; | 9 | import * as Tracer from "effect/Tracer"; |
| 18 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 10 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 19 | 11 | ||
| 12 | import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts"; | ||
| 20 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 13 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 21 | 14 | ||
| 22 | const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; | ||
| 23 | const DESKTOP_LOG_FILE_MAX_FILES = 10; | ||
| 24 | const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; | ||
| 25 | const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; | 15 | const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; |
| 26 | 16 | ||
| 27 | export interface RotatingLogFileWriter { | 17 | export { DesktopBackendOutputLog } from "./DesktopBackendOutputLog.ts"; |
| 28 | readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; | ||
| 29 | readonly writeText: (chunk: string) => Effect.Effect<void>; | ||
| 30 | } | ||
| 31 | |||
| 32 | export 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 | |||
| 43 | export class DesktopBackendOutputLog extends Context.Service< | ||
| 44 | DesktopBackendOutputLog, | ||
| 45 | DesktopBackendOutputLogShape | ||
| 46 | >()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLog") {} | ||
| 47 | |||
| 48 | const textEncoder = new TextEncoder(); | ||
| 49 | const textDecoder = new TextDecoder(); | ||
| 50 | 18 | ||
| 51 | export type DesktopLogAnnotations = Record<string, unknown>; | 19 | export type DesktopLogAnnotations = Record<string, unknown>; |
| 52 | 20 | ||
| ... | @@ -82,160 +50,6 @@ export function makeComponentLogger(component: string): DesktopComponentLogger { | ... | @@ -82,160 +50,6 @@ export function makeComponentLogger(component: string): DesktopComponentLogger { |
| 82 | }; | 50 | }; |
| 83 | } | 51 | } |
| 84 | 52 | ||
| 85 | class 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 | |||
| 96 | type DesktopLogFileWriterError = | ||
| 97 | | DesktopLogFileWriterConfigurationError | ||
| 98 | | PlatformError.PlatformError; | ||
| 99 | |||
| 100 | const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); | ||
| 101 | |||
| 102 | const 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 | |||
| 111 | const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( | ||
| 112 | Schema.fromJsonString(DesktopBackendChildLogRecord), | ||
| 113 | ); | ||
| 114 | |||
| 115 | const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { | ||
| 116 | writeSessionBoundary: () => Effect.void, | ||
| 117 | writeOutputChunk: () => Effect.void, | ||
| 118 | }; | ||
| 119 | |||
| 120 | const 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 | |||
| 126 | const 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 | |||
| 135 | const 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 | |||
| 239 | const readPersistedOtlpTracesUrl: Effect.Effect< | 53 | const 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 | }); |
| 262 | 76 | ||
| 263 | const 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 | |||
| 272 | const 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 | |||
| 296 | const 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 | |||
| 347 | const desktopLoggerLayer = Layer.mergeAll( | 77 | const 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)); |
| 388 | 118 | ||
| 389 | export const layer = Layer.mergeAll( | 119 | export 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 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | |||
| 7 | export 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 | |||
| 18 | const 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 | |||
| 35 | export 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"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Ref from "effect/Ref"; | 4 | import * as Ref from "effect/Ref"; |
| 5 | 5 | ||
| 6 | export interface DesktopStateShape { | 6 | export 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") {} | ||
| 10 | 13 | ||
| 11 | export class DesktopState extends Context.Service<DesktopState, DesktopStateShape>()( | 14 | const make = Effect.all({ |
| 12 | "@t3tools/desktop/app/DesktopState", | 15 | backendReady: Ref.make(false), |
| 13 | ) {} | 16 | quitting: Ref.make(false), |
| 17 | }); | ||
| 14 | 18 | ||
| 15 | export const layer = Layer.effect( | 19 | export 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) { |
| 104 | function makeManagerLayer(input: { | 104 | function 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"; |
| 14 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; | 14 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 15 | import serverPackageJson from "../../server/package.json" with { type: "json" }; | 15 | import serverPackageJson from "../../server/package.json" with { type: "json" }; |
| 16 | 16 | ||
| 17 | import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; | ||
| 18 | import * as DesktopIpc from "./ipc/DesktopIpc.ts"; | 17 | import * as DesktopIpc from "./ipc/DesktopIpc.ts"; |
| 19 | import * as ElectronApp from "./electron/ElectronApp.ts"; | 18 | import * as ElectronApp from "./electron/ElectronApp.ts"; |
| 20 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; | 19 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; |
| ... | @@ -68,8 +67,8 @@ const desktopEnvironmentLayer = Layer.unwrap( | ... | @@ -68,8 +67,8 @@ const desktopEnvironmentLayer = Layer.unwrap( |
| 68 | ); | 67 | ); |
| 69 | 68 | ||
| 70 | const resolveDesktopSshCliRunner = ( | 69 | const 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 | ); |
| 64 | 64 | ||
| 65 | const fileSystemLayer = FileSystem.layerNoop({ | 65 | const fileSystemLayer = FileSystem.layerNoop({ |
| ... | @@ -82,7 +82,7 @@ const layer = PreviewManager.layer.pipe( | ... | @@ -82,7 +82,7 @@ const layer = PreviewManager.layer.pipe( |
| 82 | 82 | ||
| 83 | const withManager = <A>( | 83 | const 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 |
| 127 | function createBaseUpdateState( | 127 | function 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"]); |
| 95 | 95 | ||
| 96 | const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { | 96 | const 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"]); |
| 110 | 110 | ||
| 111 | const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { | 111 | const 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"]); |
| 116 | 116 | ||
| 117 | const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { | 117 | const 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"]); |
| 122 | 122 | ||
| 123 | const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( | 123 | const 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"]); |
| 160 | 160 | ||
| 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)({ |