Skip to content
Draft
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
import * as DesktopConfig from "./DesktopConfig.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts";
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopCloudAuthTokenStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";

import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

interface CloudAuthTokenDocument {
Expand Down
96 changes: 96 additions & 0 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
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 ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
import * as DesktopConfig from "./DesktopConfig.ts";
import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();

function makeSafeStorageLayer(available: boolean) {
return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
isEncryptionAvailable: Effect.succeed(available),
encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)),
decryptString: (value) => {
const decoded = textDecoder.decode(value);
if (!decoded.startsWith("encrypted:")) {
return Effect.fail(
new ElectronSafeStorage.ElectronSafeStorageDecryptError({
cause: new Error("invalid encrypted catalog"),
}),
);
}
return Effect.succeed(decoded.slice("encrypted:".length));
},
} satisfies ElectronSafeStorage.ElectronSafeStorageShape);
}

function makeLayer(baseDir: string, encryptionAvailable = true) {
const environmentLayer = DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/src",
homeDirectory: baseDir,
platform: "darwin",
processArch: "arm64",
appVersion: "1.2.3",
appPath: "/repo",
isPackaged: true,
resourcesPath: "/missing/resources",
runningUnderArm64Translation: false,
}).pipe(
Layer.provide(
Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
),
);

return DesktopConnectionCatalogStore.layer.pipe(
Layer.provideMerge(environmentLayer),
Layer.provideMerge(makeSafeStorageLayer(encryptionAvailable)),
Layer.provideMerge(NodeServices.layer),
);
}

const withStore = <A, E, R>(
effect: Effect.Effect<A, E, R | DesktopConnectionCatalogStore.DesktopConnectionCatalogStore>,
encryptionAvailable = true,
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-connection-catalog-test-",
});
return yield* effect.pipe(Effect.provide(makeLayer(baseDir, encryptionAvailable)));
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped);

describe("DesktopConnectionCatalogStore", () => {
it.effect("persists, reads, and clears an encrypted connection catalog", () =>
withStore(
Effect.gen(function* () {
const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
const catalog = '{"schemaVersion":1,"targets":[]}';

assert.isTrue(yield* store.set(catalog));
assert.deepStrictEqual(yield* store.get, Option.some(catalog));

yield* store.clear;
assert.deepStrictEqual(yield* store.get, Option.none());
}),
),
);

it.effect("does not persist when secure storage is unavailable", () =>
withStore(
Effect.gen(function* () {
const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
assert.isFalse(yield* store.set("{}"));
assert.deepStrictEqual(yield* store.get, Option.none());
}),
false,
),
);
});
149 changes: 149 additions & 0 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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";
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 Schema from "effect/Schema";

import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

const ConnectionCatalogDocument = Schema.Struct({
version: Schema.Literal(1),
encryptedCatalog: Schema.String,
});
type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type;

const ConnectionCatalogDocumentJson = fromLenientJson(ConnectionCatalogDocument);
const decodeConnectionCatalogDocumentJson = Schema.decodeEffect(ConnectionCatalogDocumentJson);
const encodeConnectionCatalogDocumentJson = Schema.encodeEffect(ConnectionCatalogDocumentJson);

export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError(
"DesktopConnectionCatalogStoreWriteError",
)<{
readonly cause: PlatformError.PlatformError | Schema.SchemaError;
}> {
override get message() {
return `Failed to write desktop connection catalog: ${this.cause.message}`;
}
}

export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError(
"DesktopConnectionCatalogStoreDecodeError",
)<{
readonly cause: Encoding.EncodingError;
}> {
override get message() {
return "Failed to decode the desktop connection catalog.";
}
}

export interface DesktopConnectionCatalogStoreShape {
readonly get: Effect.Effect<
Option.Option<string>,
| DesktopConnectionCatalogStoreDecodeError
| ElectronSafeStorage.ElectronSafeStorageAvailabilityError
| ElectronSafeStorage.ElectronSafeStorageDecryptError
>;
readonly set: (
catalog: string,
) => Effect.Effect<
boolean,
| DesktopConnectionCatalogStoreWriteError
| ElectronSafeStorage.ElectronSafeStorageAvailabilityError
| ElectronSafeStorage.ElectronSafeStorageEncryptError
>;
readonly clear: Effect.Effect<void>;
}

export class DesktopConnectionCatalogStore extends Context.Service<
DesktopConnectionCatalogStore,
DesktopConnectionCatalogStoreShape
>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {}

function decodeSecretBytes(
encoded: string,
): Effect.Effect<Uint8Array, DesktopConnectionCatalogStoreDecodeError> {
return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(
Effect.mapError((cause) => new DesktopConnectionCatalogStoreDecodeError({ cause })),
);
}

const readDocument = (
fileSystem: FileSystem.FileSystem,
catalogPath: string,
): Effect.Effect<Option.Option<ConnectionCatalogDocument>> =>
fileSystem.readFileString(catalogPath).pipe(
Effect.option,
Effect.flatMap(
Option.match({
onNone: () => Effect.succeed(Option.none<ConnectionCatalogDocument>()),
onSome: (raw) => decodeConnectionCatalogDocumentJson(raw).pipe(Effect.option),
}),
),
);

