Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Ensure Electron runtime is installed
run: |
if ! node -e "require('./apps/desktop/node_modules/electron')" >/dev/null 2>&1; then
rm -rf apps/desktop/node_modules/electron/dist apps/desktop/node_modules/electron/path.txt
bun apps/desktop/node_modules/electron/install.js
fi
node -e "require('./apps/desktop/node_modules/electron')"

- name: Format
run: bun run fmt:check

Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as Cause from "effect/Cause";
import * as Crypto from "effect/Crypto";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Random from "effect/Random";
import * as Ref from "effect/Ref";

import * as NetService from "@t3tools/shared/Net";
Expand All @@ -26,7 +26,8 @@ const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
const MAX_TCP_PORT = 65_535;
const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const;

const makeDesktopRunId = Random.nextUUIDv4.pipe(
const makeDesktopRunId = Crypto.Crypto.pipe(
Effect.flatMap((crypto) => crypto.randomUUIDv4),
Effect.map((value) => value.replaceAll("-", "").slice(0, 12)),
);

Expand Down
39 changes: 19 additions & 20 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Random from "effect/Random";
import * as PlatformError from "effect/PlatformError";
import * as Ref from "effect/Ref";

import * as DesktopBackendManager from "./DesktopBackendManager.ts";
Expand All @@ -13,7 +15,10 @@ import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";

export interface DesktopBackendConfigurationShape {
readonly resolve: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>;
readonly resolve: Effect.Effect<
DesktopBackendManager.DesktopBackendStartConfig,
PlatformError.PlatformError
>;
}

export class DesktopBackendConfiguration extends Context.Service<
Expand Down Expand Up @@ -80,23 +85,6 @@ const readPersistedBackendObservabilitySettings: Effect.Effect<
};
});

const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootstrapToken")(
function* (tokenRef: Ref.Ref<Option.Option<string>>) {
const existing = yield* Ref.get(tokenRef);
if (Option.isSome(existing)) {
return existing.value;
}

let token = "";
while (token.length < 48) {
token += (yield* Random.nextUUIDv4).replace(/-/g, "");
}
token = token.slice(0, 48);
yield* Ref.set(tokenRef, Option.some(token));
return token;
},
);

