diff --git a/apps/web/src/domains/chat/chat-layout.tsx b/apps/web/src/domains/chat/chat-layout.tsx index 1af7ed05b3d..ac34055c036 100644 --- a/apps/web/src/domains/chat/chat-layout.tsx +++ b/apps/web/src/domains/chat/chat-layout.tsx @@ -11,6 +11,7 @@ import { Outlet, useLocation, useNavigate } from "react-router"; import { useQueryClient } from "@tanstack/react-query"; import { haptic } from "@/utils/haptics"; +import { getLocalBool, setLocalBool, getLocalNumber, setLocalNumber } from "@/utils/local-settings"; import { routes } from "@/utils/routes"; import { MOBILE_MEDIA_QUERY, useIsMobile } from "@/hooks/use-is-mobile"; import { useRootOutletContext } from "@/root-layout"; @@ -66,29 +67,16 @@ const FOCUSABLE_SELECTOR = [ ].join(","); export function readPersistedCollapsed(): boolean { - try { - return ( - window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true" - ); - } catch { - return false; - } + return getLocalBool(SIDEBAR_COLLAPSED_STORAGE_KEY, false); } const MIN_SIDEBAR_WIDTH = 220; const MAX_SIDEBAR_WIDTH = 400; export function readPersistedWidth(): number { - try { - const stored = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY); - if (stored != null) { - const parsed = Number(stored); - if (Number.isFinite(parsed) && parsed > 0) { - return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, parsed)); - } - } - } catch { - // Storage unavailable + const raw = getLocalNumber(SIDEBAR_WIDTH_STORAGE_KEY, DEFAULT_SIDEBAR_WIDTH); + if (raw > 0) { + return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, raw)); } return DEFAULT_SIDEBAR_WIDTH; } @@ -314,23 +302,12 @@ export function ChatLayout() { const [sidebarWidth, setSidebarWidth] = useState(readPersistedWidth); useEffect(() => { - try { - window.localStorage.setItem( - SIDEBAR_COLLAPSED_STORAGE_KEY, - String(collapsed), - ); - } catch { - // Storage unavailable (private mode, quota, etc.) - } + setLocalBool(SIDEBAR_COLLAPSED_STORAGE_KEY, collapsed); }, [collapsed]); const handleSidebarWidthChange = useCallback((width: number) => { setSidebarWidth(width); - try { - window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(width))); - } catch { - // Storage unavailable - } + setLocalNumber(SIDEBAR_WIDTH_STORAGE_KEY, Math.round(width)); }, []); const isMobile = useIsMobile(); diff --git a/apps/web/src/domains/chat/components/chat-composer/use-draft-input.ts b/apps/web/src/domains/chat/components/chat-composer/use-draft-input.ts index 5e7bee9cad3..82b36637fe2 100644 --- a/apps/web/src/domains/chat/components/chat-composer/use-draft-input.ts +++ b/apps/web/src/domains/chat/components/chat-composer/use-draft-input.ts @@ -20,6 +20,8 @@ import { useState, } from "react"; +import { getLocalSetting, setLocalSetting } from "@/utils/local-settings"; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -35,10 +37,9 @@ function storageKey(assistantId: string): string { // --------------------------------------------------------------------------- function loadDrafts(assistantId: string): Map { - if (typeof window === "undefined") return new Map(); + const raw = getLocalSetting(storageKey(assistantId), ""); + if (!raw) return new Map(); try { - const raw = window.localStorage.getItem(storageKey(assistantId)); - if (!raw) return new Map(); const parsed: unknown = JSON.parse(raw); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return new Map(); @@ -57,15 +58,10 @@ function persistDrafts( assistantId: string, drafts: Map, ): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - storageKey(assistantId), - JSON.stringify(Object.fromEntries(drafts)), - ); - } catch { - // Storage can fail in private browsing / quota-exceeded. - } + setLocalSetting( + storageKey(assistantId), + JSON.stringify(Object.fromEntries(drafts)), + ); } // --------------------------------------------------------------------------- diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index d20479314f5..310e265cb9e 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -50,6 +50,7 @@ import { IOSAppBanner } from "@/components/nudges/ios-app-banner"; import { MacOSAppBanner } from "@/components/nudges/macos-app-banner"; import { Loader2 } from "lucide-react"; import { Button, Notice, ResizablePanel } from "@vellum/design-library"; +import { getLocalBool, setLocalBool, removeLocalSetting } from "@/utils/local-settings"; import { ProviderBillingBanner } from "@/domains/chat/components/provider-billing-banner"; import { QueuedMessagesDrawer } from "@/domains/chat/components/queued-messages-drawer"; import { AppViewerContainer } from "@/components/app-viewer-container"; @@ -1013,12 +1014,12 @@ export function ChatRouteContent({ const [warningDismissed, setWarningDismissed] = useState(() => { if (!assistantId) return false; - return localStorage.getItem(`vellum:diskPressureDismissed:${assistantId}`) === "true"; + return getLocalBool(`vellum:diskPressureDismissed:${assistantId}`, false); }); const dismissWarning = useCallback(() => { if (!assistantId) return; - localStorage.setItem(`vellum:diskPressureDismissed:${assistantId}`, "true"); + setLocalBool(`vellum:diskPressureDismissed:${assistantId}`, true); setWarningDismissed(true); }, [assistantId]); @@ -1027,7 +1028,7 @@ export function ChatRouteContent({ const st = diskPressure.status?.state; if (st && st !== "warning" && warningDismissed) { if (assistantId) { - localStorage.removeItem(`vellum:diskPressureDismissed:${assistantId}`); + removeLocalSetting(`vellum:diskPressureDismissed:${assistantId}`); } setWarningDismissed(false); } diff --git a/apps/web/src/domains/chat/components/mic-permission-primer.tsx b/apps/web/src/domains/chat/components/mic-permission-primer.tsx index c63fefad5c8..95dd39bafb4 100644 --- a/apps/web/src/domains/chat/components/mic-permission-primer.tsx +++ b/apps/web/src/domains/chat/components/mic-permission-primer.tsx @@ -3,6 +3,7 @@ import { Mic } from "lucide-react"; import { Button } from "@vellum/design-library"; import { Modal } from "@vellum/design-library"; +import { getLocalBool, setLocalBool } from "@/utils/local-settings"; import { isBatchSttSupported } from "@/domains/chat/components/voice-input-button"; const MIC_PRIMER_STORAGE_KEY = "vellum:voice:permissionPrimerSeen"; @@ -13,17 +14,10 @@ const MIC_PRIMER_STORAGE_KEY = "vellum:voice:permissionPrimerSeen"; * dismissed the primer dialog. */ export function shouldShowMicPrimer(): boolean { - if (typeof window === "undefined") { - return false; - } if (!isBatchSttSupported()) { return false; } - try { - return localStorage.getItem(MIC_PRIMER_STORAGE_KEY) !== "true"; - } catch { - return false; - } + return !getLocalBool(MIC_PRIMER_STORAGE_KEY, false); } export interface MicPermissionPrimerProps { @@ -52,11 +46,7 @@ export function MicPermissionPrimer({ onCancel, }: MicPermissionPrimerProps) { const handleContinue = () => { - try { - localStorage.setItem(MIC_PRIMER_STORAGE_KEY, "true"); - } catch { - // localStorage may be unavailable (e.g. private browsing quota exceeded). - } + setLocalBool(MIC_PRIMER_STORAGE_KEY, true); onContinue(); }; diff --git a/apps/web/src/domains/intelligence/components/skills/skills-tab.tsx b/apps/web/src/domains/intelligence/components/skills/skills-tab.tsx index 24f3d8a9cae..269cd4224d6 100644 --- a/apps/web/src/domains/intelligence/components/skills/skills-tab.tsx +++ b/apps/web/src/domains/intelligence/components/skills/skills-tab.tsx @@ -27,6 +27,7 @@ import { } from "react"; import { Button, Card, ConfirmDialog, Input, Popover } from "@vellum/design-library"; +import { getLocalBool, setLocalBool } from "@/utils/local-settings"; import { MobileSidebarDrawer, MobileSidebarTrigger, @@ -95,10 +96,9 @@ export function SkillsTab({ assistantId, initialSkillId }: SkillsTabProps) { const [removingSkillId, setRemovingSkillId] = useState(null); const [skillPendingRemoval, setSkillPendingRemoval] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); - const [tipDismissed, setTipDismissed] = useState(() => { - if (typeof window === "undefined") return false; - return window.localStorage.getItem(TIP_STORAGE_KEY) === "1"; - }); + const [tipDismissed, setTipDismissed] = useState(() => + getLocalBool(TIP_STORAGE_KEY, false), + ); useEffect(() => { const handle = setTimeout(() => { @@ -185,9 +185,7 @@ export function SkillsTab({ assistantId, initialSkillId }: SkillsTabProps) { const handleDismissTip = useCallback(() => { setTipDismissed(true); - if (typeof window !== "undefined") { - window.localStorage.setItem(TIP_STORAGE_KEY, "1"); - } + setLocalBool(TIP_STORAGE_KEY, true); }, []); const allSkills = useMemo( diff --git a/apps/web/src/domains/settings/components/integration-detail-modal.tsx b/apps/web/src/domains/settings/components/integration-detail-modal.tsx index 7e535196724..37a490c10b4 100644 --- a/apps/web/src/domains/settings/components/integration-detail-modal.tsx +++ b/apps/web/src/domains/settings/components/integration-detail-modal.tsx @@ -39,6 +39,12 @@ import { } from "@/domains/settings/api/oauth-apps"; import { IntegrationIcon } from "@/domains/settings/components/integration-icon"; +import { + type OAuthCompletePayload, + oauthCompletionStorageKey, + getOAuthCompleteMessagePayload, + getOAuthCompleteStoragePayload, +} from "@/lib/auth/oauth-popup"; function extractErrorDetail(error: unknown, fallback: string): string { if (typeof error === "object" && error !== null && "detail" in error) { @@ -53,67 +59,6 @@ function extractErrorDetail(error: unknown, fallback: string): string { return fallback; } -export interface OAuthCompletePayload { - type: "vellum:oauth-complete"; - requestId?: string | null; - oauthStatus?: string | null; - oauthProvider?: string | null; - oauthCode?: string | null; -} - -export function oauthCompletionStorageKey(requestId: string): string { - return `vellum:oauth-complete:${requestId}`; -} - -export function isOAuthCompletePayloadForRequest( - payload: unknown, - requestId: string, -): payload is OAuthCompletePayload { - return ( - typeof payload === "object" && - payload !== null && - (payload as OAuthCompletePayload).type === "vellum:oauth-complete" && - (payload as OAuthCompletePayload).requestId === requestId - ); -} - -export function getOAuthCompleteMessagePayload( - event: MessageEvent, - expectedOrigin: string, - requestId: string, -): OAuthCompletePayload | null { - if (event.origin !== expectedOrigin) { - return null; - } - - if (!isOAuthCompletePayloadForRequest(event.data, requestId)) { - return null; - } - - return event.data; -} - -export function getOAuthCompleteStoragePayload( - event: StorageEvent, - requestId: string, -): OAuthCompletePayload | null { - if ( - event.key !== oauthCompletionStorageKey(requestId) || - event.newValue === null - ) { - return null; - } - - try { - const payload = JSON.parse(event.newValue); - return isOAuthCompletePayloadForRequest(payload, requestId) - ? payload - : null; - } catch { - return null; - } -} - function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/apps/web/src/domains/settings/pages/voice-page.tsx b/apps/web/src/domains/settings/pages/voice-page.tsx index 93716a61a45..fd356b9cb24 100644 --- a/apps/web/src/domains/settings/pages/voice-page.tsx +++ b/apps/web/src/domains/settings/pages/voice-page.tsx @@ -91,9 +91,10 @@ function SpeechServicesBanner() { } function PushToTalkCard() { - const [activator, setActivator] = useState(() => - parseActivator(getLocalSetting(LS_PTT_ACTIVATION_KEY, "")), - ); + const [activator, setActivator] = useState(() => { + const raw = getLocalSetting(LS_PTT_ACTIVATION_KEY, ""); + return raw ? parseActivator(raw) : { kind: "off" }; + }); const [isRecording, setIsRecording] = useState(false); const [pendingModifiers, setPendingModifiers] = useState([]); const recordingZoneRef = useRef(null); diff --git a/apps/web/src/domains/voice/use-push-to-talk.ts b/apps/web/src/domains/voice/use-push-to-talk.ts index 054ad8f177c..7dbc4c3a08e 100644 --- a/apps/web/src/domains/voice/use-push-to-talk.ts +++ b/apps/web/src/domains/voice/use-push-to-talk.ts @@ -8,6 +8,7 @@ import { parseActivator, type PTTActivator, } from "@/utils/ptt-activator"; +import { getLocalSetting } from "@/utils/local-settings"; /** * Imperative handle (subset of `VoiceInputButtonHandle`) that the hook drives. @@ -119,13 +120,8 @@ export function usePushToTalk( } const readActivator = () => { - try { - activatorRef.current = parseActivator( - window.localStorage.getItem(LS_PTT_ACTIVATION_KEY), - ); - } catch { - activatorRef.current = { kind: "off" }; - } + const raw = getLocalSetting(LS_PTT_ACTIVATION_KEY, ""); + activatorRef.current = raw ? parseActivator(raw) : { kind: "off" }; }; readActivator(); diff --git a/apps/web/src/lib/auth/session-cleanup.test.ts b/apps/web/src/lib/auth/session-cleanup.test.ts index 675d75b8fc3..e85470fce88 100644 --- a/apps/web/src/lib/auth/session-cleanup.test.ts +++ b/apps/web/src/lib/auth/session-cleanup.test.ts @@ -131,6 +131,22 @@ describe("clearUserScopedStorage", () => { expect(localStorage.getItem("vellum:onboarding:completed")).toBeNull(); }); + test("preserves active app. nudge keys on logout", () => { + localStorage.setItem("app.iosNudge.downloaded", "true"); + localStorage.setItem("app.macOsNudge.bannerDismissed", "true"); + localStorage.setItem("app.githubNudge.starred", "true"); + + clearUserScopedStorage(); + + // Active iOS/macOS nudge keys must survive logout — they are + // still read by use-ios-app-nudge.ts and use-macos-app-nudge.ts. + // Dead github/discord keys are removed at startup by removeKey() + // in storage-migration.ts, not by the logout sweep. + expect(localStorage.getItem("app.iosNudge.downloaded")).toBe("true"); + expect(localStorage.getItem("app.macOsNudge.bannerDismissed")).toBe("true"); + expect(localStorage.getItem("app.githubNudge.starred")).toBe("true"); + }); + test("clears legacy prefixed keys if startup migration failed", () => { // eslint-disable-next-line no-restricted-syntax -- test: verifying cleanup of legacy auth token localStorage.setItem("gw:token", "legacy-jwt-token"); diff --git a/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts b/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts index 083126d7f32..32137b606a3 100644 --- a/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts +++ b/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts @@ -28,6 +28,8 @@ // impersonateVersion(null) — clear + reload // impersonateVersion() — log + return current value, no reload +import { getLocalSetting, setLocalSetting, removeLocalSetting } from "@/utils/local-settings"; + const STORAGE_KEY = "vellum:debug:impersonateAssistantVersion"; /** @@ -39,13 +41,8 @@ const STORAGE_KEY = "vellum:debug:impersonateAssistantVersion"; * or localStorage access throws (private browsing / sandboxed iframes). */ export function getImpersonatedAssistantVersion(): string | null { - if (typeof window === "undefined") return null; - try { - const raw = window.localStorage.getItem(STORAGE_KEY); - return raw && raw.length > 0 ? raw : null; - } catch { - return null; - } + const raw = getLocalSetting(STORAGE_KEY, ""); + return raw.length > 0 ? raw : null; } /** @@ -79,25 +76,30 @@ export function setImpersonatedAssistantVersion( return current; } - try { - if (value === null || value === "") { - window.localStorage.removeItem(STORAGE_KEY); - console.info( - "[vellumDebug] impersonateAssistantVersion = null (cleared) — reloading…", + if (value === null || value === "") { + removeLocalSetting(STORAGE_KEY); + if (getImpersonatedAssistantVersion() !== null) { + console.warn( + "[vellumDebug] failed to clear impersonateAssistantVersion flag", ); - } else { - window.localStorage.setItem(STORAGE_KEY, value); - console.info( - `[vellumDebug] impersonateAssistantVersion = ${JSON.stringify( - value, - )} — reloading…`, + return getImpersonatedAssistantVersion(); + } + console.info( + "[vellumDebug] impersonateAssistantVersion = null (cleared) — reloading…", + ); + } else { + setLocalSetting(STORAGE_KEY, value); + if (getImpersonatedAssistantVersion() !== value) { + console.warn( + "[vellumDebug] failed to persist impersonateAssistantVersion flag", ); + return getImpersonatedAssistantVersion(); } - } catch { - console.warn( - "[vellumDebug] failed to persist impersonateAssistantVersion flag", + console.info( + `[vellumDebug] impersonateAssistantVersion = ${JSON.stringify( + value, + )} — reloading…`, ); - return getImpersonatedAssistantVersion(); } window.location.reload(); return value === "" ? null : value; diff --git a/apps/web/src/stores/nudge-store.ts b/apps/web/src/stores/nudge-store.ts index 99777e3a110..5fc4e743e73 100644 --- a/apps/web/src/stores/nudge-store.ts +++ b/apps/web/src/stores/nudge-store.ts @@ -116,30 +116,4 @@ if (typeof window !== "undefined") { }); } -// --------------------------------------------------------------------------- -// One-shot legacy cleanup -// --------------------------------------------------------------------------- - -const LEGACY_CLEANUP_FLAG = "app.nudgeLegacy.cleaned"; - -const LEGACY_KEYS = [ - "app.githubNudge.starred", - "app.githubNudge.bannerDismissed", - "app.githubNudge.bannerDismissedAt", - "app.discordNudge.joined", - "app.discordNudge.bannerDismissed", - "app.discordNudge.firstSeenAt", -]; -if (typeof window !== "undefined") { - try { - if (localStorage.getItem(LEGACY_CLEANUP_FLAG) !== "true") { - for (const key of LEGACY_KEYS) { - localStorage.removeItem(key); - } - localStorage.setItem(LEGACY_CLEANUP_FLAG, "true"); - } - } catch { - // Storage unavailable (private mode, quota, etc.) — re-attempt next load. - } -} diff --git a/apps/web/src/utils/storage-migration.test.ts b/apps/web/src/utils/storage-migration.test.ts index c1a01e68ef9..e003c7565ee 100644 --- a/apps/web/src/utils/storage-migration.test.ts +++ b/apps/web/src/utils/storage-migration.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { migrateKey, migratePrefix, runStorageMigrations } from "./storage-migration"; +import { migrateKey, migratePrefix, migrateValue, removeKey, runStorageMigrations } from "./storage-migration"; beforeEach(() => { localStorage.clear(); @@ -10,6 +10,46 @@ afterEach(() => { localStorage.clear(); }); +describe("removeKey", () => { + test("removes an existing key", () => { + localStorage.setItem("old:key", "value"); + + removeKey("old:key"); + + expect(localStorage.getItem("old:key")).toBeNull(); + }); + + test("no-op when key is absent", () => { + removeKey("missing"); + + expect(localStorage.getItem("missing")).toBeNull(); + }); +}); + +describe("migrateValue", () => { + test("converts matching old value to new value", () => { + localStorage.setItem("key", "1"); + + migrateValue("key", "1", "true"); + + expect(localStorage.getItem("key")).toBe("true"); + }); + + test("no-op when current value does not match old value", () => { + localStorage.setItem("key", "true"); + + migrateValue("key", "1", "true"); + + expect(localStorage.getItem("key")).toBe("true"); + }); + + test("no-op when key is absent", () => { + migrateValue("missing", "1", "true"); + + expect(localStorage.getItem("missing")).toBeNull(); + }); +}); + describe("migrateKey", () => { test("renames old key to new key", () => { localStorage.setItem("old:key", "value"); @@ -230,6 +270,14 @@ describe("runStorageMigrations", () => { expect(localStorage.getItem("vellum:skillsTabTipDismissed")).toBeNull(); }); + test("converts skills tip value from '1' to 'true'", () => { + localStorage.setItem("vellum:skills:tipDismissed", "1"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:skills:tipDismissed")).toBe("true"); + }); + test("does not touch device: keys", () => { localStorage.setItem("device:theme", "dark"); localStorage.setItem("device:timezone", "UTC"); @@ -250,6 +298,18 @@ describe("runStorageMigrations", () => { expect(localStorage.getItem("intercom-session")).toBe("abc"); }); + test("removes legacy nudge keys and their cleanup flag", () => { + localStorage.setItem("app.githubNudge.starred", "true"); + localStorage.setItem("app.discordNudge.joined", "true"); + localStorage.setItem("app.nudgeLegacy.cleaned", "true"); + + runStorageMigrations(); + + expect(localStorage.getItem("app.githubNudge.starred")).toBeNull(); + expect(localStorage.getItem("app.discordNudge.joined")).toBeNull(); + expect(localStorage.getItem("app.nudgeLegacy.cleaned")).toBeNull(); + }); + test("full migration is idempotent", () => { localStorage.setItem("assistantSidebarCollapsed", "true"); localStorage.setItem("voice:ttsProvider", "openai"); diff --git a/apps/web/src/utils/storage-migration.ts b/apps/web/src/utils/storage-migration.ts index 37895f4bacc..8828ba62931 100644 --- a/apps/web/src/utils/storage-migration.ts +++ b/apps/web/src/utils/storage-migration.ts @@ -16,6 +16,34 @@ * at module level (see the import order comment in `main.tsx`). */ +/** + * Migrate a key's stored value from one format to another without + * renaming the key. Idempotent — only writes when the current value + * matches `oldValue` exactly. + */ +export function migrateValue(key: string, oldValue: string, newValue: string): void { + if (typeof window === "undefined") return; + try { + if (localStorage.getItem(key) === oldValue) { + localStorage.setItem(key, newValue); + } + } catch { + // Storage unavailable — retry on next load. + } +} + +/** + * Remove a legacy key that has no successor. Idempotent. + */ +export function removeKey(key: string): void { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(key); + } catch { + // Storage unavailable — retry on next load. + } +} + /** * Migrate a single static key. Idempotent: writes the new key only * when it doesn't already exist, removes the old key only after the @@ -142,4 +170,19 @@ export function runStorageMigrations(): void { // vellum_ per-org → vellum: migratePrefix("vellum_current_assistant_id__", "vellum:currentAssistantId:"); + + // -- Value format migrations --------------------------------------------- + // Skills tip was stored as "1"; getLocalBool expects "true". + migrateValue("vellum:skills:tipDismissed", "1", "true"); + + // -- Dead key removals -------------------------------------------------- + // Legacy nudge keys superseded by the `vellum:nudge-prefs` Zustand + // persist store. Also remove the one-time cleanup flag itself. + removeKey("app.githubNudge.starred"); + removeKey("app.githubNudge.bannerDismissed"); + removeKey("app.githubNudge.bannerDismissedAt"); + removeKey("app.discordNudge.joined"); + removeKey("app.discordNudge.bannerDismissed"); + removeKey("app.discordNudge.firstSeenAt"); + removeKey("app.nudgeLegacy.cleaned"); }