const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(function* (input: {
readonly fileSystem: FileSystem.FileSystem;
readonly path: Path.Path;
readonly catalogPath: string;
readonly document: ConnectionCatalogDocument;
readonly suffix: string;
}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
const directory = input.path.dirname(input.catalogPath);
const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`;
const encoded = yield* encodeConnectionCatalogDocumentJson(input.document);
yield* input.fileSystem.makeDirectory(directory, { recursive: true });
yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
yield* input.fileSystem.rename(tempPath, input.catalogPath);
});

export const layer = Layer.effect(
DesktopConnectionCatalogStore,
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
const crypto = yield* Crypto.Crypto;
const catalogPath = path.join(environment.stateDir, "connection-catalog.json");

return DesktopConnectionCatalogStore.of({
get: Effect.gen(function* () {
const document = yield* readDocument(fileSystem, catalogPath);
if (Option.isNone(document) || !(yield* safeStorage.isEncryptionAvailable)) {
return Option.none<string>();
}
const bytes = yield* decodeSecretBytes(document.value.encryptedCatalog);
return Option.some(yield* safeStorage.decryptString(bytes));
}).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),
set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {
if (!(yield* safeStorage.isEncryptionAvailable)) {
return false;
}
const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog));
const suffix = (yield* crypto.randomUUIDv4.pipe(
Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })),
)).replace(/-/g, "");
yield* writeDocument({
fileSystem,
path,
catalogPath,
document: { version: 1, encryptedCatalog },
suffix,
}).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })));
return true;
}),
clear: fileSystem.remove(catalogPath, { force: true }).pipe(
Effect.catch(() => Effect.void),
Effect.withSpan("desktop.connectionCatalogStore.clear"),
),
});
}),
);
2 changes: 1 addition & 1 deletion apps/desktop/src/backend/tailscaleEndpointProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
input.readMagicDnsName ??
readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.catch(() => Effect.succeed<string | null>(null)),
Effect.orElseSucceed(() => null),
);
const dnsName =
input.statusJson === undefined
Expand Down
52 changes: 7 additions & 45 deletions apps/desktop/src/electron/ElectronSafeStorage.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,16 @@
import * as Context from "effect/Context";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as Electron from "electron";

export class ElectronSafeStorageAvailabilityError extends Data.TaggedError(
"ElectronSafeStorageAvailabilityError",
)<{
readonly cause: unknown;
}> {
override get message() {
return "Electron safe storage failed to check encryption availability.";
}
}

export class ElectronSafeStorageEncryptError extends Data.TaggedError(
"ElectronSafeStorageEncryptError",
)<{
readonly cause: unknown;
}> {
override get message() {
return "Electron safe storage failed to encrypt a string.";
}
}

export class ElectronSafeStorageDecryptError extends Data.TaggedError(
"ElectronSafeStorageDecryptError",
)<{
readonly cause: unknown;
}> {
override get message() {
return "Electron safe storage failed to decrypt a string.";
}
}

export interface ElectronSafeStorageShape {
readonly isEncryptionAvailable: Effect.Effect<boolean, ElectronSafeStorageAvailabilityError>;
readonly encryptString: (
value: string,
) => Effect.Effect<Uint8Array, ElectronSafeStorageEncryptError>;
readonly decryptString: (
value: Uint8Array,
) => Effect.Effect<string, ElectronSafeStorageDecryptError>;
}

export class ElectronSafeStorage extends Context.Service<
import {
ElectronSafeStorage,
ElectronSafeStorageShape
>()("@t3tools/desktop/electron/ElectronSafeStorage") {}
ElectronSafeStorageAvailabilityError,
ElectronSafeStorageDecryptError,
ElectronSafeStorageEncryptError,
} from "./ElectronSafeStorageService.ts";

export * from "./ElectronSafeStorageService.ts";

const make = ElectronSafeStorage.of({
isEncryptionAvailable: Effect.try({
Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/src/electron/ElectronSafeStorageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as Context from "effect/Context";
import * as Data from "effect/Data";
import type * as Effect from "effect/Effect";

export class ElectronSafeStorageAvailabilityError extends Data.TaggedError(
"ElectronSafeStorageAvailabilityError",
)<{
readonly cause: unknown;
}> {
override get message() {
return "Electron safe storage failed to check encryption availability.";
}
}

export class ElectronSafeStorageEncryptError extends Data.TaggedError(
"ElectronSafeStorageEncryptError",
)<{
readonly cause: unknown;
}> {
override get message() {
return "Electron safe storage failed to encrypt a string.";
}
}

export class ElectronSafeStorageDecryptError extends Data.TaggedError(
"ElectronSafeStorageDecryptError",
)<{
readonly cause: unknown;
}> {
override get message() {
return "Electron safe storage failed to decrypt a string.";
}
}

export interface ElectronSafeStorageShape {
readonly isEncryptionAvailable: Effect.Effect<boolean, ElectronSafeStorageAvailabilityError>;
readonly encryptString: (
value: string,
) => Effect.Effect<Uint8Array, ElectronSafeStorageEncryptError>;
readonly decryptString: (
value: Uint8Array,
) => Effect.Effect<string, ElectronSafeStorageDecryptError>;
}

export class ElectronSafeStorage extends Context.Service<
ElectronSafeStorage,
ElectronSafeStorageShape
>()("@t3tools/desktop/electron/ElectronSafeStorageService/ElectronSafeStorage") {}
Loading
Loading