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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ build/
release/
release-mock/
.t3
# Marcode's worktree-local dev state. The dev runner defaults a linked worktree
# here, and tooling already documents it as ignored — `.t3` alone is upstream's
# name and never got renamed with the rest of the fork.
.marcode
.idea/
apps/web/.playwright
apps/web/playwright-report
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@
"tailwindcss": "^4.0.0",
"vite-plus": "catalog:"
},
"productName": "Marcode (Alpha)"
"productName": "Marcode"
}
2 changes: 1 addition & 1 deletion apps/desktop/scripts/electron-launcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const repoRoot = NodePath.resolve(desktopDir, "..", "..");
const devBundleIdSuffix = NodePath.basename(repoRoot)
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "");
export const APP_DISPLAY_NAME = isDevelopment ? "Marcode (Dev)" : "Marcode (Alpha)";
export const APP_DISPLAY_NAME = isDevelopment ? "Marcode (Dev)" : "Marcode";
export const APP_BUNDLE_ID = isDevelopment
? `app.marcode.desktop.dev.${devBundleIdSuffix || "local"}`
: "app.marcode.desktop";
Expand Down
100 changes: 91 additions & 9 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as TestClock from "effect/testing/TestClock";

import type * as Electron from "electron";

Expand Down Expand Up @@ -107,7 +110,9 @@ const withIdentity = <A, E, R>(
input: {
readonly calls?: ElectronAppCalls;
readonly environment?: TestEnvironmentInput;
readonly existsOverride?: (path: string) => Effect.Effect<boolean, PlatformError.PlatformError>;
readonly legacyPathExists?: boolean;
readonly legacyPathMatch?: string;
readonly legacyPathProbeError?: PlatformError.PlatformError;
readonly packageJson?: string;
readonly pngIconPath?: Option.Option<string>;
Expand All @@ -124,12 +129,15 @@ const withIdentity = <A, E, R>(
DesktopAppIdentity.layer.pipe(
Layer.provideMerge(
FileSystem.layerNoop({
exists: (path) =>
input.legacyPathProbeError
? Effect.fail(input.legacyPathProbeError)
: Effect.succeed(
input.legacyPathExists === true && path.includes("T3 Code (Alpha)"),
),
exists:
input.existsOverride ??
((path) =>
input.legacyPathProbeError
? Effect.fail(input.legacyPathProbeError)
: Effect.succeed(
input.legacyPathExists === true &&
path.includes(input.legacyPathMatch ?? "T3 Code (Alpha)"),
)),
readFileString: () =>
Effect.succeed(input.packageJson ?? '{"marcodeCommitHash":"abcdef1234567890"}'),
}),
Expand All @@ -143,7 +151,7 @@ const withIdentity = <A, E, R>(
};

describe("DesktopAppIdentity", () => {
it.effect("keeps using the legacy userData path when it already exists", () =>
it.effect("keeps using the legacy T3 Code userData path when it already exists", () =>
withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
Expand All @@ -155,6 +163,80 @@ describe("DesktopAppIdentity", () => {
),
);

// Covers the userData migration for users who installed before the
// "(Alpha)" suffix was dropped from the product name: their state must
// keep resolving to the old "Marcode (Alpha)" directory, not get orphaned
// by a fresh "marcode" directory.
it.effect("keeps using the legacy Marcode (Alpha) userData path when it already exists", () =>
withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const userDataPath = yield* identity.resolveUserDataPath;

assert.equal(userDataPath, "/Users/alice/Library/Application Support/Marcode (Alpha)");
}),
{ legacyPathExists: true, legacyPathMatch: "Marcode (Alpha)" },
),
);

// The concurrent rewrite checks every legacy candidate at once instead of
// stopping at the first match, so priority order has to come from the
// candidate list's order (oldest first), not from whichever filesystem
// check happens to settle first. Cover an install with both legacy
// directories present: the older "T3 Code (Alpha)" must still win.
it.effect("prefers the oldest legacy userData path when multiple legacy paths exist", () =>
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)");
}),
{
existsOverride: (path) =>
Effect.succeed(path.includes("T3 Code (Alpha)") || path.includes("Marcode (Alpha)")),
},
),
);

// Regression test for a real startup crash: resolveUserDataPath runs
// before DesktopClerk creates the Clerk bridge, which synchronously calls
// Electron's protocol.registerSchemesAsPrivileged — an API that must run
// before Electron's "ready" event fires. A version of this function that
// checked legacy names one at a time (sequentially) added enough extra
// wall-clock latency on a real filesystem for "ready" to fire first,
// crashing the packaged app on launch even though every unit test (all
// running against a mocked, effectively-instant filesystem) stayed green.
//
// Prove the checks run concurrently on the (virtual) clock: give every
// legacy candidate the same 10ms probe latency, then advance the clock by
// exactly one probe's worth of time. Concurrent probing settles both
// candidates in that single tick; a sequential loop would still be
// awaiting the second candidate's own 10ms turn, so the fiber would still
// be pending — pollUnsafe() catches that without risking a hang, since
// (unlike Fiber.join) it never waits.
it.effect("probes legacy userData paths concurrently, not one at a time", () =>
withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const resolveFiber = yield* identity.resolveUserDataPath.pipe(Effect.forkChild);

yield* TestClock.adjust(Duration.millis(10));
assert.isDefined(
resolveFiber.pollUnsafe(),
"expected every legacy candidate to be probed in parallel, not queued behind each other",
);

const userDataPath = yield* Fiber.join(resolveFiber);
assert.equal(userDataPath, "/Users/alice/Library/Application Support/Marcode (Alpha)");
}),
{
existsOverride: (path) =>
Effect.sleep(Duration.millis(10)).pipe(Effect.as(path.includes("Marcode (Alpha)"))),
},
).pipe(Effect.provide(TestClock.layer())),
);

