Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
50 changes: 35 additions & 15 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,25 +116,45 @@
this fork's build are both non-development builds, so every identity
upstream derives from "is this a dev build" collapses onto the same value
for both: the same bundle id, the same "T3 Code (Alpha).app" path in
/Applications, the same ~/.t3/userdata state directory holding
state.sqlite and secrets, and the same Electron user data directory.
Installing a fork build therefore offered to replace the real app, and
running one would have adopted its live database — including the legacy
directory upstream migrates from, which is why legacyUserDataDirName is
redirected too rather than left pointing at "T3 Code (Alpha)". Fork builds
use the bundle id com.t3tools.t3code.fork, install as "T3 Code Fork.app",
and keep state in userdata-fork. Development paths are deliberately
untouched: `vp dev` in this repo is already the fork, and renaming them
would strand existing dev state. The URL scheme is also deliberately left
shared — t3code:// is the app's own internal origin, not just a deep-link
handler, so changing only the registration would be incoherent; two
installed apps contend for the scheme, which is untidy but not
destructive.
/Applications, the same ~/.t3 state tree holding state.sqlite and secrets,
and the same Electron user data directory. Installing a fork build
therefore offered to replace the real app, and running one would have
adopted its live database — including the legacy directory upstream
migrates from, which is why legacyUserDataDirName is redirected too rather
than left pointing at "T3 Code (Alpha)". The fork presents as "N3 Code":
bundle id com.t3tools.t3code.fork (unchanged across renames — it is the
stable identity macOS keys on), installs as "N3 Code.app", ships
N3-Code-* artifacts, packages placeholder "N" lettermark art from
assets/fork on all three platforms, and keeps all filesystem state under
the fork-owned base directory ~/.t3-fork (also stable across renames, so
display renames never orphan state).
Isolation is forked at the base directory, not the "userdata" leaf,
because the packaged app is two processes: the desktop resolves its own
state directory and hands only baseDir (as bootstrap t3Home) to the
bundled server child, which independently derives its stateDir, caches/
and worktrees/ from it. A desktop-side leaf rename shipped in v0.1.1 and
left the server child writing the real ~/.t3/userdata — see
FORK-DATA-ISOLATION-HANDOFF.md. Moving the base keeps both halves on
upstream's own leaf derivations and isolates all three shared directories
at once, with no fork hunks in apps/server. Development paths are
deliberately untouched: `vp dev` in this repo is already the fork, dev
keeps ~/.t3/dev, and renaming it would strand existing dev state. The URL
scheme is also deliberately left shared — t3code:// is the app's own
internal origin, not just a deep-link handler, so changing only the
registration would be incoherent; two installed apps contend for the
scheme, which is untidy but not destructive. Known carve-out: the WSL
backend bootstrap deliberately omits t3Home, so WSL-side state is not
isolated by this customization.
tier: 4
files: []
files:
- assets/fork/n3-macos-1024.png
- assets/fork/n3-universal-1024.png
- assets/fork/n3-windows.ico
shadows: []
watch:
- apps/desktop/src/app/DesktopEnvironment.ts
- apps/desktop/src/backend/DesktopBackendConfiguration.ts
- apps/server/src/config.ts
- scripts/build-desktop-artifact.ts
verify:
- apps/web/src/__fork_guards__/forkAppIdentity.test.ts
Expand Down
373 changes: 373 additions & 0 deletions FORK-DATA-ISOLATION-HANDOFF.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,8 @@ describe("DesktopAppIdentity", () => {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
yield* identity.configure;

assert.deepEqual(calls.setName, ["T3 Code Fork (Alpha)"]);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code Fork (Alpha)");
assert.deepEqual(calls.setName, ["N3 Code (Alpha)"]);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "N3 Code (Alpha)");
assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
assert.deepEqual(calls.setDockIcon, ["/icon.png"]);
Expand Down
16 changes: 8 additions & 8 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ describe("DesktopConnectionCatalogStore", () => {
_tag: "PermissionDenied",
module: "FileSystem",
method: "readFileString",
pathOrDescriptor: `${baseDir}/userdata-fork/connection-catalog.json`,
pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`,
});
const fileSystemLayer = Layer.succeed(
FileSystem.FileSystem,
Expand All @@ -271,11 +271,11 @@ describe("DesktopConnectionCatalogStore", () => {
error,
DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError,
);
assert.equal(error.catalogPath, `${baseDir}/userdata-fork/connection-catalog.json`);
assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`);
assert.strictEqual(error.cause, permissionError);
assert.equal(
error.message,
`Failed to read the desktop connection catalog at ${baseDir}/userdata-fork/connection-catalog.json.`,
`Failed to read the desktop connection catalog at ${baseDir}/userdata/connection-catalog.json.`,
);
assert.notEqual(error.message, permissionError.message);
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
Expand All @@ -291,7 +291,7 @@ describe("DesktopConnectionCatalogStore", () => {
_tag: "PermissionDenied",
module: "FileSystem",
method: "makeDirectory",
pathOrDescriptor: `${baseDir}/userdata-fork`,
pathOrDescriptor: `${baseDir}/userdata`,
});
const fileSystemLayer = Layer.succeed(
FileSystem.FileSystem,
Expand All @@ -309,11 +309,11 @@ describe("DesktopConnectionCatalogStore", () => {
DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError,
);
assert.equal(error.operation, "create-directory");
assert.equal(error.path, `${baseDir}/userdata-fork`);
assert.equal(error.path, `${baseDir}/userdata`);
assert.strictEqual(error.cause, permissionError);
assert.equal(
error.message,
`Desktop connection catalog write failed during create-directory at ${baseDir}/userdata-fork.`,
`Desktop connection catalog write failed during create-directory at ${baseDir}/userdata.`,
);
assert.notEqual(error.message, permissionError.message);
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
Expand Down Expand Up @@ -398,14 +398,14 @@ describe("DesktopConnectionCatalogStore", () => {
DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError,
);
assert.equal(error.operation, "decrypt-catalog");
assert.equal(error.catalogPath, `${baseDir}/userdata-fork/connection-catalog.json`);
assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`);
assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError);
const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError;
assert.instanceOf(decryptError.cause, Error);
assert.equal(decryptError.cause.message, "invalid encrypted catalog");
assert.equal(
error.message,
`Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata-fork/connection-catalog.json.`,
`Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`,
);
assert.notEqual(error.message, decryptError.message);
yield* Ref.set(failDecrypt, false);
Expand Down
25 changes: 13 additions & 12 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,16 @@ describe("DesktopEnvironment", () => {
assert.equal(environment.isDevelopment, true);
assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support");
assert.equal(environment.baseDir, "/tmp/t3");
assert.equal(environment.stateDir, "/tmp/t3/userdata-fork");
assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata-fork/desktop-settings.json");
assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata-fork/client-settings.json");
assert.equal(environment.stateDir, "/tmp/t3/userdata");
assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json");
assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json");
assert.equal(
environment.savedEnvironmentRegistryPath,
"/tmp/t3/userdata-fork/saved-environments.json",
"/tmp/t3/userdata/saved-environments.json",
);
assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata-fork/settings.json");
assert.equal(environment.logDir, "/tmp/t3/userdata-fork/logs");
assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata-fork/browser-artifacts");
assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
assert.equal(environment.rootDir, "/repo");
assert.equal(environment.appRoot, "/repo");
assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
Expand Down Expand Up @@ -91,10 +91,10 @@ describe("DesktopEnvironment", () => {
);

assert.equal(environment.isDevelopment, false);
assert.equal(environment.stateDir, "/tmp/t3/userdata-fork");
assert.equal(environment.logDir, "/tmp/t3/userdata-fork/logs");
assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata-fork/browser-artifacts");
assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata-fork/settings.json");
assert.equal(environment.stateDir, "/tmp/t3/userdata");
assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
}),
);

Expand All @@ -107,7 +107,8 @@ describe("DesktopEnvironment", () => {
const production = yield* makeEnvironment();

assert.equal(development.stateDir, "/Users/alice/.t3/dev");
assert.equal(production.stateDir, "/Users/alice/.t3/userdata-fork");
assert.equal(production.baseDir, "/Users/alice/.t3-fork");
assert.equal(production.stateDir, "/Users/alice/.t3-fork/userdata");
}),
);

Expand Down
37 changes: 23 additions & 14 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class DesktopEnvironment extends Context.Service<
>()("@t3tools/desktop/app/DesktopEnvironment") {}

// fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity
const APP_BASE_NAME = "T3 Code Fork";
const APP_BASE_NAME = "N3 Code";
// fork:end fork-app-identity

function resolveDesktopAppStageLabel(input: {
Expand Down Expand Up @@ -150,30 +150,39 @@ const make = Effect.fn("desktop.environment.make")(function* (
? path.join(homeDirectory, "Library", "Application Support")
: Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
const configuredBaseDir = config.t3Home;
const baseDir = Option.getOrElse(configuredBaseDir, () => path.join(homeDirectory, ".t3"));
// fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity
// A packaged fork build must not share ~/.t3 with an installed upstream
// release. The base directory is the one input both halves of the app derive
// shared state from: the desktop resolves stateDir from it below, and hands
// it to the bundled server child as bootstrap t3Home
// (DesktopBackendConfiguration), which derives its own stateDir plus caches/
// and worktrees/ from it. Forking the base — rather than renaming the
// "userdata" leaf, which the server re-derives independently and would not
// pick up — keeps both derivations upstream's and isolates all three shared
// directories at once. T3CODE_HOME still wins, so `vp dev` and explicit
// overrides behave exactly as upstream.
const baseDir = Option.getOrElse(configuredBaseDir, () =>
path.join(homeDirectory, isDevelopment ? ".t3" : ".t3-fork"),
);
// fork:end fork-app-identity
const rootDir = path.resolve(input.dirname, "../../..");
const appRoot = input.isPackaged ? input.appPath : rootDir;
const branding = resolveDesktopAppBranding({
isDevelopment,
appVersion: input.appVersion,
});
const displayName = branding.displayName;
// fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity
// Every packaged-build path that upstream points at shared "t3code" names is
// redirected to a fork-owned name. Upstream's release and this fork's build
// are both non-development builds carrying the same bundle id, so without
// this they resolve to the same state directory and the same Electron user
// data directory, and the fork adopts the installed app's live database.
// Development paths are left alone: `vp dev` in this repo is already the
// fork, and moving them would strand the dev state that exists today.
const stateDir = path.join(
baseDir,
isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata-fork",
isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata",
);
// fork:begin fork-app-identity — see .fork/customizations.yaml#fork-app-identity
// Electron-side identity stays forked: both builds are non-development, so
// without these overrides they share the same Electron user data directory —
// and the legacy directory upstream migrates from, which is why
// legacyUserDataDirName is redirected too rather than left pointing at
// upstream's "T3 Code (Alpha)".
const userDataDirName = isDevelopment ? "t3code-dev" : "t3code-fork";
// Upstream migrates from a legacy directory when one exists. Pointing that at
// upstream's own "T3 Code (Alpha)" would hand the fork the real app's data
// through the back door even with the names above separated.
const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Fork)";
// fork:end fork-app-identity
const resourcesPath = input.resourcesPath;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ describe("DesktopBackendConfiguration", () => {
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});
const settingsPath = path.join(baseDir, "userdata-fork", "settings.json");
const settingsPath = path.join(baseDir, "userdata", "settings.json");
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe("DesktopClientSettings diagnostics", () => {
_tag: "PermissionDenied",
module: "FileSystem",
method: "readFileString",
pathOrDescriptor: `${baseDir}/userdata-fork/client-settings.json`,
pathOrDescriptor: `${baseDir}/userdata/client-settings.json`,
});

return Effect.gen(function* () {
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/settings/DesktopSavedEnvironments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ describe("DesktopSavedEnvironments", () => {
const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-saved-environments-test-",
});
const registryPath = `${baseDir}/userdata-fork/saved-environments.json`;
const registryPath = `${baseDir}/userdata/saved-environments.json`;
const permissionError = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
Expand Down Expand Up @@ -439,7 +439,7 @@ describe("DesktopSavedEnvironments", () => {
_tag: "PermissionDenied",
module: "FileSystem",
method: "makeDirectory",
pathOrDescriptor: `${baseDir}/userdata-fork`,
pathOrDescriptor: `${baseDir}/userdata`,
});
const fileSystemLayer = Layer.succeed(
FileSystem.FileSystem,
Expand All @@ -455,11 +455,11 @@ describe("DesktopSavedEnvironments", () => {
const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip);
assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError);
assert.equal(error.operation, "create-directory");
assert.equal(error.path, `${baseDir}/userdata-fork`);
assert.equal(error.path, `${baseDir}/userdata`);
assert.strictEqual(error.cause, permissionError);
assert.equal(
error.message,
`Desktop saved-environment write failed during create-directory at ${baseDir}/userdata-fork.`,
`Desktop saved-environment write failed during create-directory at ${baseDir}/userdata.`,
);
assert.notEqual(error.message, permissionError.message);
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
Expand Down
Loading
Loading