From 4e90090bfd248c14e448c0a1e6118313bdcb94d0 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sat, 19 Sep 2026 23:29:20 -0400 Subject: [PATCH 01/11] feat(contracts): environment icon overrides carry emoji, monogram, and image The `environmentIcon` server setting held one of seven machine kinds, so two generic servers wore the same glyph with no way to tell them apart. Widen it to an `EnvironmentIconOverride` union of a named icon with an optional color, an emoji, a one or two character monogram, and a capped inline PNG or WebP. A bare machine kind still decodes, lifted into the named variant, so settings files and snapshots from older servers keep their pick; a plain pick of a legacy kind still encodes as that string, so older servers accept the patch and older clients decode the snapshot. Anything richer encodes as the object, which older peers drop to null through the existing forward-compatible decoding. One place would have corrupted an object-valued setting. `deepMerge` fuses two variants with different keys into a hybrid, so the patch applier now replaces `environmentIcon` whole. The default stripper is fine as it is, because the field's default is `null` and the stripper only recurses when both sides are objects. A new `environmentIconOverride` capability tells clients the server stores the object form. A monogram is held to two characters in the schema itself. Projects check that bound in the decider; a settings patch has no decider behind it, so the environment contract is the write boundary. The color, emoji, monogram, and Lucide-name leaves move to a shared module that project icons now import, so there is one definition of each. No user-visible change. The web picker writes the named variant and `resolveEnvironmentMachineKind` reads it back, so every renderer still receives a machine kind. Tests cover each variant's round trip, the legacy string in both directions, an unknown variant collapsing to null without failing the snapshot, the merge replacement, and the persisted file after swapping variants. Claude Fable 5.1 via Claude Code --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/serverSettings.test.ts | 39 +++++++++ .../settings/EnvironmentIconPicker.tsx | 6 +- packages/contracts/src/environment.ts | 85 +++++++++++++++++++ packages/contracts/src/icon.ts | 76 +++++++++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 38 ++------- packages/contracts/src/server.test.ts | 9 ++ packages/contracts/src/server.ts | 7 +- packages/contracts/src/settings.test.ts | 72 ++++++++++++++-- packages/contracts/src/settings.ts | 9 +- packages/shared/src/serverSettings.test.ts | 23 +++++ packages/shared/src/serverSettings.ts | 10 +++ 13 files changed, 330 insertions(+), 46 deletions(-) create mode 100644 packages/contracts/src/icon.ts diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index d58c014d86e8..fc0689d4d609 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -241,6 +241,7 @@ export const make = Effect.gen(function* () { pullRequestStackActions: true, threadPullRequestLinking: true, environmentIcon: true, + environmentIconOverride: true, projectCloneTracking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index b2caba4a0347..5c17cf69a7c7 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -30,6 +30,10 @@ import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.t const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); +// The settings file as written, before any defaults or lifting apply. +const decodeRawSettingsJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); const makeServerSettingsLayer = () => ServerSettingsModule.layer.pipe( @@ -303,6 +307,41 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists an environment icon whole and swaps variants without leftovers", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // Inspect the raw file: a partial object or a stale key from the + // previous variant would only be visible before schema decoding. + const readPersistedIcon = fileSystem.readFileString(serverConfig.settingsPath).pipe( + Effect.flatMap(decodeRawSettingsJson), + Effect.map((raw) => raw.environmentIcon), + ); + + yield* serverSettings.updateSettings({ + environmentIcon: { kind: "icon", name: "laptop", color: "red" }, + }); + assert.deepEqual(yield* readPersistedIcon, { kind: "icon", name: "laptop", color: "red" }); + + yield* serverSettings.updateSettings({ environmentIcon: { kind: "emoji", emoji: "๐Ÿš€" } }); + assert.deepEqual(yield* readPersistedIcon, { kind: "emoji", emoji: "๐Ÿš€" }); + assert.deepEqual((yield* serverSettings.getSettings).environmentIcon, { + kind: "emoji", + emoji: "๐Ÿš€", + }); + + // A plain legacy kind lands on disk as the string an older server reads. + yield* serverSettings.updateSettings({ environmentIcon: { kind: "icon", name: "laptop" } }); + assert.strictEqual(yield* readPersistedIcon, "laptop"); + + yield* serverSettings.updateSettings({ environmentIcon: null }); + assert.isUndefined(yield* readPersistedIcon); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("persists and broadcasts thread settlement settings", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx index 0add490159a9..5fe23fff4f5c 100644 --- a/apps/web/src/components/settings/EnvironmentIconPicker.tsx +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -112,7 +112,11 @@ export function EnvironmentIconMenu({ value={resolved} onValueChange={(next) => { if (lock !== null || !isEnvironmentMachineKind(next)) return; - updateSettings({ environmentIcon: next === detected ? null : next }); + // A plain machine kind encodes to the bare string on the wire, so a + // server that predates the object form accepts this unchanged. + updateSettings({ + environmentIcon: next === detected ? null : { kind: "icon", name: next }, + }); }} > {ENVIRONMENT_MACHINE_KINDS.map((kind) => ( diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 089133d878a4..dbe4170924a3 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; import { EnvironmentId, @@ -8,6 +9,14 @@ import { ThreadId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; +import { + IconColor, + IconEmoji, + IconImageDataUrl, + isMonogramLength, + LucideIconName, + MonogramText, +} from "./icon.ts"; /** Wire version for orchestration snapshots, streams, commands, and RPC payloads. */ export const ORCHESTRATION_PROTOCOL_VERSION = 1; @@ -42,6 +51,76 @@ export const EnvironmentMachineKind = Schema.Literals(ENVIRONMENT_MACHINE_KINDS) export type EnvironmentMachineKind = typeof EnvironmentMachineKind.Type; export const isEnvironmentMachineKind = Schema.is(EnvironmentMachineKind); +/** + * A named glyph: one of the curated ids above, an id the clients add on top + * of them, or a Lucide id. One field rather than one variant per source, + * because renderers resolve the curated map first and fall through, so the + * name alone says which map answers. + */ +export const EnvironmentIconName = LucideIconName; +export type EnvironmentIconName = typeof EnvironmentIconName.Type; + +const EnvironmentNamedIcon = Schema.Struct({ + kind: Schema.Literal("icon"), + name: EnvironmentIconName, + color: Schema.optionalKey(IconColor), +}); +const EnvironmentEmojiIcon = Schema.Struct({ + kind: Schema.Literal("emoji"), + emoji: IconEmoji, +}); +// Projects check the two-character bound in the decider; a settings patch has +// no such boundary, so the environment schema holds it. +const EnvironmentMonogramIcon = Schema.Struct({ + kind: Schema.Literal("monogram"), + text: MonogramText.check(Schema.makeFilter(isMonogramLength)), + color: Schema.optionalKey(IconColor), +}); +/** + * The only setting that holds bytes. It lives in one environment's own + * `settings.json`, never a map across environments, and the settings stream + * sends the whole object to every client on change, so one icon per payload + * is the entire exposure. `IconImageDataUrl` caps the size. + */ +const EnvironmentImageIcon = Schema.Struct({ + kind: Schema.Literal("image"), + dataUrl: IconImageDataUrl, +}); + +const EnvironmentIcon = Schema.Union([ + EnvironmentNamedIcon, + EnvironmentEmojiIcon, + EnvironmentMonogramIcon, + EnvironmentImageIcon, +]); +export type EnvironmentIcon = typeof EnvironmentIcon.Type; + +/** + * What a user picked for an environment's icon. Servers that predate the + * override stored a bare machine kind, and that string form stays on the + * wire and on disk for a plain pick of one of the seven kinds: it is what an + * older server accepts in a patch and what an older client can decode from a + * snapshot, so the picks that always existed keep working across versions. + * Anything richer (a color, a name outside the seven, another variant) + * encodes as the object, which older peers drop to null through + * `ForwardCompatibleNullable`; the `environmentIconOverride` capability keeps + * clients from sending an object to a server that would reject it. + */ +export const EnvironmentIconOverride = Schema.Union([EnvironmentMachineKind, EnvironmentIcon]).pipe( + Schema.decodeTo( + EnvironmentIcon, + SchemaTransformation.transform({ + decode: (icon): EnvironmentIcon => + typeof icon === "string" ? { kind: "icon", name: icon } : icon, + encode: (icon) => + icon.kind === "icon" && icon.color === undefined && isEnvironmentMachineKind(icon.name) + ? icon.name + : icon, + }), + ), +); +export type EnvironmentIconOverride = typeof EnvironmentIconOverride.Type; + export const ExecutionEnvironmentPlatform = Schema.Struct({ os: ExecutionEnvironmentPlatformOs, arch: ExecutionEnvironmentPlatformArch, @@ -180,6 +259,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ setting. Older servers drop the key on write, so clients show the picker inert rather than offering a choice that would never stick. */ environmentIcon: Schema.optionalKey(Schema.Boolean), + /** Server stores `environmentIcon` as an `EnvironmentIconOverride` object. + `environmentIcon` alone means the server only knows the bare machine + kind: it would reject an object patch, so clients that see only the + older flag keep writing the string form and offer only the seven + legacy kinds. */ + environmentIconOverride: Schema.optionalKey(Schema.Boolean), /** The desktop app supervising this server can be driven over RPC: server.updateServer runs its check -> download -> relaunch. Absent on desktop servers whose app predates the remote trigger, where clients diff --git a/packages/contracts/src/icon.ts b/packages/contracts/src/icon.ts new file mode 100644 index 000000000000..6d37d77edb75 --- /dev/null +++ b/packages/contracts/src/icon.ts @@ -0,0 +1,76 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Leaf schemas shared by every user-chosen icon (project overrides and + * environment overrides). The composite override shapes differ per owner and + * live beside the owner; only the pieces that must agree across them live here. + */ + +export const IconColor = Schema.Literals([ + "gray", + "red", + "orange", + "amber", + "yellow", + "lime", + "green", + "emerald", + "teal", + "cyan", + "sky", + "blue", + "indigo", + "violet", + "purple", + "fuchsia", + "pink", + "rose", +]); +export type IconColor = typeof IconColor.Type; + +/** A Lucide icon id, or any curated id that follows the same kebab-case grammar. */ +export const LucideIconName = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), +); +export type LucideIconName = typeof LucideIconName.Type; + +export const IconEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); +export type IconEmoji = typeof IconEmoji.Type; + +// Grapheme-count validation belongs to the write boundary, not snapshot decoding. +export const MonogramText = TrimmedNonEmptyString.check( + Schema.isMaxLength(32), + Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), +); +export type MonogramText = typeof MonogramText.Type; + +/** Whether `text` reads as at most two characters, the bound a monogram tile can hold. */ +export function isMonogramLength(text: string): boolean { + const Segmenter = (Intl as { Segmenter?: typeof Intl.Segmenter }).Segmenter; + const count = + typeof Segmenter === "function" + ? Array.from(new Segmenter(undefined, { granularity: "grapheme" }).segment(text)).length + : Array.from(text.replace(/\p{M}/gu, "")).length; + return count <= 2; +} + +/** + * Encoded length budget for an inline raster icon. A 64 by 64 PNG with alpha + * is at most 16 KiB of pixels before compression, which base64 grows by a + * third; the cap leaves room for the container without admitting a photo. + */ +export const ICON_IMAGE_DATA_URL_MAX_LENGTH = 32_768; + +/** + * A small raster icon carried inline. The prefix is pinned to two known + * raster types rather than any `data:image/` so an SVG, which can script, + * never reaches a renderer through this field. + */ +export const IconImageDataUrl = Schema.String.check( + Schema.isMaxLength(ICON_IMAGE_DATA_URL_MAX_LENGTH), + Schema.isPattern(/^data:image\/(?:png|webp);base64,[A-Za-z0-9+/]+={0,2}$/), +); +export type IconImageDataUrl = typeof IconImageDataUrl.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 978a0459e69b..d457c1fd148a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas.ts"; +export * from "./icon.ts"; export * from "./assistantCitations.ts"; export * from "./composerContext.ts"; export * from "./composerContextClipboard.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1ae704d3f666..cbdad1aae361 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -6,6 +6,7 @@ import * as Struct from "effect/Struct"; import { OrchestrationMessageContext } from "./composerContext.ts"; import { ProviderOptionSelections } from "./model.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; +import { IconColor, IconEmoji, LucideIconName, MonogramText } from "./icon.ts"; import { ApprovalRequestId, CheckpointRef, @@ -451,40 +452,15 @@ export const ProjectFaviconPath = TrimmedNonEmptyString.check( ); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; -export const ProjectIconColor = Schema.Literals([ - "gray", - "red", - "orange", - "amber", - "yellow", - "lime", - "green", - "emerald", - "teal", - "cyan", - "sky", - "blue", - "indigo", - "violet", - "purple", - "fuchsia", - "pink", - "rose", -]); -export type ProjectIconColor = typeof ProjectIconColor.Type; +// One definition of a color and a monogram, shared with environment icons. +export const ProjectIconColor = IconColor; +export type ProjectIconColor = IconColor; -const ProjectLucideIconName = TrimmedNonEmptyString.check( - Schema.isMaxLength(64), - Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), -); +const ProjectLucideIconName = LucideIconName; -const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); +const ProjectEmoji = IconEmoji; -// Grapheme-count validation belongs to the server command boundary, not snapshot decoding. -export const ProjectMonogramText = TrimmedNonEmptyString.check( - Schema.isMaxLength(32), - Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), -); +export const ProjectMonogramText = MonogramText; const ProjectLucideIcon = Schema.Struct({ kind: Schema.Literal("lucide"), diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 9112896eb1c9..4c651a8bab01 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -224,6 +224,15 @@ describe("resolveEnvironmentMachineKind", () => { ).toBe("mac-mini"); }); + it("uses detection when the pick is not a machine kind", () => { + expect( + resolveEnvironmentMachineKind({ + environment: descriptor({ machine: "mac-mini" }), + settings: decodeSettings({ environmentIcon: { kind: "emoji", emoji: "๐Ÿš€" } }), + }), + ).toBe("mac-mini"); + }); + it("uses detection from a bare descriptor before connecting", () => { expect(resolveEnvironmentMachineKind({ environment: descriptor({ machine: "laptop" }) })).toBe( "laptop", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c137cac9ac7a..b672e48e2fd6 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { type EnvironmentMachineKind, ExecutionEnvironmentDescriptor, + isEnvironmentMachineKind, ServerSelfUpdateMethod, } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; @@ -634,7 +635,11 @@ export function resolveEnvironmentMachineKind( readonly settings?: Pick; } | null, ): EnvironmentMachineKind { - return config?.settings?.environmentIcon ?? config?.environment.platform.machine ?? "server"; + const picked = config?.settings?.environmentIcon; + if (picked?.kind === "icon" && isEnvironmentMachineKind(picked.name)) { + return picked.name; + } + return config?.environment.platform.machine ?? "server"; } const ServerUpsertKeybindingReplaceTarget = Schema.Struct({ diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 16b9418ab7cc..e2e72c104d78 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -962,21 +962,75 @@ describe("ServerSettings environment icon", () => { expect(decodeServerSettings({}).environmentIcon).toBeNull(); }); - it("keeps a kind this build knows", () => { - expect(decodeServerSettings({ environmentIcon: "mac-mini" }).environmentIcon).toBe("mac-mini"); - expect(decodeServerSettings({ environmentIcon: "linux" }).environmentIcon).toBe("linux"); + it("lifts the bare machine kind an older server stored into the icon variant", () => { + expect(decodeServerSettings({ environmentIcon: "mac-mini" }).environmentIcon).toEqual({ + kind: "icon", + name: "mac-mini", + }); + }); + + it("round-trips every variant", () => { + for (const environmentIcon of [ + { kind: "icon", name: "cpu", color: "violet" }, + { kind: "emoji", emoji: "๐Ÿš€" }, + { kind: "monogram", text: "K8", color: "teal" }, + { kind: "image", dataUrl: "data:image/png;base64,iVBORw==" }, + ] as const) { + const settings = decodeServerSettings({ environmentIcon }); + expect(settings.environmentIcon).toEqual(environmentIcon); + expect(encodeServerSettings(settings).environmentIcon).toEqual(environmentIcon); + } }); - it("decodes a kind from a newer server as null instead of failing the snapshot", () => { + it("encodes a plain pick of a legacy kind as the string older peers accept", () => { + const plain = decodeServerSettings({ environmentIcon: { kind: "icon", name: "laptop" } }); + expect(encodeServerSettings(plain).environmentIcon).toBe("laptop"); + + // A color is more than the string form can carry, so it stays an object. + const colored = decodeServerSettings({ + environmentIcon: { kind: "icon", name: "laptop", color: "red" }, + }); + expect(encodeServerSettings(colored).environmentIcon).toEqual({ + kind: "icon", + name: "laptop", + color: "red", + }); + }); + + it("decodes an icon from a newer server as null instead of failing the snapshot", () => { expect(decodeServerSettings({ environmentIcon: "toaster" }).environmentIcon).toBeNull(); + expect( + decodeServerSettings({ environmentIcon: { kind: "hologram", frames: 3 } }).environmentIcon, + ).toBeNull(); + + // One bad field drops the whole icon; every other setting survives. + const settings = decodeServerSettings({ + environmentIcon: { kind: "image", dataUrl: "data:image/svg+xml;base64,PHN2Zz4=" }, + addProjectBaseDirectory: "~/Development", + }); + expect(settings.environmentIcon).toBeNull(); + expect(settings.addProjectBaseDirectory).toBe("~/Development"); }); - it("round-trips through encode", () => { - const settings = decodeServerSettings({ environmentIcon: "laptop" }); - expect(encodeServerSettings(settings).environmentIcon).toBe("laptop"); + it("holds a monogram to two characters at the write boundary", () => { + expect(() => + decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text: "ABC" } }), + ).toThrow(); + expect( + decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text: "e\u0301K" } }) + .environmentIcon, + ).toEqual({ kind: "monogram", text: "e\u0301K" }); + }); - const linuxSettings = decodeServerSettings({ environmentIcon: "linux" }); - expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux"); + it("rejects a patch carrying a variant this build does not know", () => { + expect(() => + decodeServerSettingsPatch({ environmentIcon: { kind: "hologram", frames: 3 } }), + ).toThrow(); + expect(decodeServerSettingsPatch({ environmentIcon: null }).environmentIcon).toBeNull(); + expect(decodeServerSettingsPatch({ environmentIcon: "linux" }).environmentIcon).toEqual({ + kind: "icon", + name: "linux", + }); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 94bc27734923..910aba3529f8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -12,7 +12,7 @@ import { TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; -import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; +import { EnvironmentIconOverride, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { CustomModelSetting, @@ -1194,10 +1194,11 @@ export const ServerSettings = Schema.Struct({ * The icon clients draw for this environment. Null means "use what the * server detected" (`environment.platform.machine`), falling back to a * generic server. Lives on the server, not the client, so every device - * sees the same machine. A kind picked on a newer server decodes as null + * sees the same machine. An icon picked on a newer server decodes as null * here rather than failing the whole settings snapshot for an older client. + * The legacy bare machine kind still decodes; see `EnvironmentIconOverride`. */ - environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( + environmentIcon: ForwardCompatibleNullable(EnvironmentIconOverride).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), /** @@ -1507,7 +1508,7 @@ export const ServerSettingsPatch = Schema.Struct({ automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), - environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), + environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentIconOverride)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 05f625a1bd0f..d96daa102e8a 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -500,6 +500,29 @@ describe("serverSettings helpers", () => { }); }); + it("replaces the environment icon so a new variant carries no keys of the old one", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + environmentIcon: { kind: "icon" as const, name: "laptop", color: "red" as const }, + }; + + expect( + applyServerSettingsPatch(current, { + environmentIcon: { kind: "emoji", emoji: "๐Ÿš€" }, + }).environmentIcon, + ).toEqual({ kind: "emoji", emoji: "๐Ÿš€" }); + }); + + it("clears the environment icon with null", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + environmentIcon: { kind: "emoji" as const, emoji: "๐Ÿš€" }, + }; + + expect(applyServerSettingsPatch(current, { environmentIcon: null }).environmentIcon).toBeNull(); + expect(applyServerSettingsPatch(current, {}).environmentIcon).toEqual(current.environmentIcon); + }); + it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => { const hubA = UsageLimitSourceId.make("cliproxy-a"); const hubB = UsageLimitSourceId.make("cliproxy-b"); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index d847cb2b0795..d1e72f36e073 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -262,6 +262,12 @@ function translateLegacyProjectOverridePatch( } as ServerSettingsPatch; } +/** + * Applies a client patch to the current settings. Most keys deep-merge so a + * client can send one field of a nested object; the keys destructured below + * are carved out because merging would keep values the client meant to + * clear, fuse two shapes of the same field, or double-apply a translation. + */ export function applyServerSettingsPatch( current: ServerSettings, rawPatch: ServerSettingsPatch, @@ -279,6 +285,9 @@ export function applyServerSettingsPatch( usagePriceOverrides: usagePriceOverridesPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. projectSettingsOverrides: projectSettingsOverridesPatch, + // Whole-value replacement: the variants have different keys, and deepMerge + // would fuse an emoji pick onto the named icon it replaces. + environmentIcon: environmentIconPatch, // Already translated into `projectSettingsOverrides` above; the legacy // maps are derived views and must never be merged directly. projectAgentBrowserAccessOverrides: _legacyBrowserAccess, @@ -394,6 +403,7 @@ export function applyServerSettingsPatch( ...(patch.sourceControlWriterModelSelection !== undefined ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } : {}), + ...(environmentIconPatch !== undefined ? { environmentIcon: environmentIconPatch } : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), ...(providerHealthRefreshInterval !== undefined ? { providerHealthRefreshInterval } : {}), }; From e14faae8f2ef38e5793a7d218d37b719313277da Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 20 Sep 2026 22:50:02 -0400 Subject: [PATCH 02/11] fix(contracts): monogram length check moves to the write boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-character bound sat inside `EnvironmentIcon`, which is also the decoded form of `EnvironmentIconOverride`, so it ran on every settings snapshot rather than only on a write. Grapheme counting depends on the runtime. `isMonogramLength` uses `Intl.Segmenter` where it exists and strips combining marks otherwise, and the two disagree: "เค•เฅเคทเค•เฅเคท" counts 2 with a segmenter and 4 without. Hermes ships no segmenter, so a monogram the server accepted could count longer on mobile, fail the decode, and get dropped to null by `ForwardCompatibleNullable`. That client alone would draw the detected glyph, with no way to tell why. The bound moves to `EnvironmentIconOverrideWrite`, used by `ServerSettingsPatch`. Snapshots decode what is stored; writes are checked. Both sibling comments already said this is where the check belongs, in `icon.ts` and in `orchestration.ts`. Also adds the missing encode coverage for `ServerSettingsPatch`. The client-to-server direction was untested, so nothing pinned the behavior that a plain pick of one of the seven legacy kinds still goes out as the bare string an older server accepts. Claude Opus 5 via Claude Code --- packages/contracts/src/environment.ts | 19 +++++++-- packages/contracts/src/settings.test.ts | 52 +++++++++++++++++++++++++ packages/contracts/src/settings.ts | 9 ++++- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index dbe4170924a3..4e7c499a4017 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -69,11 +69,9 @@ const EnvironmentEmojiIcon = Schema.Struct({ kind: Schema.Literal("emoji"), emoji: IconEmoji, }); -// Projects check the two-character bound in the decider; a settings patch has -// no such boundary, so the environment schema holds it. const EnvironmentMonogramIcon = Schema.Struct({ kind: Schema.Literal("monogram"), - text: MonogramText.check(Schema.makeFilter(isMonogramLength)), + text: MonogramText, color: Schema.optionalKey(IconColor), }); /** @@ -121,6 +119,21 @@ export const EnvironmentIconOverride = Schema.Union([EnvironmentMachineKind, Env ); export type EnvironmentIconOverride = typeof EnvironmentIconOverride.Type; +/** + * What a client may write. Projects check the two-character monogram bound in + * their decider; a settings patch has no such boundary, so it lives here. + * + * It stays off `EnvironmentIconOverride` because that schema also decodes + * snapshots, and grapheme counting differs by runtime: Hermes ships no + * `Intl.Segmenter`, so a monogram the server accepted can count longer on + * mobile. Checking it during decode would fail there, and + * `ForwardCompatibleNullable` would drop the stored icon to null on that + * client alone. + */ +export const EnvironmentIconOverrideWrite = EnvironmentIconOverride.check( + Schema.makeFilter((icon) => icon.kind !== "monogram" || isMonogramLength(icon.text)), +); + export const ExecutionEnvironmentPlatform = Schema.Struct({ os: ExecutionEnvironmentPlatformOs, arch: ExecutionEnvironmentPlatformArch, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e2e72c104d78..8a7eeec7c32c 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; +import { ENVIRONMENT_MACHINE_KINDS } from "./environment.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, @@ -18,6 +19,7 @@ const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); describe("storage cleanup settings", () => { @@ -1032,6 +1034,56 @@ describe("ServerSettings environment icon", () => { name: "linux", }); }); + + it("accepts a stored monogram the write boundary would reject", () => { + // Grapheme counting differs by runtime, so a monogram one client wrote can + // count longer on another. Only the write boundary counts; a snapshot + // decodes as stored, or that client would draw the detected glyph instead. + expect( + decodeServerSettings({ environmentIcon: { kind: "monogram", text: "ABC" } }).environmentIcon, + ).toEqual({ kind: "monogram", text: "ABC" }); + expect(() => + decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text: "ABC" } }), + ).toThrow(); + }); + + it("writes a plain pick of a legacy kind as the string an older server accepts", () => { + // Written out rather than read from `ENVIRONMENT_MACHINE_KINDS`: these are + // the kinds an older server accepts, so the list is frozen and must not + // follow a later build that detects something new. + const legacyKinds = [ + "server", + "cloud", + "linux", + "desktop", + "laptop", + "mac-mini", + "mac-studio", + ] as const; + expect(ENVIRONMENT_MACHINE_KINDS).toEqual(expect.arrayContaining([...legacyKinds])); + + for (const kind of legacyKinds) { + expect(encodeServerSettingsPatch({ environmentIcon: { kind: "icon", name: kind } })).toEqual({ + environmentIcon: kind, + }); + } + + // Anything an older server would reject stays an object for the capability to gate. + expect( + encodeServerSettingsPatch({ + environmentIcon: { kind: "icon", name: "laptop", color: "red" }, + }), + ).toEqual({ environmentIcon: { kind: "icon", name: "laptop", color: "red" } }); + expect(encodeServerSettingsPatch({ environmentIcon: { kind: "icon", name: "cpu" } })).toEqual({ + environmentIcon: { kind: "icon", name: "cpu" }, + }); + expect( + encodeServerSettingsPatch({ environmentIcon: { kind: "emoji", emoji: "\u{1f680}" } }), + ).toEqual({ + environmentIcon: { kind: "emoji", emoji: "\u{1f680}" }, + }); + expect(encodeServerSettingsPatch({ environmentIcon: null })).toEqual({ environmentIcon: null }); + }); }); const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 910aba3529f8..3b47ed4ce95e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -12,7 +12,12 @@ import { TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; -import { EnvironmentIconOverride, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; +import { + EnvironmentIconOverride, + EnvironmentIconOverrideWrite, + ThreadEnvMode, + WorktreeSubmodules, +} from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { CustomModelSetting, @@ -1508,7 +1513,7 @@ export const ServerSettingsPatch = Schema.Struct({ automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), - environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentIconOverride)), + environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentIconOverrideWrite)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), From 45b60637444637bc9069e7945abb1aaca165ef7b Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 20 Sep 2026 23:01:13 -0400 Subject: [PATCH 03/11] fix(contracts): inline environment icons are PNG only The pattern accepted `image/webp` as well, which nothing writes. Both clients downscale through a canvas and ask it for PNG, so a WebP value could only arrive by hand-editing `settings.json`. Narrowing it here rather than later keeps the accepted value space equal to the produced one from the first commit that defines the field. The comment also stops overstating the prefix. It is what keeps an SVG out of the `` on web, where Blink picks the decoder from the declared type; mobile's image library sniffs content, so there the guarantee comes from neither renderer having a script engine. And the prefix says nothing about frame count, since APNG declares `image/png`. Claude Opus 5 via Claude Code --- packages/contracts/src/icon.ts | 13 +++++++++---- packages/contracts/src/settings.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/icon.ts b/packages/contracts/src/icon.ts index 6d37d77edb75..310858b068e6 100644 --- a/packages/contracts/src/icon.ts +++ b/packages/contracts/src/icon.ts @@ -65,12 +65,17 @@ export function isMonogramLength(text: string): boolean { export const ICON_IMAGE_DATA_URL_MAX_LENGTH = 32_768; /** - * A small raster icon carried inline. The prefix is pinned to two known - * raster types rather than any `data:image/` so an SVG, which can script, - * never reaches a renderer through this field. + * A small raster icon carried inline. The prefix is pinned to PNG rather than + * any `data:image/`. On web that is what keeps an SVG, which can script, out + * of the ``: Blink picks the decoder from the declared type. Mobile's + * image library sniffs content instead, so there the guarantee is that neither + * renderer in use has a script engine, not the prefix. + * + * The bytes are never validated, so the prefix says nothing about frame count. + * APNG declares `image/png` and animates. */ export const IconImageDataUrl = Schema.String.check( Schema.isMaxLength(ICON_IMAGE_DATA_URL_MAX_LENGTH), - Schema.isPattern(/^data:image\/(?:png|webp);base64,[A-Za-z0-9+/]+={0,2}$/), + Schema.isPattern(/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/), ); export type IconImageDataUrl = typeof IconImageDataUrl.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 8a7eeec7c32c..5ff882513b0d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1012,6 +1012,14 @@ describe("ServerSettings environment icon", () => { }); expect(settings.environmentIcon).toBeNull(); expect(settings.addProjectBaseDirectory).toBe("~/Development"); + + // Any raster type but PNG lands here too, so widening the format later is a + // contract change rather than something a client can do on its own. + expect( + decodeServerSettings({ + environmentIcon: { kind: "image", dataUrl: "data:image/webp;base64,UklGRg==" }, + }).environmentIcon, + ).toBeNull(); }); it("holds a monogram to two characters at the write boundary", () => { From dcba75733523949b0a4f19eb7c4fc82ec5382796 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 20 Sep 2026 23:34:50 -0400 Subject: [PATCH 04/11] fix(contracts): freeze the legacy icon wire format apart from detected kinds The encoder wrote an uncoloured named icon as a bare string whenever its name was in `ENVIRONMENT_MACHINE_KINDS`. That list is the set of kinds a server can detect, and it grows. The container kind lands later in this stack. A build that detects a new kind would have encoded it as a string no older peer knows, and `ForwardCompatibleNullable` decodes an unknown string as null, so the user's icon would disappear on the other side. The two sets were equal here, so the bug was latent rather than live. Split them anyway. `LEGACY_ENVIRONMENT_MACHINE_KINDS` is frozen at the seven kinds that have ever had a bare-string wire form, the encoder keys off it, and anything else travels as the object the capability flag already gates. The test asserts the frozen list exactly, so growing it fails rather than silently widening what goes on the wire. --- packages/contracts/src/environment.ts | 25 ++++++++++++++++++++----- packages/contracts/src/settings.test.ts | 11 ++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4e7c499a4017..951f200281c1 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -34,11 +34,13 @@ export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; /** - * The curated set of machine shapes and OS identities an environment can wear as its icon. - * Servers detect one from the hardware they run on (`platform.machine`), and - * the `environmentIcon` server setting lets a user pick one instead. + * The kinds every server has ever stored as a bare string. Only these have + * that wire form: a server without `environmentIconOverride` accepts them and + * nothing else, and an older client decodes them from a snapshot. The list is + * frozen. A kind detected later travels as the object, because an older peer + * decodes a string it does not know as null and loses the icon. */ -export const ENVIRONMENT_MACHINE_KINDS = [ +export const LEGACY_ENVIRONMENT_MACHINE_KINDS = [ "server", "cloud", "linux", @@ -47,6 +49,17 @@ export const ENVIRONMENT_MACHINE_KINDS = [ "mac-mini", "mac-studio", ] as const; +export const isLegacyEnvironmentMachineKind = Schema.is( + Schema.Literals(LEGACY_ENVIRONMENT_MACHINE_KINDS), +); + +/** + * The curated set of machine shapes and OS identities an environment can wear as its icon. + * Servers detect one from the hardware they run on (`platform.machine`), and + * the `environmentIcon` server setting lets a user pick one instead. This list + * grows as detection improves, which is why the wire form above does not. + */ +export const ENVIRONMENT_MACHINE_KINDS = [...LEGACY_ENVIRONMENT_MACHINE_KINDS] as const; export const EnvironmentMachineKind = Schema.Literals(ENVIRONMENT_MACHINE_KINDS); export type EnvironmentMachineKind = typeof EnvironmentMachineKind.Type; export const isEnvironmentMachineKind = Schema.is(EnvironmentMachineKind); @@ -111,7 +124,9 @@ export const EnvironmentIconOverride = Schema.Union([EnvironmentMachineKind, Env decode: (icon): EnvironmentIcon => typeof icon === "string" ? { kind: "icon", name: icon } : icon, encode: (icon) => - icon.kind === "icon" && icon.color === undefined && isEnvironmentMachineKind(icon.name) + icon.kind === "icon" && + icon.color === undefined && + isLegacyEnvironmentMachineKind(icon.name) ? icon.name : icon, }), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 5ff882513b0d..7527ead06304 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { ENVIRONMENT_MACHINE_KINDS } from "./environment.ts"; +import { LEGACY_ENVIRONMENT_MACHINE_KINDS } from "./environment.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, @@ -1056,9 +1056,10 @@ describe("ServerSettings environment icon", () => { }); it("writes a plain pick of a legacy kind as the string an older server accepts", () => { - // Written out rather than read from `ENVIRONMENT_MACHINE_KINDS`: these are - // the kinds an older server accepts, so the list is frozen and must not - // follow a later build that detects something new. + // Written out rather than read from the contract: these are the kinds an + // older server accepts, so the list is frozen and must not follow a later + // build that detects something new. Asserting it exactly is what fails if + // someone grows it alongside the detected kinds. const legacyKinds = [ "server", "cloud", @@ -1068,7 +1069,7 @@ describe("ServerSettings environment icon", () => { "mac-mini", "mac-studio", ] as const; - expect(ENVIRONMENT_MACHINE_KINDS).toEqual(expect.arrayContaining([...legacyKinds])); + expect(LEGACY_ENVIRONMENT_MACHINE_KINDS).toEqual(legacyKinds); for (const kind of legacyKinds) { expect(encodeServerSettingsPatch({ environmentIcon: { kind: "icon", name: kind } })).toEqual({ From 910e5a3eef09630f927620f7ee8dd9156017870d Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 21 Sep 2026 00:22:33 -0400 Subject: [PATCH 05/11] fix(contracts): inline icon base64 must be whole quartets The pattern accepted any run of base64 characters with optional padding, so a truncated upload such as `data:image/png;base64,iVBORw` decoded to nothing and every surface showing that environment drew a broken image. Spell out whole quartets instead. Both clients validate through this schema before writing, so the tighter rule reaches the web canvas path and the mobile manipulator path without either repeating it. --- packages/contracts/src/icon.ts | 8 +++++++- packages/contracts/src/settings.test.ts | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/icon.ts b/packages/contracts/src/icon.ts index 310858b068e6..f5b2f7f3109f 100644 --- a/packages/contracts/src/icon.ts +++ b/packages/contracts/src/icon.ts @@ -73,9 +73,15 @@ export const ICON_IMAGE_DATA_URL_MAX_LENGTH = 32_768; * * The bytes are never validated, so the prefix says nothing about frame count. * APNG declares `image/png` and animates. + * + * The pattern spells out whole base64 quartets rather than a run of characters + * and loose padding, so a truncated value is refused here instead of reaching a + * decoder and drawing as a broken image on every surface that shows it. */ export const IconImageDataUrl = Schema.String.check( Schema.isMaxLength(ICON_IMAGE_DATA_URL_MAX_LENGTH), - Schema.isPattern(/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/), + Schema.isPattern( + /^data:image\/png;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/, + ), ); export type IconImageDataUrl = typeof IconImageDataUrl.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7527ead06304..6e391a93d189 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1032,6 +1032,27 @@ describe("ServerSettings environment icon", () => { ).toEqual({ kind: "monogram", text: "e\u0301K" }); }); + it("refuses an inline image whose base64 cannot decode", () => { + // A truncated upload is a run of base64 characters that stops mid-quartet. + // Nothing downstream decodes it, so this boundary is where it has to die, + // or every surface showing that environment draws a broken image instead. + for (const dataUrl of [ + "data:image/png;base64,iVBORw", + "data:image/png;base64,iVBORw0KGgo", + "data:image/png;base64,", + ]) { + expect(() => + decodeServerSettingsPatch({ environmentIcon: { kind: "image", dataUrl } }), + ).toThrow(); + } + + const whole = "data:image/png;base64,iVBORw0KGgo="; + expect( + decodeServerSettingsPatch({ environmentIcon: { kind: "image", dataUrl: whole } }) + .environmentIcon, + ).toEqual({ kind: "image", dataUrl: whole }); + }); + it("rejects a patch carrying a variant this build does not know", () => { expect(() => decodeServerSettingsPatch({ environmentIcon: { kind: "hologram", frames: 3 } }), From 3815e24b93a0e7b6473522d787a0be32e7b44f68 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 21 Sep 2026 00:30:46 -0400 Subject: [PATCH 06/11] fix(contracts): inline icons must carry the PNG signature The declared type in an inline data URL is the writer's claim, and nothing downstream checks it. A value spelling "Hello" passed the schema and reached every connected client, which drew a broken image for that environment. Check the encoded signature instead of decoding. Base64 fixes the PNG magic to `iVBORw0KGg` for any PNG whatever its ninth byte, so the guarantee costs one anchored match on a path that runs for every client on every settings change. --- packages/contracts/src/icon.ts | 16 +++++++++++----- packages/contracts/src/settings.test.ts | 18 +++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/contracts/src/icon.ts b/packages/contracts/src/icon.ts index f5b2f7f3109f..464914b1a783 100644 --- a/packages/contracts/src/icon.ts +++ b/packages/contracts/src/icon.ts @@ -71,17 +71,23 @@ export const ICON_IMAGE_DATA_URL_MAX_LENGTH = 32_768; * image library sniffs content instead, so there the guarantee is that neither * renderer in use has a script engine, not the prefix. * - * The bytes are never validated, so the prefix says nothing about frame count. - * APNG declares `image/png` and animates. + * Nothing past the signature is read, so this says nothing about frame count. + * APNG carries the same signature and animates. * - * The pattern spells out whole base64 quartets rather than a run of characters - * and loose padding, so a truncated value is refused here instead of reaching a - * decoder and drawing as a broken image on every surface that shows it. + * The first pattern spells out whole base64 quartets rather than a run of + * characters and loose padding, so a truncated value is refused here instead of + * reaching a decoder and drawing as a broken image on every surface. + * + * The second is the PNG signature, which base64 fixes to `iVBORw0KGg` for any + * PNG whatever its ninth byte. Checking the encoded prefix costs nothing on a + * path that decodes settings for every connected client on every change, and it + * means the declared type is the writer's claim while this is the evidence. */ export const IconImageDataUrl = Schema.String.check( Schema.isMaxLength(ICON_IMAGE_DATA_URL_MAX_LENGTH), Schema.isPattern( /^data:image\/png;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/, ), + Schema.isPattern(/^data:image\/png;base64,iVBORw0KGg/), ); export type IconImageDataUrl = typeof IconImageDataUrl.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 6e391a93d189..275b912755df 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -976,7 +976,7 @@ describe("ServerSettings environment icon", () => { { kind: "icon", name: "cpu", color: "violet" }, { kind: "emoji", emoji: "๐Ÿš€" }, { kind: "monogram", text: "K8", color: "teal" }, - { kind: "image", dataUrl: "data:image/png;base64,iVBORw==" }, + { kind: "image", dataUrl: "data:image/png;base64,iVBORw0KGgo=" }, ] as const) { const settings = decodeServerSettings({ environmentIcon }); expect(settings.environmentIcon).toEqual(environmentIcon); @@ -1032,21 +1032,25 @@ describe("ServerSettings environment icon", () => { ).toEqual({ kind: "monogram", text: "e\u0301K" }); }); - it("refuses an inline image whose base64 cannot decode", () => { - // A truncated upload is a run of base64 characters that stops mid-quartet. - // Nothing downstream decodes it, so this boundary is where it has to die, - // or every surface showing that environment draws a broken image instead. + it("refuses an inline image that is not whole base64 holding a PNG", () => { + // A truncated upload is a run of base64 characters that stops mid-quartet, + // and a mislabelled one decodes cleanly to something that is not a PNG. + // Neither reaches a decoder that would tell the user, so they die here. for (const dataUrl of [ - "data:image/png;base64,iVBORw", "data:image/png;base64,iVBORw0KGgo", + "data:image/png;base64,iVBORw0KGgoAA", "data:image/png;base64,", + // Whole base64, right declared type, and the bytes spell "Hello". + "data:image/png;base64,SGVsbG8=", + // Stops four bytes into the eight byte signature. + "data:image/png;base64,iVBORw==", ]) { expect(() => decodeServerSettingsPatch({ environmentIcon: { kind: "image", dataUrl } }), ).toThrow(); } - const whole = "data:image/png;base64,iVBORw0KGgo="; + const whole = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; expect( decodeServerSettingsPatch({ environmentIcon: { kind: "image", dataUrl: whole } }) .environmentIcon, From 4d7eb76e06db28448bfa1530573d40aca6035f35 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 21 Sep 2026 01:08:48 -0400 Subject: [PATCH 07/11] fix(contracts): monogram counting drops joiners on a runtime without Segmenter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MonogramText` admits ZWJ and ZWNJ in the tail, but the code-point fallback in `isMonogramLength` only stripped combining marks. So "Aโ€B" counted three on a runtime lacking `Intl.Segmenter` and the write boundary refused a monogram every other client accepts. Strip the joiners too. The fallback still over-counts a Devanagari conjunct and a decomposed Hangul syllable; exact clustering needs UAX #29. That direction only ever refuses, so it cannot store text too wide for the tile, and the doc comment now says so instead of implying the two branches agree. --- packages/contracts/src/icon.ts | 18 +++++++++++---- packages/contracts/src/settings.test.ts | 29 ++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/contracts/src/icon.ts b/packages/contracts/src/icon.ts index 464914b1a783..678fbbb8a54e 100644 --- a/packages/contracts/src/icon.ts +++ b/packages/contracts/src/icon.ts @@ -47,13 +47,22 @@ export const MonogramText = TrimmedNonEmptyString.check( ); export type MonogramText = typeof MonogramText.Type; -/** Whether `text` reads as at most two characters, the bound a monogram tile can hold. */ +/** + * Whether `text` reads as at most two characters, the bound a monogram tile + * can hold. `Intl.Segmenter` is exact. Without it the count is code points + * after stripping the combining marks and joiners `MonogramText` admits, + * which agrees for Latin, digits, and accents either precomposed or + * decomposed. It over-counts a Devanagari conjunct or a decomposed Hangul + * syllable, so a runtime lacking `Intl.Segmenter` refuses a monogram the + * server would take. That direction is the safe one: it never stores text + * too wide for the tile. + */ export function isMonogramLength(text: string): boolean { const Segmenter = (Intl as { Segmenter?: typeof Intl.Segmenter }).Segmenter; const count = typeof Segmenter === "function" ? Array.from(new Segmenter(undefined, { granularity: "grapheme" }).segment(text)).length - : Array.from(text.replace(/\p{M}/gu, "")).length; + : Array.from(text.replace(/[\p{M}\u200c\u200d]/gu, "")).length; return count <= 2; } @@ -75,8 +84,9 @@ export const ICON_IMAGE_DATA_URL_MAX_LENGTH = 32_768; * APNG carries the same signature and animates. * * The first pattern spells out whole base64 quartets rather than a run of - * characters and loose padding, so a truncated value is refused here instead of - * reaching a decoder and drawing as a broken image on every surface. + * characters and loose padding. That refuses the three in four truncations + * that stop mid-quartet. One that stops on a quartet boundary still decodes, + * to a PNG carrying a signature and no pixels, and reaches the renderer. * * The second is the PNG signature, which base64 fixes to `iVBORw0KGg` for any * PNG whatever its ninth byte. Checking the encoded prefix costs nothing on a diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 275b912755df..127e51a63f9c 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1032,10 +1032,33 @@ describe("ServerSettings environment icon", () => { ).toEqual({ kind: "monogram", text: "e\u0301K" }); }); + it("counts a monogram the same way on a runtime without Intl.Segmenter", () => { + // Mobile runs Hermes, which may not ship Intl.Segmenter. The fallback + // counts code points, so it has to drop the combining marks and joiners + // MonogramText admits or it would refuse two-character monograms that + // every other client accepts. + const segmenter = Object.getOwnPropertyDescriptor(Intl, "Segmenter"); + // @ts-expect-error removing an Intl member to exercise the fallback path + delete Intl.Segmenter; + try { + for (const text of ["e\u0301K", "A\u200dB", "A\u200cB", "AB"]) { + expect( + decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text } }) + .environmentIcon, + ).toEqual({ kind: "monogram", text }); + } + expect(() => + decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text: "ABC" } }), + ).toThrow(); + } finally { + if (segmenter) Object.defineProperty(Intl, "Segmenter", segmenter); + } + }); + it("refuses an inline image that is not whole base64 holding a PNG", () => { - // A truncated upload is a run of base64 characters that stops mid-quartet, - // and a mislabelled one decodes cleanly to something that is not a PNG. - // Neither reaches a decoder that would tell the user, so they die here. + // A truncated upload usually stops mid-quartet, and a mislabelled one + // decodes cleanly to something that is not a PNG. Neither reaches a + // decoder that would tell the user, so they die here. for (const dataUrl of [ "data:image/png;base64,iVBORw0KGgo", "data:image/png;base64,iVBORw0KGgoAA", From e2cb09c6b730fc2469b2596c2dd05b675ff4a92a Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 21 Sep 2026 01:08:56 -0400 Subject: [PATCH 08/11] fix(server): strip environment icons as a whole value `stripDefaultServerSettings` recurses field by field for any key outside `ATOMIC_SETTINGS_KEYS`, so an icon object could persist as a fragment that `deepMerge` then reassembles into a hybrid carrying keys from two variants. Today the `null` default keeps the recursion from ever reaching inside the object, which makes the existing "swaps variants without leftovers" test pass for a reason that would disappear the moment the default stops being `null`. Name the key so the guarantee comes from the set rather than from the default. --- apps/server/src/serverSettings.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ed52282c0237..c80bcfd79f19 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -358,6 +358,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "sourceControlWriterModelSelection", "textGenerationModelSelection", "pullRequestMergeMethod", + "environmentIcon", ]); // Preserve both enabled states because provider history cannot recover a new opt-in. From 26a72c9b084b652e4457c9ed17c6137d1b32dd9c Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 21 Sep 2026 01:09:03 -0400 Subject: [PATCH 09/11] docs(contracts): correct three claims about the icon wire format All three read as guarantees and none of them hold. The frozen legacy list said a server without `environmentIconOverride` accepts those seven kinds "and nothing else". `linux` is the exception. The `environmentIcon` capability shipped 2026-09-02 and `linux` joined the set 2026-09-06, so 25 nightly builds in between advertise the capability and reject the string. No stable release sits in that window, and dropping `linux` from the list would lock the Linux glyph on every stable server shipping today, so the list stays and the comment names the gap. The write-boundary comment stated as fact that Hermes ships no `Intl.Segmenter`. Nothing in this repo establishes that. The reason the check lives at the write boundary does not depend on it, only on the count differing by runtime. The base64 quartet pattern was described as refusing a truncated value. It refuses the three in four truncations that stop mid-quartet. One that stops on a quartet boundary is still whole base64 with the right signature, and reaches the renderer as a PNG with no pixels. --- packages/contracts/src/environment.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 951f200281c1..10a30509efe0 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -34,11 +34,18 @@ export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; /** - * The kinds every server has ever stored as a bare string. Only these have - * that wire form: a server without `environmentIconOverride` accepts them and - * nothing else, and an older client decodes them from a snapshot. The list is - * frozen. A kind detected later travels as the object, because an older peer - * decodes a string it does not know as null and loses the icon. + * The kinds a released server accepts as a bare string. Only these have that + * wire form: a server without `environmentIconOverride` takes them and + * nothing else, and an older client decodes them from a snapshot. A kind + * added later travels as the object, because an older peer decodes a string + * it does not know as null and loses the icon. The list is frozen. + * + * `linux` has one gap. The `environmentIcon` capability shipped on + * 2026-09-02 and `linux` joined the set on 2026-09-06, so 25 nightly builds + * in between advertise the capability and reject the string, and picking the + * Linux glyph against one of those fails the whole settings patch. No stable + * release sits in that window. Dropping `linux` here would instead lock the + * glyph on every stable server shipping today, which is the larger loss. */ export const LEGACY_ENVIRONMENT_MACHINE_KINDS = [ "server", @@ -139,9 +146,10 @@ export type EnvironmentIconOverride = typeof EnvironmentIconOverride.Type; * their decider; a settings patch has no such boundary, so it lives here. * * It stays off `EnvironmentIconOverride` because that schema also decodes - * snapshots, and grapheme counting differs by runtime: Hermes ships no - * `Intl.Segmenter`, so a monogram the server accepted can count longer on - * mobile. Checking it during decode would fail there, and + * snapshots, and grapheme counting differs by runtime. Nothing here proves + * this Hermes build ships `Intl.Segmenter`, and `isMonogramLength` counts + * code points without it, so a monogram the server accepted can count longer + * on mobile. Checking it during decode would fail there, and * `ForwardCompatibleNullable` would drop the stored icon to null on that * client alone. */ From bce1653da4176651237a5ff1c61a317581522dc2 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Tue, 22 Sep 2026 00:20:37 -0400 Subject: [PATCH 10/11] refactor(contracts): drop the Intl cast from the monogram counter `isMonogramLength` reached `Intl.Segmenter` through a type assertion that restated the lib declaration. `typeof Intl.Segmenter === "function"` reads the real member and keeps the same guard, so a runtime that defines the property as undefined still takes the code-point fallback. Also rewrites five comments that used a colon as a mid-sentence connector. --- apps/server/src/serverSettings.test.ts | 2 +- packages/contracts/src/environment.ts | 9 +++++---- packages/contracts/src/icon.ts | 11 +++++------ packages/contracts/src/settings.test.ts | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5c17cf69a7c7..2a965d0caee8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -313,7 +313,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - // Inspect the raw file: a partial object or a stale key from the + // Inspect the raw file. A partial object or a stale key from the // previous variant would only be visible before schema decoding. const readPersistedIcon = fileSystem.readFileString(serverConfig.settingsPath).pipe( Effect.flatMap(decodeRawSettingsJson), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 10a30509efe0..fe9544ea6689 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -35,7 +35,7 @@ export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatfo /** * The kinds a released server accepts as a bare string. Only these have that - * wire form: a server without `environmentIconOverride` takes them and + * wire form. A server without `environmentIconOverride` takes them and * nothing else, and an older client decodes them from a snapshot. A kind * added later travels as the object, because an older peer decodes a string * it does not know as null and loses the icon. The list is frozen. @@ -116,9 +116,10 @@ export type EnvironmentIcon = typeof EnvironmentIcon.Type; /** * What a user picked for an environment's icon. Servers that predate the * override stored a bare machine kind, and that string form stays on the - * wire and on disk for a plain pick of one of the seven kinds: it is what an - * older server accepts in a patch and what an older client can decode from a - * snapshot, so the picks that always existed keep working across versions. + * wire and on disk for a plain pick of one of the seven kinds. That string is + * what an older server accepts in a patch and what an older client can decode + * from a snapshot, so the picks that always existed keep working across + * versions. * Anything richer (a color, a name outside the seven, another variant) * encodes as the object, which older peers drop to null through * `ForwardCompatibleNullable`; the `environmentIconOverride` capability keeps diff --git a/packages/contracts/src/icon.ts b/packages/contracts/src/icon.ts index 678fbbb8a54e..aae6f74935bc 100644 --- a/packages/contracts/src/icon.ts +++ b/packages/contracts/src/icon.ts @@ -54,14 +54,13 @@ export type MonogramText = typeof MonogramText.Type; * which agrees for Latin, digits, and accents either precomposed or * decomposed. It over-counts a Devanagari conjunct or a decomposed Hangul * syllable, so a runtime lacking `Intl.Segmenter` refuses a monogram the - * server would take. That direction is the safe one: it never stores text - * too wide for the tile. + * server would take. That direction is the safe one, because it never + * stores text too wide for the tile. */ export function isMonogramLength(text: string): boolean { - const Segmenter = (Intl as { Segmenter?: typeof Intl.Segmenter }).Segmenter; const count = - typeof Segmenter === "function" - ? Array.from(new Segmenter(undefined, { granularity: "grapheme" }).segment(text)).length + typeof Intl.Segmenter === "function" + ? Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)).length : Array.from(text.replace(/[\p{M}\u200c\u200d]/gu, "")).length; return count <= 2; } @@ -76,7 +75,7 @@ export const ICON_IMAGE_DATA_URL_MAX_LENGTH = 32_768; /** * A small raster icon carried inline. The prefix is pinned to PNG rather than * any `data:image/`. On web that is what keeps an SVG, which can script, out - * of the ``: Blink picks the decoder from the declared type. Mobile's + * of the ``, because Blink picks the decoder from the declared type. Mobile's * image library sniffs content instead, so there the guarantee is that neither * renderer in use has a script engine, not the prefix. * diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 127e51a63f9c..91d055867bac 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1104,9 +1104,9 @@ describe("ServerSettings environment icon", () => { }); it("writes a plain pick of a legacy kind as the string an older server accepts", () => { - // Written out rather than read from the contract: these are the kinds an - // older server accepts, so the list is frozen and must not follow a later - // build that detects something new. Asserting it exactly is what fails if + // The list is written out rather than read from the contract. These are + // the kinds an older server accepts, so it is frozen and must not follow a + // later build that detects something new. Asserting it exactly is what fails if // someone grows it alongside the detected kinds. const legacyKinds = [ "server", From 63b7aa1866a349eb891734ea4a28fb11b588f8c7 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Tue, 22 Sep 2026 05:10:10 -0400 Subject: [PATCH 11/11] refactor(contracts): one monogram count for every client `isMonogramLength` used `Intl.Segmenter` where it existed and counted code points otherwise. Hermes ships no segmenter, so the same monogram was accepted on the server and on web and refused on mobile. That divergence is also why `t3code/no-hermes-unsupported-apis` reports the constructor inside `packages/contracts`, at error severity. Counting code points everywhere costs a two-cluster Devanagari or decomposed Hangul monogram, which counts high and gets one cluster. In exchange every client agrees on what it will store. The write schema keeps the bound off decode for the reason that outlives this. A decode-time check would send a longer stored monogram through `ForwardCompatibleNullable` to null, and the user would get the detected glyph with nothing saying why. --- packages/contracts/src/environment.ts | 11 ++++--- packages/contracts/src/icon.ts | 23 +++++++------- packages/contracts/src/settings.test.ts | 40 +++++++------------------ 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index fe9544ea6689..327c89423fe9 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -147,12 +147,11 @@ export type EnvironmentIconOverride = typeof EnvironmentIconOverride.Type; * their decider; a settings patch has no such boundary, so it lives here. * * It stays off `EnvironmentIconOverride` because that schema also decodes - * snapshots, and grapheme counting differs by runtime. Nothing here proves - * this Hermes build ships `Intl.Segmenter`, and `isMonogramLength` counts - * code points without it, so a monogram the server accepted can count longer - * on mobile. Checking it during decode would fail there, and - * `ForwardCompatibleNullable` would drop the stored icon to null on that - * client alone. + * snapshots. A peer writing outside the picker, or a later build that widens + * the bound, can store a longer monogram, and checking it during decode would + * send that icon through `ForwardCompatibleNullable` to null. The user would + * get the detected glyph with nothing saying why. Only the write boundary + * counts, so a snapshot draws what is stored. */ export const EnvironmentIconOverrideWrite = EnvironmentIconOverride.check( Schema.makeFilter((icon) => icon.kind !== "monogram" || isMonogramLength(icon.text)), diff --git a/packages/contracts/src/icon.ts b/packages/contracts/src/icon.ts index aae6f74935bc..fdbcd7b061f8 100644 --- a/packages/contracts/src/icon.ts +++ b/packages/contracts/src/icon.ts @@ -49,20 +49,19 @@ export type MonogramText = typeof MonogramText.Type; /** * Whether `text` reads as at most two characters, the bound a monogram tile - * can hold. `Intl.Segmenter` is exact. Without it the count is code points - * after stripping the combining marks and joiners `MonogramText` admits, - * which agrees for Latin, digits, and accents either precomposed or - * decomposed. It over-counts a Devanagari conjunct or a decomposed Hangul - * syllable, so a runtime lacking `Intl.Segmenter` refuses a monogram the - * server would take. That direction is the safe one, because it never - * stores text too wide for the tile. + * can hold. The count is code points after stripping the combining marks and + * joiners `MonogramText` admits, which matches grapheme counting for Latin, + * digits, and accents either precomposed or decomposed. A Devanagari conjunct + * or a decomposed Hangul syllable counts high, so those scripts get one + * cluster rather than two. + * + * `Intl.Segmenter` would be exact, and Hermes does not ship it. Reaching for + * it where it exists would accept on the server and on web what mobile + * refuses, and one bound every client computes the same way is worth more + * than the extra scripts. */ export function isMonogramLength(text: string): boolean { - const count = - typeof Intl.Segmenter === "function" - ? Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)).length - : Array.from(text.replace(/[\p{M}\u200c\u200d]/gu, "")).length; - return count <= 2; + return Array.from(text.replace(/[\p{M}\u200c\u200d]/gu, "")).length <= 2; } /** diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 91d055867bac..8b29bbfe8d69 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1023,36 +1023,16 @@ describe("ServerSettings environment icon", () => { }); it("holds a monogram to two characters at the write boundary", () => { + // The count drops the combining marks and joiners MonogramText admits, so + // each of these is two characters rather than three or four. + for (const text of ["e\u0301K", "A\u200dB", "A\u200cB", "AB"]) { + expect( + decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text } }).environmentIcon, + ).toEqual({ kind: "monogram", text }); + } expect(() => decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text: "ABC" } }), ).toThrow(); - expect( - decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text: "e\u0301K" } }) - .environmentIcon, - ).toEqual({ kind: "monogram", text: "e\u0301K" }); - }); - - it("counts a monogram the same way on a runtime without Intl.Segmenter", () => { - // Mobile runs Hermes, which may not ship Intl.Segmenter. The fallback - // counts code points, so it has to drop the combining marks and joiners - // MonogramText admits or it would refuse two-character monograms that - // every other client accepts. - const segmenter = Object.getOwnPropertyDescriptor(Intl, "Segmenter"); - // @ts-expect-error removing an Intl member to exercise the fallback path - delete Intl.Segmenter; - try { - for (const text of ["e\u0301K", "A\u200dB", "A\u200cB", "AB"]) { - expect( - decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text } }) - .environmentIcon, - ).toEqual({ kind: "monogram", text }); - } - expect(() => - decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text: "ABC" } }), - ).toThrow(); - } finally { - if (segmenter) Object.defineProperty(Intl, "Segmenter", segmenter); - } }); it("refuses an inline image that is not whole base64 holding a PNG", () => { @@ -1092,9 +1072,9 @@ describe("ServerSettings environment icon", () => { }); it("accepts a stored monogram the write boundary would reject", () => { - // Grapheme counting differs by runtime, so a monogram one client wrote can - // count longer on another. Only the write boundary counts; a snapshot - // decodes as stored, or that client would draw the detected glyph instead. + // A peer writing outside the picker can store a longer monogram. Only the + // write boundary counts; a snapshot decodes as stored, or that client + // would draw the detected glyph instead. expect( decodeServerSettings({ environmentIcon: { kind: "monogram", text: "ABC" } }).environmentIcon, ).toEqual({ kind: "monogram", text: "ABC" });