Repository navigation
feat: add portable settings backups #3811
Changes from 1 commit
5923cba
9175748
0aabdff
c26ef29
d40a222
5a0fed0
f89b471
d979521
a5cfd1c
d8c15a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { createSettingsBackup, parseSettingsBackup } from "./settingsBackup"; | ||
|
|
||
| describe("settings backup", () => { | ||
| it("round trips categories and custom sounds", () => { | ||
| const bytes = createSettingsBackup( | ||
| { | ||
| notifications: { | ||
| desktopNotifications: true, | ||
| dockBadgeNotifications: true, | ||
| dockBounceNotifications: false, | ||
| toastNotifications: true, | ||
| completionSound: "none", | ||
| completionVolume: 42, | ||
| scaleSoundWithTaskLength: false, | ||
| }, | ||
| }, | ||
| [ | ||
| { | ||
| id: "ding", | ||
| name: "Ding", | ||
| durationMs: 500, | ||
| dataUrl: "data:audio/webm;base64,AAEC", | ||
| }, | ||
| ], | ||
| "2026-07-25T00:00:00.000Z", | ||
| ); | ||
| const backup = parseSettingsBackup(bytes); | ||
| expect(backup.manifest.categories.notifications?.completionVolume).toBe(42); | ||
| expect(backup.manifest.sounds[0]).toMatchObject({ | ||
| id: "ding", | ||
| name: "Ding", | ||
| }); | ||
| expect(backup.soundDataUrls.ding).toBe("data:audio/webm;base64,AAEC"); | ||
| }); | ||
|
|
||
| it("rejects data that is not an archive", () => { | ||
| expect(() => parseSettingsBackup(new Uint8Array([1, 2, 3]))).toThrow(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| import { unzipSync, zipSync } from "fflate"; | ||
| import { z } from "zod"; | ||
|
|
||
| const MAX_ARCHIVE_BYTES = 25_000_000; | ||
| const MAX_ENTRY_BYTES = 5_000_000; | ||
| const MANIFEST_PATH = "manifest.json"; | ||
|
|
||
| export const settingsBackupCategorySchema = z.enum([ | ||
| "notifications", | ||
| "agentDefaults", | ||
| "composer", | ||
| "terminal", | ||
| "system", | ||
| "appearance", | ||
| "onboarding", | ||
| ]); | ||
|
|
||
| export type SettingsBackupCategory = z.infer< | ||
| typeof settingsBackupCategorySchema | ||
| >; | ||
|
|
||
| const soundSchema = z.object({ | ||
| id: z.string().min(1).max(200), | ||
| name: z.string().min(1).max(60), | ||
| durationMs: z.number().nonnegative().max(5_300), | ||
| file: z.string().regex(/^sounds\/[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/), | ||
| mimeType: z.string().regex(/^audio\/[A-Za-z0-9.+-]+$/), | ||
| }); | ||
|
|
||
| const categoriesSchema = z | ||
| .object({ | ||
| notifications: z.object({ | ||
| desktopNotifications: z.boolean(), | ||
| dockBadgeNotifications: z.boolean(), | ||
| dockBounceNotifications: z.boolean(), | ||
| toastNotifications: z.boolean(), | ||
| completionSound: z.union([ | ||
| z.enum([ | ||
| "none", | ||
| "guitar", | ||
| "danilo", | ||
| "revi", | ||
| "meep", | ||
| "meep-smol", | ||
| "bubbles", | ||
| "drop", | ||
| "knock", | ||
| "ring", | ||
| "shoot", | ||
| "slide", | ||
| "switch", | ||
| "wilhelm", | ||
| "icq", | ||
| "random-all", | ||
| "random-custom", | ||
| ]), | ||
| z.string().regex(/^custom:[A-Za-z0-9_-]+$/), | ||
| ]), | ||
| completionVolume: z.number().min(0).max(100), | ||
| scaleSoundWithTaskLength: z.boolean(), | ||
| }), | ||
| agentDefaults: z.object({ | ||
| defaultRunMode: z.enum(["local", "cloud", "last_used"]), | ||
| defaultInitialTaskMode: z.enum(["plan", "last_used"]), | ||
| defaultReasoningEffort: z.enum([ | ||
| "low", | ||
| "medium", | ||
| "high", | ||
| "xhigh", | ||
| "max", | ||
| "last_used", | ||
| ]), | ||
| defaultMessagingMode: z.enum(["queue", "steer"]), | ||
| }), | ||
| composer: z.object({ | ||
| autoConvertLongText: z.enum(["off", "1000", "2500", "5000", "10000"]), | ||
| sendMessagesWith: z.enum(["enter", "cmd+enter"]), | ||
| customInstructions: z.string(), | ||
| diffOpenMode: z.enum(["auto", "split", "same-pane", "last-active-pane"]), | ||
| conversationCollapseMode: z.string(), | ||
| }), | ||
| terminal: z.object({ | ||
| terminalFont: z.enum([ | ||
| "berkeley-mono", | ||
| "jetbrains-mono", | ||
| "system", | ||
| "custom", | ||
| ]), | ||
| terminalCustomFontFamily: z.string(), | ||
| terminalGpuRendering: z.boolean(), | ||
| }), | ||
| system: z.object({ | ||
| allowBypassPermissions: z.boolean(), | ||
| preventSleepWhileRunning: z.boolean(), | ||
| debugLogsCloudRuns: z.boolean(), | ||
| autoPublishCloudRuns: z.boolean(), | ||
| downloadUpdatesAutomatically: z.boolean(), | ||
| }), | ||
| appearance: z.object({ | ||
| hedgehogMode: z.boolean(), | ||
| slotMachineMode: z.boolean(), | ||
| brainrotMode: z.boolean(), | ||
| mcpAppsDisabledServers: z.array(z.string()), | ||
| dismissibleUpdateBanners: z.boolean(), | ||
| useNewChatThread: z.boolean(), | ||
| theme: z.enum(["light", "dark", "system"]), | ||
| }), | ||
| onboarding: z.object({ | ||
| lastSeenChangelogVersion: z.string().nullable(), | ||
| hints: z.record( | ||
| z.string(), | ||
| z.object({ | ||
| count: z.number().int().nonnegative(), | ||
| learned: z.boolean(), | ||
| }), | ||
| ), | ||
| }), | ||
| }) | ||
| .partial(); | ||
|
|
||
| export const settingsBackupManifestSchema = z.object({ | ||
| format: z.literal("posthog-code-settings"), | ||
| version: z.literal(1), | ||
| exportedAt: z.string(), | ||
| categories: categoriesSchema, | ||
| sounds: z.array(soundSchema).max(100), | ||
| }); | ||
|
|
||
| export type SettingsBackupManifest = z.infer< | ||
| typeof settingsBackupManifestSchema | ||
| >; | ||
|
|
||
| export interface ExportSound { | ||
| id: string; | ||
| name: string; | ||
| durationMs: number; | ||
| dataUrl: string; | ||
| } | ||
|
|
||
| export interface ParsedSettingsBackup { | ||
| manifest: SettingsBackupManifest; | ||
| soundDataUrls: Record<string, string>; | ||
| } | ||
|
|
||
| function extensionForMimeType(mimeType: string): string { | ||
| const subtype = mimeType.split("/")[1]?.split(";")[0] ?? "bin"; | ||
| return subtype === "mpeg" | ||
| ? "mp3" | ||
| : subtype.replace(/[^A-Za-z0-9]/g, "") || "bin"; | ||
| } | ||
|
|
||
| function parseDataUrl(dataUrl: string): { | ||
| mimeType: string; | ||
| bytes: Uint8Array; | ||
| } { | ||
| const match = | ||
| /^data:(audio\/[A-Za-z0-9.+-]+)(?:;[^,]*)?;base64,([A-Za-z0-9+/=]+)$/.exec( | ||
| dataUrl, | ||
| ); | ||
| if (!match) throw new Error("A custom sound has invalid audio data"); | ||
| const binary = atob(match[2]); | ||
| const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); | ||
| if (bytes.byteLength > MAX_ENTRY_BYTES) | ||
| throw new Error("A custom sound is too large"); | ||
| return { mimeType: match[1], bytes }; | ||
| } | ||
|
|
||
| function toDataUrl(mimeType: string, bytes: Uint8Array): string { | ||
| let binary = ""; | ||
| for (let offset = 0; offset < bytes.length; offset += 0x8000) { | ||
| binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); | ||
| } | ||
| return `data:${mimeType};base64,${btoa(binary)}`; | ||
| } | ||
|
|
||
| export function createSettingsBackup( | ||
| categories: SettingsBackupManifest["categories"], | ||
| sounds: ExportSound[], | ||
| exportedAt = new Date().toISOString(), | ||
| ): Uint8Array { | ||
| const files: Record<string, Uint8Array> = {}; | ||
| const soundEntries = sounds.map((sound) => { | ||
| const { mimeType, bytes } = parseDataUrl(sound.dataUrl); | ||
| const file = `sounds/${sound.id}.${extensionForMimeType(mimeType)}`; | ||
| files[file] = bytes; | ||
| return { | ||
| id: sound.id, | ||
| name: sound.name, | ||
| durationMs: sound.durationMs, | ||
| file, | ||
| mimeType, | ||
| }; | ||
| }); | ||
| const manifest = settingsBackupManifestSchema.parse({ | ||
| format: "posthog-code-settings", | ||
| version: 1, | ||
| exportedAt, | ||
| categories, | ||
| sounds: soundEntries, | ||
| }); | ||
| files[MANIFEST_PATH] = Uint8Array.from( | ||
| new TextEncoder().encode(JSON.stringify(manifest, null, 2)), | ||
| ); | ||
| return zipSync(files, { level: 6 }); | ||
| } | ||
|
Comment on lines
+207
to
+236
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Archive export/import runs fully synchronously via zipSync/unzipSync, contradicting this codebase's own established fix for the identical problemWhy we think it's a valid issue
Issue description
Suggested fixUse fflate's async, worker-backed Prompt to fix with AI (copy-paste) |
||
|
|
||
| export function parseSettingsBackup(bytes: Uint8Array): ParsedSettingsBackup { | ||
| if (bytes.byteLength > MAX_ARCHIVE_BYTES) | ||
| throw new Error("Settings archive is too large"); | ||
| const files = unzipSync(bytes); | ||
|
veria-ai[bot] marked this conversation as resolved.
Resolved
pauldambra marked this conversation as resolved.
Resolved
pauldambra marked this conversation as resolved.
Resolved
|
||
| let expandedBytes = 0; | ||
| for (const [name, contents] of Object.entries(files)) { | ||
| if (name.includes("..") || name.startsWith("/") || name.includes("\\")) { | ||
| throw new Error("Settings archive contains an unsafe path"); | ||
| } | ||
| if (contents.byteLength > MAX_ENTRY_BYTES) { | ||
| throw new Error("Settings archive contains an oversized file"); | ||
| } | ||
| expandedBytes += contents.byteLength; | ||
| if (expandedBytes > MAX_ARCHIVE_BYTES) { | ||
| throw new Error("Expanded settings archive is too large"); | ||
| } | ||
| } | ||
| const rawManifest = files[MANIFEST_PATH]; | ||
| if (!rawManifest) throw new Error("Settings archive has no manifest"); | ||
| const manifest = settingsBackupManifestSchema.parse( | ||
| JSON.parse(new TextDecoder().decode(rawManifest)), | ||
| ); | ||
| const soundDataUrls: Record<string, string> = {}; | ||
| for (const sound of manifest.sounds) { | ||
| const soundBytes = files[sound.file]; | ||
| if (!soundBytes) | ||
| throw new Error(`Settings archive is missing ${sound.file}`); | ||
| soundDataUrls[sound.id] = toDataUrl(sound.mimeType, soundBytes); | ||
|
pauldambra marked this conversation as resolved.
Resolved
|
||
| } | ||
| return { manifest, soundDataUrls }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
completionSound schema is missing the "msn" built-in sound value, so exportArchive() throws for any user who selected it
Why we think it's a valid issue
completionSoundenum (settingsBackup.ts:38-59) against the domain typeBuiltInCompletionSound(settingsStore.ts:39-55) and the live UI options (NotificationsSettings.tsx), then traced the full export path.BuiltInCompletionSoundends in"msn"(settingsStore.ts:55) andNotificationsSettings.tsx:286renders<Select.Item value="msn">MSN Messenger</Select.Item>as a live, user-selectable option — but the backup enum omits"msn", and"msn"also fails thecustom:regex branch, so it fails both arms of thez.union.exportArchive()maps over allSETTINGS_BACKUP_CATEGORY_IDS(notifications first) →createSettingsBackup()→settingsBackupManifestSchema.parse(...), which validatescompletionSound.pick()readsstate.completionSoundverbatim with no normalization, so a stored"msn"throws a Zod validation error on every Export click, regardless of import category selection. The UIexportSettingscatch turns it into atoast.error, so export silently fails for that user.settingsBackup.test.tsonly exercisescompletionSound: "none"(line 14), so the added suite would not catch this before release.Issue description
The
completionSoundzod schema enumerates exactly the built-in sound ids: "none", "guitar", "danilo", "revi", "meep", "meep-smol", "bubbles", "drop", "knock", "ring", "shoot", "slide", "switch", "wilhelm", "icq" (plus "random-all"/"random-custom" and thecustom:regex branch). This list is out of sync with the real domain type it is meant to mirror:packages/ui/src/features/settings/settingsStore.tsdefinesBuiltInCompletionSoundwith 16 members, ending in"msn"— andpackages/ui/src/features/settings/sections/NotificationsSettings.tsxrenders<Select.Item value="msn">MSN Messenger</Select.Item>as a live, user-selectable option right next toicq. "msn" is absent from the new schema's enum and does not match thecustom:regex either, so it fails both branches of thez.union. Any user who has "MSN Messenger" set as their completion sound will haveSettingsBackupService.exportArchive()->createSettingsBackup()->settingsBackupManifestSchema.parse(...)throw a Zod validation error the instant they try to export settings — the entire export feature is broken for that (real, currently reachable) subset of users, not just the notifications category.settingsBackup.test.tsnever exercises "msn", so nothing in the added test suite would catch this before it reaches users. This is a schema/domain-type mismatch distinct from the array-ordering, null-guard, zip-bomb, zip-slip, and prototype-pollution issues already raised on this chunk — none of those touch enum completeness forcompletionSound.Suggested fix
Add "msn" to the
completionSoundenum insettingsBackupCategorySchema'scategoriesSchema.notifications.completionSound, e.g. derive the built-in list fromBuiltInCompletionSound(or a shared const) instead of hand-duplicating it, so the two can't drift again. Also add a regression test in settingsBackup.test.ts that round-tripscompletionSound: "msn"through export/import.Prompt to fix with AI (copy-paste)