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..2a965d0caee8 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/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. 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..327c89423fe9 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; @@ -25,11 +34,20 @@ 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 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 ENVIRONMENT_MACHINE_KINDS = [ +export const LEGACY_ENVIRONMENT_MACHINE_KINDS = [ "server", "cloud", "linux", @@ -38,10 +56,107 @@ 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); +/** + * 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, +}); +const EnvironmentMonogramIcon = Schema.Struct({ + kind: Schema.Literal("monogram"), + text: MonogramText, + 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. 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 + * 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 && + isLegacyEnvironmentMachineKind(icon.name) + ? icon.name + : icon, + }), + ), +); +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. 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)), +); + export const ExecutionEnvironmentPlatform = Schema.Struct({ os: ExecutionEnvironmentPlatformOs, arch: ExecutionEnvironmentPlatformArch, @@ -180,6 +295,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..fdbcd7b061f8 --- /dev/null +++ b/packages/contracts/src/icon.ts @@ -0,0 +1,101 @@ +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. 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 { + return Array.from(text.replace(/[\p{M}\u200c\u200d]/gu, "")).length <= 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 PNG rather than + * any `data:image/`. On web that is what keeps an SVG, which can script, out + * 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. + * + * Nothing past the signature is read, so this says nothing about frame count. + * APNG carries the same signature and animates. + * + * The first pattern spells out whole base64 quartets rather than a run of + * 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 + * 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/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..8b29bbfe8d69 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 { LEGACY_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", () => { @@ -962,21 +964,162 @@ 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,iVBORw0KGgo=" }, + ] as const) { + const settings = decodeServerSettings({ environmentIcon }); + expect(settings.environmentIcon).toEqual(environmentIcon); + expect(encodeServerSettings(settings).environmentIcon).toEqual(environmentIcon); + } + }); + + 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 a kind from a newer server as null instead of failing the snapshot", () => { + 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"); + + // 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("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", () => { + // 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(); + }); - const linuxSettings = decodeServerSettings({ environmentIcon: "linux" }); - expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux"); + it("refuses an inline image that is not whole base64 holding a PNG", () => { + // 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", + "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,iVBORw0KGgoAAAANSUhEUg=="; + 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 } }), + ).toThrow(); + expect(decodeServerSettingsPatch({ environmentIcon: null }).environmentIcon).toBeNull(); + expect(decodeServerSettingsPatch({ environmentIcon: "linux" }).environmentIcon).toEqual({ + kind: "icon", + name: "linux", + }); + }); + + it("accepts a stored monogram the write boundary would reject", () => { + // 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" }); + expect(() => + decodeServerSettingsPatch({ environmentIcon: { kind: "monogram", text: "ABC" } }), + ).toThrow(); + }); + + it("writes a plain pick of a legacy kind as the string an older server accepts", () => { + // 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", + "cloud", + "linux", + "desktop", + "laptop", + "mac-mini", + "mac-studio", + ] as const; + expect(LEGACY_ENVIRONMENT_MACHINE_KINDS).toEqual(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 }); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 94bc27734923..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 { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; +import { + EnvironmentIconOverride, + EnvironmentIconOverrideWrite, + ThreadEnvMode, + WorktreeSubmodules, +} from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { CustomModelSetting, @@ -1194,10 +1199,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 +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(EnvironmentMachineKind)), + environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentIconOverrideWrite)), 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 } : {}), };