Skip to content
Open
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
127 changes: 109 additions & 18 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ const withIdentity = <A, E, R>(
input: {
readonly calls?: ElectronAppCalls;
readonly environment?: TestEnvironmentInput;
readonly legacyPathExists?: boolean;
readonly legacyPathProbeError?: PlatformError.PlatformError;
readonly existingUserDataPaths?: readonly string[];
readonly pathProbeError?: PlatformError.PlatformError;
readonly renameError?: PlatformError.PlatformError;
readonly packageJson?: string;
readonly pngIconPath?: Option.Option<string>;
readonly renamedPaths?: Array<{ readonly from: string; readonly to: string }>;
} = {},
) => {
const calls: ElectronAppCalls = input.calls ?? {
Expand All @@ -123,13 +125,17 @@ const withIdentity = <A, E, R>(
Layer.provideMerge(
FileSystem.layerNoop({
exists: (path) =>
input.legacyPathProbeError
? Effect.fail(input.legacyPathProbeError)
: Effect.succeed(
input.legacyPathExists === true && path.includes("T3 Code (Alpha)"),
),
input.pathProbeError
? Effect.fail(input.pathProbeError)
: Effect.succeed(input.existingUserDataPaths?.includes(path) === true),
readFileString: () =>
Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'),
rename: (from, to) =>
input.renameError
? Effect.fail(input.renameError)
: Effect.sync(() => {
input.renamedPaths?.push({ from, to });
}),
}),
),
Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())),
Expand All @@ -141,25 +147,106 @@ const withIdentity = <A, E, R>(
};

describe("DesktopAppIdentity", () => {
it.effect("keeps using the legacy userData path when it already exists", () =>
withIdentity(
it.effect("keeps the canonical userData path when canonical and legacy paths both exist", () => {
const renamedPaths: Array<{ readonly from: string; readonly to: string }> = [];

return withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const userDataPath = yield* identity.resolveUserDataPath;

assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)");
assert.equal(userDataPath, "/Users/alice/Library/Application Support/t3code");
assert.deepEqual(renamedPaths, []);
}),
{ legacyPathExists: true },
),
);
{
existingUserDataPaths: [
"/Users/alice/Library/Application Support/t3code",
"/Users/alice/Library/Application Support/T3 Code (Alpha)",
],
renamedPaths,
},
);
});

it.effect("preserves failures while inspecting the legacy userData path", () => {
it.effect("migrates the stage-matched legacy userData path into the canonical path", () => {
const renamedPaths: Array<{ readonly from: string; readonly to: string }> = [];
const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)";
const canonicalPath = "/Users/alice/Library/Application Support/t3code";

return withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const userDataPath = yield* identity.resolveUserDataPath;

assert.equal(userDataPath, canonicalPath);
assert.deepEqual(renamedPaths, [{ from: legacyPath, to: canonicalPath }]);
}),
{
existingUserDataPaths: [legacyPath],
renamedPaths,
},
);
});

it.effect("prefers the historical packaged profile when Nightly legacy profiles coexist", () => {
const renamedPaths: Array<{ readonly from: string; readonly to: string }> = [];
const historicalLegacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)";
const nightlyLegacyPath = "/Users/alice/Library/Application Support/T3 Code (Nightly)";
const canonicalPath = "/Users/alice/Library/Application Support/t3code";

return withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const userDataPath = yield* identity.resolveUserDataPath;

assert.equal(userDataPath, canonicalPath);
assert.deepEqual(renamedPaths, [{ from: historicalLegacyPath, to: canonicalPath }]);
}),
{
environment: {
appVersion: "0.0.29-nightly.20260723.864",
},
existingUserDataPaths: [historicalLegacyPath, nightlyLegacyPath],
renamedPaths,
},
);
});

it.effect("preserves failures while inspecting the canonical userData path", () => {
const canonicalPath = "/Users/alice/Library/Application Support/t3code";
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "exists",
description: "permission denied",
pathOrDescriptor: canonicalPath,
});

return withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const error = yield* identity.resolveUserDataPath.pipe(Effect.flip);

assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathInspectionError);
assert.equal(error.path, canonicalPath);
assert.strictEqual(error.cause, cause);
assert.equal(
error.message,
`Failed to inspect desktop user-data path at "${canonicalPath}".`,
);
}),
{ pathProbeError: cause },
);
});

it.effect("preserves failures while migrating a legacy userData path", () => {
const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)";
const canonicalPath = "/Users/alice/Library/Application Support/t3code";
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "rename",
description: "permission denied",
pathOrDescriptor: legacyPath,
});

Expand All @@ -168,15 +255,19 @@ describe("DesktopAppIdentity", () => {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const error = yield* identity.resolveUserDataPath.pipe(Effect.flip);

assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError);
assert.equal(error.legacyPath, legacyPath);
assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathMigrationError);
assert.equal(error.path, legacyPath);
assert.equal(error.targetPath, canonicalPath);
assert.strictEqual(error.cause, cause);
assert.equal(
error.message,
`Failed to inspect legacy desktop user-data path at "${legacyPath}".`,
`Failed to migrate legacy desktop user-data path from "${legacyPath}" to "${canonicalPath}".`,
);
}),
{ legacyPathProbeError: cause },
{
existingUserDataPaths: [legacyPath],
renameError: cause,
},
);
});

Expand Down
71 changes: 58 additions & 13 deletions apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,38 @@ const AppPackageMetadata = Schema.Struct({
});
const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata));