const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")(
function* (input: {
readonly bootstrapToken: string;
Expand Down Expand Up @@ -148,11 +136,22 @@ export const layer = Layer.effect(
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const crypto = yield* Crypto.Crypto;
const tokenRef = yield* Ref.make(Option.none<string>());
const getOrCreateBootstrapToken = Effect.gen(function* () {
const existing = yield* Ref.get(tokenRef);
if (Option.isSome(existing)) {
return existing.value;
}

const token = Encoding.encodeHex(yield* crypto.randomBytes(24));
yield* Ref.set(tokenRef, Option.some(token));
return token;
});

return DesktopBackendConfiguration.of({
resolve: Effect.gen(function* () {
const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef);
const bootstrapToken = yield* getOrCreateBootstrapToken;
const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
Expand Down
22 changes: 16 additions & 6 deletions apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,21 +329,31 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio
}

yield* Ref.set(desktopState.backendReady, false);
const config = yield* configuration.resolve;
const config = yield* configuration.resolve.pipe(
Effect.tapError((error) =>
logBackendManagerError("failed to generate desktop backend configuration", {
cause: error.message,
}),
),
Effect.option,
);
if (Option.isNone(config)) {
return;
}
Comment thread
juliusmarminge marked this conversation as resolved.
const entryExists = yield* fileSystem
.exists(config.entryPath)
.exists(config.value.entryPath)
.pipe(Effect.orElseSucceed(() => false));

yield* cancelRestart;
yield* Ref.update(state, (latest) => ({
...latest,
desiredRunning: true,
ready: false,
config: Option.some(config),
config: Option.some(config.value),
}));

if (!entryExists) {
yield* scheduleRestart(`missing server entry at ${config.entryPath}`);
yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`);
return;
}

Expand Down Expand Up @@ -425,15 +435,15 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio
});

const program = runBackendProcess({
...config,
...config.value,
onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) {
yield* updateActiveRun(runId, (run) => ({
...run,
pid: Option.some(pid),
}));
yield* backendOutputLog.writeSessionBoundary({
phase: "START",
details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`,
details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`,
});
}),
onReady: Effect.fn("desktop.backendManager.onReady")(function* () {
Expand Down
26 changes: 16 additions & 10 deletions apps/desktop/src/settings/DesktopAppSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import {
} from "@t3tools/contracts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Random from "effect/Random";
import * as Schema from "effect/Schema";
import * as SynchronizedRef from "effect/SynchronizedRef";

Expand Down Expand Up @@ -222,10 +222,10 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
readonly settingsPath: string;
readonly settings: DesktopSettings;
readonly defaultSettings: DesktopSettings;
readonly suffix: string;
}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
const directory = input.path.dirname(input.settingsPath);
const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`;
const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
const encoded = yield* encodeDesktopSettingsJson(
toDesktopSettingsDocument(input.settings, input.defaultSettings),
);
Expand All @@ -240,6 +240,7 @@ export const layer = Layer.effect(
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const crypto = yield* Crypto.Crypto;
const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings);

const persist = (
Expand All @@ -251,13 +252,18 @@ export const layer = Layer.effect(
return Effect.succeed([settingsChange(settings, false), settings] as const);
}

return writeSettings({
fileSystem,
path,
settingsPath: environment.desktopSettingsPath,
settings: nextSettings,
defaultSettings: environment.defaultDesktopSettings,
}).pipe(
return crypto.randomUUIDv4.pipe(
Effect.map((uuid) => uuid.replace(/-/g, "")),
Effect.flatMap((suffix) =>
writeSettings({
fileSystem,
path,
settingsPath: environment.desktopSettingsPath,
settings: nextSettings,
defaultSettings: environment.defaultDesktopSettings,
suffix,
}),
),
Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })),
Effect.as([settingsChange(nextSettings, true), nextSettings] as const),
);
Expand Down
24 changes: 15 additions & 9 deletions apps/desktop/src/settings/DesktopClientSettings.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Random from "effect/Random";
import * as Schema from "effect/Schema";
import * as Ref from "effect/Ref";

Expand Down Expand Up @@ -74,10 +74,10 @@ const writeClientSettings = Effect.fnUntraced(function* (input: {
readonly path: Path.Path;
readonly settingsPath: string;
readonly settings: ClientSettings;
readonly suffix: string;
}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
const directory = input.path.dirname(input.settingsPath);
const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`;
const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
const encoded = yield* encodeClientSettingsJson(input.settings);
yield* input.fileSystem.makeDirectory(directory, { recursive: true });
yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
Expand All @@ -90,18 +90,24 @@ export const layer = Layer.effect(
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const crypto = yield* Crypto.Crypto;

return DesktopClientSettings.of({
get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
Effect.withSpan("desktop.clientSettings.get"),
),
set: (settings) =>
writeClientSettings({
fileSystem,
path,
settingsPath: environment.clientSettingsPath,
settings,
}).pipe(
crypto.randomUUIDv4.pipe(
Effect.map((uuid) => uuid.replace(/-/g, "")),
Effect.flatMap((suffix) =>
writeClientSettings({
fileSystem,
path,
settingsPath: environment.clientSettingsPath,
settings,
suffix,
}),
),
Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })),
Effect.withSpan("desktop.clientSettings.set"),
),
Expand Down
26 changes: 17 additions & 9 deletions apps/desktop/src/settings/DesktopSavedEnvironments.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
Expand All @@ -9,7 +10,6 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Random from "effect/Random";
import * as Schema from "effect/Schema";
import * as Ref from "effect/Ref";

Expand Down Expand Up @@ -200,10 +200,10 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry
readonly path: Path.Path;
readonly registryPath: string;
readonly document: SavedEnvironmentRegistryDocument;
readonly suffix: string;
}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
const directory = input.path.dirname(input.registryPath);
const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
const tempPath = `${input.registryPath}.${process.pid}.${suffix}.tmp`;
const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`;
const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document);
yield* input.fileSystem.makeDirectory(directory, { recursive: true });
yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
Expand Down Expand Up @@ -247,14 +247,22 @@ export const layer = Layer.effect(
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
const crypto = yield* Crypto.Crypto;

const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
writeRegistryDocument({
fileSystem,
path,
registryPath: environment.savedEnvironmentRegistryPath,
document,
}).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })));
crypto.randomUUIDv4.pipe(
Effect.map((uuid) => uuid.replace(/-/g, "")),
Effect.flatMap((suffix) =>
writeRegistryDocument({
fileSystem,
path,
registryPath: environment.savedEnvironmentRegistryPath,
document,
suffix,
}),
),
Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })),
);

return DesktopSavedEnvironments.of({
getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
Expand Down Expand Up @@ -86,6 +87,7 @@ function makeElectronWindowLayer(window: ReturnType<typeof makeTestWindow>["wind
function makeLayer(window: ReturnType<typeof makeTestWindow>["window"]) {
return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe(
Layer.provide(makeElectronWindowLayer(window)),
Layer.provide(NodeServices.layer),
Layer.provideMerge(TestClock.layer()),
);
}
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts";
import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts";
import type { SshPasswordRequest } from "@t3tools/ssh/auth";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Data from "effect/Data";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Random from "effect/Random";
import * as Ref from "effect/Ref";

import * as IpcChannels from "../ipc/channels.ts";
Expand Down Expand Up @@ -163,6 +163,7 @@ const failPending = (

const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const crypto = yield* Crypto.Crypto;
const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>());
const passwordPromptTimeoutMs =
options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;
Expand Down Expand Up @@ -230,7 +231,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La
});
}

const requestId = yield* Random.nextUUIDv4;
const requestId = yield* crypto.randomUUIDv4.pipe(
Effect.mapError(
() => new DesktopSshPromptUnavailableError({ reason: "Secure randomness is unavailable." }),
),
);
const now = yield* DateTime.now;
const expiresAt = DateTime.formatIso(
DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }),
Expand Down
Loading
Loading