it.effect("preserves failures while inspecting the legacy userData path", () => {
const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)";
const cause = PlatformError.systemError({
Expand Down Expand Up @@ -194,8 +276,8 @@ describe("DesktopAppIdentity", () => {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
yield* identity.configure;

assert.deepEqual(calls.setName, ["Marcode (Alpha)"]);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "Marcode (Alpha)");
assert.deepEqual(calls.setName, ["Marcode"]);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "Marcode");
assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
assert.deepEqual(calls.setDockIcon, ["/icon.png"]);
Expand Down
66 changes: 52 additions & 14 deletions apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";

import * as ElectronApp from "../electron/ElectronApp.ts";
Expand Down Expand Up @@ -48,22 +49,59 @@ const normalizeCommitHash = (value: string): Option.Option<string> => {
export const resolveUserDataPath = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const legacyPath = environment.path.join(
environment.appDataDirectory,
environment.legacyUserDataDirName,

// Check every prior product name's userData directory, oldest first, and
// keep using the first one that already exists on disk. This is a
// continuous "stay put if found" check (not a one-time copy), so an
// install that matched a legacy name keeps using it on every launch.
//
// The checks run CONCURRENTLY, not sequentially: this effect runs before
// DesktopClerk creates the Clerk bridge (see DesktopClerk.ts), which
// synchronously calls Electron's protocol.registerSchemesAsPrivileged —
// an API that must be called before Electron's "ready" event fires, on
// the real (unmocked) filesystem. Each extra sequential await here is
// extra wall-clock time for "ready" to win that race, and that race is
// real: it cost a startup crash once already (see git blame / the
// regression test below) when this loop briefly checked candidates one
// at a time. Probing concurrently keeps resolution to roughly one
// filesystem round-trip no matter how many legacy names get added.
//
// Effect.result (never fails) rather than letting fs.exists fail the
// forEach directly: this keeps the outcome deterministic regardless of
// concurrency — we pick the first-by-priority-order match or failure
// ourselves from the settled results, instead of depending on which
// concurrent fiber happens to fail first.
const legacyPaths = environment.legacyUserDataDirNames.map((legacyUserDataDirName) =>
environment.path.join(environment.appDataDirectory, legacyUserDataDirName),
);
const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe(
Effect.mapError(
(cause) =>
new DesktopUserDataPathResolutionError({
legacyPath,
cause,
}),
),
// Pair each probe with its own path up front (rather than indexing back into
// legacyPaths by position afterward) so every path stays a plain `string` —
// noUncheckedIndexedAccess types `array[i]` as `string | undefined`, and
// that undefined would otherwise leak into the resolved userData path.
const probes = yield* Effect.forEach(
legacyPaths,
(legacyPath) =>
Effect.result(fileSystem.exists(legacyPath)).pipe(
Effect.map((probe) => [legacyPath, probe] as const),
),
{ concurrency: "unbounded" },
);
return legacyPathExists
? legacyPath
: environment.path.join(environment.appDataDirectory, environment.userDataDirName);

for (const [legacyPath, probe] of probes) {
if (Result.isFailure(probe)) {
return yield* new DesktopUserDataPathResolutionError({
legacyPath,
cause: probe.failure,
});
}
}

const matched = probes.find(([, probe]) => Result.isSuccess(probe) && probe.success);
if (matched !== undefined) {
return matched[0];
}

return environment.path.join(environment.appDataDirectory, environment.userDataDirName);
}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath"));

export const make = Effect.gen(function* () {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
isDevelopment,
appDataDirectory: "/tmp/app-data",
userDataDirName: isDevelopment ? "t3code-dev" : "t3code",
legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)",
legacyUserDataDirNames: isDevelopment
? ["T3 Code (Dev)", "Marcode (Dev)"]
: ["T3 Code (Alpha)", "Marcode (Alpha)"],
path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") },
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);

Expand Down
21 changes: 14 additions & 7 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class DesktopEnvironment extends Context.Service<
readonly linuxApplicationsDir: string;
readonly appImagePath: Option.Option<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 All @@ -79,8 +79,9 @@ export class DesktopEnvironment extends Context.Service<
}
>()("@t3tools/desktop/app/DesktopEnvironment") {}

// Marcode is the user-facing desktop brand. Keep the legacy T3 Code names
// below for migration and compatibility paths; they are not menu labels.
// Marcode is the user-facing desktop brand. Keep the legacy T3 Code and
// Marcode (Alpha) names below for migration and compatibility paths; they
// are not menu labels.
const APP_BASE_NAME = "Marcode";

function resolveDesktopAppStageLabel(input: {
Expand All @@ -91,7 +92,7 @@ function resolveDesktopAppStageLabel(input: {
return "Dev";
}

return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "";
}

function resolveDesktopAppBranding(input: {
Expand All @@ -102,7 +103,7 @@ function resolveDesktopAppBranding(input: {
return {
baseName: APP_BASE_NAME,
stageLabel,
displayName: `${APP_BASE_NAME} (${stageLabel})`,
displayName: stageLabel === "" ? APP_BASE_NAME : `${APP_BASE_NAME} (${stageLabel})`,
};
}

Expand Down Expand Up @@ -171,7 +172,13 @@ const make = Effect.fn("desktop.environment.make")(function* (
t3Home: config.marcodeHome,
});
const userDataDirName = isDevelopment ? "marcode-dev" : "marcode";
const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
// Both prior product names, oldest first: the pre-Marcode "T3 Code" brand,
// then "Marcode (Alpha)" from before the (Alpha) suffix was dropped. A
// given install only ever matches one of these — resolveUserDataPath keeps
// using whichever legacy directory already exists on disk.
const legacyUserDataDirNames = isDevelopment
? ["T3 Code (Dev)", "Marcode (Dev)"]
: ["T3 Code (Alpha)", "Marcode (Alpha)"];
const linuxApplicationsDir = path.join(
Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")),
"applications",
Expand Down Expand Up @@ -222,7 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
linuxApplicationsDir,
appImagePath: config.appImagePath,
userDataDirName,
legacyUserDataDirName,
legacyUserDataDirNames,
defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion),
runtimeInfo: resolveDesktopRuntimeInfo({
platform: input.platform,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe("DesktopApplicationMenu", () => {
yield* configureMenu(selectedAction, applicationMenuTemplate);

const template = yield* Deferred.await(applicationMenuTemplate);
const applicationMenu = template.find((item) => item.label === "Marcode (Alpha)");
const applicationMenu = template.find((item) => item.label === "Marcode");
assert.isDefined(applicationMenu);
const fileMenu = template.find((item) => item.label === "File");
assert.isDefined(fileMenu);
Expand Down
16 changes: 9 additions & 7 deletions apps/mobile/src/components/BrandMark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Image } from "expo-image";
import { View } from "react-native";

import { AppText as Text } from "./AppText";
import { resolveMobileStageLabel } from "../lib/mobileBranding";

const appVariant = Constants.expoConfig?.extra?.appVariant;
const BRAND_MARK_SOURCE =
Expand All @@ -11,8 +12,7 @@ const BRAND_MARK_SOURCE =
: appVariant === "preview"
? require("../../../../assets/nightly/nightly-ios-1024.png")
: require("../../../../assets/prod/black-ios-1024.png");
const DEFAULT_STAGE_LABEL =
appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha";
const DEFAULT_STAGE_LABEL = resolveMobileStageLabel(appVariant);

export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) {
const compact = props.compact ?? false;
Expand All @@ -33,11 +33,13 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab
<View className="gap-1">
<View className="flex-row items-center gap-2">
<Text className="text-lg font-t3-bold tracking-[-0.4px] text-foreground">Marcode</Text>
<View className="rounded-full bg-subtle px-2 py-1">
<Text className="text-3xs font-t3-bold tracking-[1.1px] uppercase text-foreground-muted">
{stageLabel}
</Text>
</View>
{stageLabel ? (
<View className="rounded-full bg-subtle px-2 py-1">
<Text className="text-3xs font-t3-bold tracking-[1.1px] uppercase text-foreground-muted">
{stageLabel}
</Text>
</View>
) : null}
</View>
{!compact ? (
<Text className="text-xs font-medium text-foreground-muted">
Expand Down
Loading
Loading