export class DesktopUserDataPathResolutionError extends Schema.TaggedErrorClass<DesktopUserDataPathResolutionError>()(
"DesktopUserDataPathResolutionError",
export class DesktopUserDataPathInspectionError extends Schema.TaggedErrorClass<DesktopUserDataPathInspectionError>()(
"DesktopUserDataPathInspectionError",
{
legacyPath: Schema.String,
path: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`;
return `Failed to inspect desktop user-data path at "${this.path}".`;
}
}

export class DesktopUserDataPathMigrationError extends Schema.TaggedErrorClass<DesktopUserDataPathMigrationError>()(
"DesktopUserDataPathMigrationError",
{
path: Schema.String,
targetPath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to migrate legacy desktop user-data path from "${this.path}" to "${this.targetPath}".`;
}
}

export class DesktopAppIdentity extends Context.Service<
DesktopAppIdentity,
{
readonly resolveUserDataPath: Effect.Effect<string, DesktopUserDataPathResolutionError>;
readonly resolveUserDataPath: Effect.Effect<
string,
DesktopUserDataPathInspectionError | DesktopUserDataPathMigrationError
>;
readonly configure: Effect.Effect<void>;
}
>()("@t3tools/desktop/app/DesktopAppIdentity") {}
Expand Down Expand Up @@ -91,22 +107,51 @@ export const make = Effect.gen(function* () {
});

const resolveUserDataPath = Effect.gen(function* () {
const legacyPath = environment.path.join(
const canonicalPath = environment.path.join(
environment.appDataDirectory,
environment.legacyUserDataDirName,
environment.userDataDirName,
);
const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe(
const canonicalPathExists = yield* fileSystem.exists(canonicalPath).pipe(
Effect.mapError(
(cause) =>
new DesktopUserDataPathResolutionError({
legacyPath,
new DesktopUserDataPathInspectionError({
path: canonicalPath,
cause,
}),
),
);
return legacyPathExists
? legacyPath
: environment.path.join(environment.appDataDirectory, environment.userDataDirName);
if (canonicalPathExists) {
return canonicalPath;
}

for (const legacyUserDataDirName of environment.legacyUserDataDirNames) {
const legacyPath = environment.path.join(environment.appDataDirectory, legacyUserDataDirName);
const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe(
Effect.mapError(
(cause) =>
new DesktopUserDataPathInspectionError({
path: legacyPath,
cause,
}),
),
);
if (!legacyPathExists) {
continue;
}

yield* fileSystem.rename(legacyPath, canonicalPath).pipe(
Effect.mapError(
(cause) =>
new DesktopUserDataPathMigrationError({
path: legacyPath,
targetPath: canonicalPath,
cause,
}),
),
);
return canonicalPath;
}
return canonicalPath;
}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath"));

const configure = Effect.gen(function* () {
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ describe("DesktopEnvironment", () => {
}),
);

it.effect("derives the historical userData candidate from the packaged release stage", () =>
Effect.gen(function* () {
const alpha = yield* makeEnvironment({ isPackaged: true, appVersion: "0.0.29-alpha.1" });
const nightly = yield* makeEnvironment({
isPackaged: true,
appVersion: "0.0.29-nightly.20260723.864",
});

assert.deepEqual(alpha.legacyUserDataDirNames, ["T3 Code (Alpha)"]);
assert.deepEqual(nightly.legacyUserDataDirNames, ["T3 Code (Alpha)", "T3 Code (Nightly)"]);
assert.equal(alpha.userDataDirName, "t3code");
assert.equal(nightly.userDataDirName, "t3code");
}),
);

it.effect("keeps implicit development state separate from production state", () =>
Effect.gen(function* () {
const development = yield* makeEnvironment(
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class DesktopEnvironment extends Context.Service<
readonly linuxDesktopEntryName: string;
readonly linuxWmClass: string;
readonly userDataDirName: string;
readonly legacyUserDataDirName: string;
readonly legacyUserDataDirNames: readonly string[];
readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings;
readonly runtimeInfo: DesktopRuntimeInfo;
readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;
Expand Down Expand Up @@ -161,7 +161,9 @@ const make = Effect.fn("desktop.environment.make")(function* (
isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata",
);
const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
const legacyUserDataDirNames = isDevelopment
? [displayName]
: [...new Set(["T3 Code (Alpha)", displayName])];
const resourcesPath = input.resourcesPath;

return DesktopEnvironment.of({
Expand Down Expand Up @@ -206,7 +208,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",
linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
userDataDirName,
legacyUserDataDirName,
legacyUserDataDirNames,
defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion),
runtimeInfo: resolveDesktopRuntimeInfo({
platform: input.platform,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo
> {
const shutdown = yield* DesktopShutdown.DesktopShutdown;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.flushRendererState;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flush renderer state before closing the window

When the user exits by closing the last window on Linux/Windows, window-all-closed calls app.quit only after the BrowserWindow has emitted closed; DesktopWindow then clears the main-window ref, so this new before-quit flush finds no live webContents and returns without asking the renderer to drain its debounced ui-state/composer preference writes. A preference changed shortly before closing the window can therefore be dropped. Please flush or prevent the window close before the renderer is destroyed, rather than only during before-quit.

Useful? React with 👍 / 👎.

yield* desktopWindow.flushMainWindowBounds;
yield* shutdown.request;
yield* shutdown.awaitComplete;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function makePoolLayer(
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
flushRendererState: Effect.void,
dispatchMenuAction: () => Effect.die("unexpected menu action"),
syncAppearance: Effect.void,
} satisfies DesktopWindow.DesktopWindow["Service"]),
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect";

import * as DesktopIpc from "./DesktopIpc.ts";
import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
import { getRendererState, setRendererState } from "./methods/rendererState.ts";
import {
clearConnectionCatalog,
getConnectionCatalog,
Expand Down Expand Up @@ -55,6 +56,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"

yield* ipc.handle(getClientSettings);
yield* ipc.handle(setClientSettings);
yield* ipc.handle(getRendererState);
yield* ipc.handle(setRendererState);
yield* ipc.handle(getConnectionCatalog);
yield* ipc.handle(setConnectionCatalog);
yield* ipc.handle(clearConnectionCatalog);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
"desktop:get-local-environment-bearer-token";
export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
export const GET_RENDERER_STATE_CHANNEL = "desktop:get-renderer-state";
export const SET_RENDERER_STATE_CHANNEL = "desktop:set-renderer-state";
export const REQUEST_RENDERER_STATE_FLUSH_CHANNEL = "desktop:request-renderer-state-flush";
export const RENDERER_STATE_FLUSH_COMPLETE_CHANNEL = "desktop:renderer-state-flush-complete";
export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog";
export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog";
export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog";
Expand Down
Loading
Loading