// ==UserScript== // @name Background for c.ai // @namespace c.ai Background Image Customizer // @match https://character.ai/* // @run-at document-idle // @grant none // @license MIT // @version 2.2.0 // @description Custom backgrounds for Character.AI: URL / Upload (IndexedDB) / Unsplash (search+browse+select), overlay, modes, sticky scroll, import/export, optional per-chat backgrounds. // @icon https://i.imgur.com/ynjBqKW.png // @downloadURL https://update.greasyfork.icu/scripts/501065/Background%20for%20cai.user.js // @updateURL https://update.greasyfork.icu/scripts/501065/Background%20for%20cai.meta.js // ==/UserScript== (() => { "use strict"; const VERSION = "2.2.0"; // Backward compatible with v2.0/v2.1 global prefs const LS_KEY_GLOBAL = "cai_bg_customizer_v2"; // New: per-chat overrides live here const LS_KEY_CHAT_PREFIX = "cai_bg_customizer_v2_chat_"; // Key storage (encrypted-at-rest if possible) const LS_SECRET_UNSPLASH = "cai_bg_customizer_v2_unsplash_secret_v1"; const LS_SECRET_FALLBACK = "cai_bg_customizer_v2_unsplash_key_plain_fallback"; // IndexedDB for uploaded images + crypto key const DB_NAME = "caiBgCustomizer"; const DB_VERSION = 1; const STORE_IMAGES = "images"; const STORE_KEYS = "keys"; // Global-only keys (never per chat) const GLOBAL_ONLY_KEYS = new Set(["enabled", "blurBehindUI", "dimUI", "perChatEnabled"]); // Per-chat keys (overrides apply only when perChatEnabled && chatId) const CHAT_KEYS = new Set([ "imageSource", "imageUrl", "uploadImageId", "uploadImageName", "unsplashSelected", "overlayOpacity", "attachment", "position", "imageType", ]); const DEFAULTS = { // Global-only enabled: true, blurBehindUI: false, dimUI: false, perChatEnabled: false, // Image source selection (can be per chat) imageSource: "url", // "url" | "upload" | "unsplash" // URL source imageUrl: "", // Upload source uploadImageId: null, uploadImageName: "", // Unsplash unsplashSelected: null, // { id, regularUrl, fullUrl, thumbUrl, userName, userProfile, attributionText, downloadLocation } | null // Background behavior overlayOpacity: 0.35, // 0..1 attachment: "fixed", // "fixed" | "scroll" position: "center center", imageType: "Stretch", // "Stretch" | "Distort" | "ContainSingle" | "ContainRepeat" }; /** --------------------------- * Utilities * --------------------------- */ const clamp = (n, min, max) => Math.min(max, Math.max(min, n)); function safeQS(sel) { try { return document.querySelector(sel); } catch { return null; } } function looksLikeUrl(s) { if (!s) return true; const v = String(s).trim(); if (!v) return true; if (v.startsWith("data:image/")) return true; try { const u = new URL(v, location.href); return u.protocol === "http:" || u.protocol === "https:"; } catch { return false; } } function uid(prefix = "id") { return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } function bufToB64(buf) { const bytes = new Uint8Array(buf); let binary = ""; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); } return btoa(binary); } function b64ToBuf(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; } async function blobToDataUrl(blob) { return await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result)); r.onerror = () => reject(r.error || new Error("FileReader failed")); r.readAsDataURL(blob); }); } async function dataUrlToBlob(dataUrl) { const res = await fetch(dataUrl); return await res.blob(); } /** --------------------------- * Chat ID from URL * --------------------------- */ function getChatIdFromUrl() { const m = location.pathname.match(/^\/chat\/([^\/?#]+)/); return m ? m[1] : null; } let currentChatId = getChatIdFromUrl(); /** --------------------------- * Global + per-chat storage * --------------------------- */ function migrateSettings(s) { const merged = { ...DEFAULTS, ...(s || {}) }; // sanity if (!["url", "upload", "unsplash"].includes(merged.imageSource)) merged.imageSource = "url"; if (!["fixed", "scroll"].includes(merged.attachment)) merged.attachment = "fixed"; if (!["Stretch", "Distort", "ContainSingle", "ContainRepeat"].includes(merged.imageType)) merged.imageType = "Stretch"; merged.overlayOpacity = clamp(Number(merged.overlayOpacity ?? DEFAULTS.overlayOpacity), 0, 1); merged.position = (merged.position || DEFAULTS.position).trim() || DEFAULTS.position; merged.enabled = !!merged.enabled; merged.blurBehindUI = !!merged.blurBehindUI; merged.dimUI = !!merged.dimUI; merged.perChatEnabled = !!merged.perChatEnabled; return merged; } function loadGlobalSettings() { try { const raw = localStorage.getItem(LS_KEY_GLOBAL); if (!raw) return { ...DEFAULTS }; return migrateSettings(JSON.parse(raw)); } catch { return { ...DEFAULTS }; } } function saveGlobalSettings(next) { localStorage.setItem(LS_KEY_GLOBAL, JSON.stringify(next)); } function loadChatOverrides(chatId) { if (!chatId) return {}; try { const raw = localStorage.getItem(LS_KEY_CHAT_PREFIX + chatId); if (!raw) return {}; const parsed = JSON.parse(raw); // only allow known keys const clean = {}; for (const k of Object.keys(parsed || {})) { if (CHAT_KEYS.has(k)) clean[k] = parsed[k]; } return clean; } catch { return {}; } } function saveChatOverrides(chatId, overrides) { if (!chatId) return; const clean = {}; for (const k of Object.keys(overrides || {})) { if (CHAT_KEYS.has(k)) clean[k] = overrides[k]; } localStorage.setItem(LS_KEY_CHAT_PREFIX + chatId, JSON.stringify(clean)); } function clearChatOverrides(chatId) { if (!chatId) return; localStorage.removeItem(LS_KEY_CHAT_PREFIX + chatId); } function clearAllChatOverrides() { const keys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(LS_KEY_CHAT_PREFIX)) keys.push(k); } keys.forEach((k) => localStorage.removeItem(k)); } function getAllChatOverridesMap() { const out = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (!k || !k.startsWith(LS_KEY_CHAT_PREFIX)) continue; const chatId = k.slice(LS_KEY_CHAT_PREFIX.length); try { out[chatId] = JSON.parse(localStorage.getItem(k) || "{}") || {}; } catch { out[chatId] = {}; } } return out; } let globalSettings = loadGlobalSettings(); function isChatScoped() { return !!(globalSettings.perChatEnabled && currentChatId); } function getEffectiveSettings() { if (!isChatScoped()) return globalSettings; const overrides = loadChatOverrides(currentChatId); return migrateSettings({ ...globalSettings, ...overrides }); } /** --------------------------- * IndexedDB (uploads + crypto key) * --------------------------- */ let dbPromise = null; function openDB() { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE_IMAGES)) db.createObjectStore(STORE_IMAGES, { keyPath: "id" }); if (!db.objectStoreNames.contains(STORE_KEYS)) db.createObjectStore(STORE_KEYS, { keyPath: "name" }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error || new Error("IndexedDB open failed")); }); return dbPromise; } async function idbPut(store, value) { const db = await openDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(store, "readwrite"); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("IDB transaction failed")); tx.objectStore(store).put(value); }); } async function idbGet(store, key) { const db = await openDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(store, "readonly"); const req = tx.objectStore(store).get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error || new Error("IDB get failed")); }); } async function idbDelete(store, key) { const db = await openDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(store, "readwrite"); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("IDB delete failed")); tx.objectStore(store).delete(key); }); } async function storeUploadedBlob({ id, name, type, blob }) { const rec = { id: id || uid("img"), name: name || "", type: type || blob.type || "image/*", size: blob.size || 0, updatedAt: Date.now(), blob }; await idbPut(STORE_IMAGES, rec); return rec; } async function storeUploadedFile(file, forcedId = null) { return await storeUploadedBlob({ id: forcedId, name: file.name || "", type: file.type || "image/*", blob: file }); } async function getUploadedImageBlob(id) { if (!id) return null; const rec = await idbGet(STORE_IMAGES, id); return rec?.blob || null; } /** --------------------------- * Unsplash key: encrypted-at-rest if possible * --------------------------- */ async function getAesKey() { if (!crypto?.subtle) return null; const existing = await idbGet(STORE_KEYS, "aes_gcm_v1").catch(() => null); if (existing?.key) return existing.key; const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); await idbPut(STORE_KEYS, { name: "aes_gcm_v1", key }).catch(() => {}); return key; } async function encryptSecret(plaintext) { const key = await getAesKey(); if (!key) return null; const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = new TextEncoder().encode(plaintext); const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc); return { iv: bufToB64(iv.buffer), ct: bufToB64(ct) }; } async function decryptSecret(payload) { const key = await getAesKey(); if (!key) return null; const iv = new Uint8Array(b64ToBuf(payload.iv)); const ct = b64ToBuf(payload.ct); const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); return new TextDecoder().decode(pt); } let unsplashAccessKeyCache = null; async function loadUnsplashKey() { if (unsplashAccessKeyCache !== null) return unsplashAccessKeyCache; try { const raw = localStorage.getItem(LS_SECRET_UNSPLASH); if (raw) { const payload = JSON.parse(raw); const key = await decryptSecret(payload); if (key) { unsplashAccessKeyCache = key; return key; } } } catch {} const fallback = localStorage.getItem(LS_SECRET_FALLBACK); unsplashAccessKeyCache = fallback ? String(fallback) : ""; return unsplashAccessKeyCache; } async function saveUnsplashKey(nextKey) { unsplashAccessKeyCache = String(nextKey || "").trim(); localStorage.removeItem(LS_SECRET_UNSPLASH); localStorage.removeItem(LS_SECRET_FALLBACK); if (!unsplashAccessKeyCache) return; const enc = await encryptSecret(unsplashAccessKeyCache); if (enc) localStorage.setItem(LS_SECRET_UNSPLASH, JSON.stringify(enc)); else localStorage.setItem(LS_SECRET_FALLBACK, unsplashAccessKeyCache); } /** --------------------------- * Unsplash API * --------------------------- */ async function unsplashFetch(path, params = {}) { const key = await loadUnsplashKey(); if (!key) throw new Error("Missing Unsplash Access Key"); const url = new URL(`https://api.unsplash.com${path}`); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, String(v))); const res = await fetch(url.toString(), { headers: { Authorization: `Client-ID ${key}` } }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Unsplash error ${res.status}: ${text.slice(0, 200)}`); } return await res.json(); } async function unsplashSearch(query) { return await unsplashFetch("/search/photos", { query, per_page: 24, orientation: "landscape" }); } async function unsplashBrowse() { return await unsplashFetch("/photos", { per_page: 24, order_by: "popular" }); } async function unsplashTrackDownload(downloadLocation) { const key = await loadUnsplashKey(); if (!key || !downloadLocation) return; try { await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } }); } catch {} } function normalizeUnsplashItem(item) { const userName = item?.user?.name || item?.user?.username || "Unknown"; const userProfile = item?.user?.links?.html || item?.user?.portfolio_url || ""; const attributionText = `Photo by ${userName} on Unsplash`; return { id: item.id, regularUrl: item?.urls?.regular || "", fullUrl: item?.urls?.full || "", thumbUrl: item?.urls?.thumb || item?.urls?.small || "", userName, userProfile, attributionText, downloadLocation: item?.links?.download_location || "" }; } /** --------------------------- * DOM target selection (userstyle-inspired) * --------------------------- */ function findAppContainer() { const primary = safeQS("body > div > div:has(main)"); if (primary) return primary; const byMain = safeQS("main")?.closest("div"); if (byMain) return byMain; return safeQS("#__next") || safeQS("[id*='root']") || document.body; } /** --------------------------- * Effective image URL resolution * --------------------------- */ let uploadObjectUrl = null; let uploadObjectUrlId = null; async function getEffectiveImageUrl(eff) { if (!eff.enabled) return ""; if (eff.imageSource === "url") { const url = (eff.imageUrl || "").trim(); return looksLikeUrl(url) ? url : ""; } if (eff.imageSource === "upload") { const id = eff.uploadImageId; if (!id) return ""; if (uploadObjectUrl && uploadObjectUrlId === id) return uploadObjectUrl; if (uploadObjectUrl) { try { URL.revokeObjectURL(uploadObjectUrl); } catch {} uploadObjectUrl = null; uploadObjectUrlId = null; } const blob = await getUploadedImageBlob(id); if (!blob) return ""; uploadObjectUrl = URL.createObjectURL(blob); uploadObjectUrlId = id; return uploadObjectUrl; } if (eff.imageSource === "unsplash") { const sel = eff.unsplashSelected; if (!sel) return ""; return (sel.regularUrl || sel.fullUrl || "").trim(); } return ""; } /** --------------------------- * CSS injection * --------------------------- */ const STYLE_ID = "cai-bg-customizer-style-v22"; function computeBackgroundCss(eff, imageUrl) { if (!eff.enabled || !imageUrl) { return ` :root { --cai-bg-enabled: 0; } .cai-bg-target { background: none !important; } `; } const overlay = clamp(Number(eff.overlayOpacity ?? 0.35), 0, 1); const attach = eff.attachment === "scroll" ? "scroll" : "fixed"; const pos = (eff.position || "center center").trim() || "center center"; let size = "cover"; let repeat = "no-repeat"; switch (eff.imageType) { case "Distort": size = "100vw 100vh"; repeat = "no-repeat"; break; case "ContainSingle": size = "contain"; repeat = "no-repeat"; break; case "ContainRepeat": size = "contain"; repeat = "repeat"; break; case "Stretch": default: size = "cover"; repeat = "no-repeat"; break; } const safeUrl = String(imageUrl).replace(/"/g, '\\"'); return ` :root { --cai-bg-enabled: 1; --cai-bg-overlay: ${overlay}; --cai-bg-attachment: ${attach}; --cai-bg-position: ${pos}; --cai-bg-size: ${size}; --cai-bg-repeat: ${repeat}; } .cai-bg-target { background-image: linear-gradient( rgba(0,0,0,var(--cai-bg-overlay)), rgba(0,0,0,var(--cai-bg-overlay)) ), url("${safeUrl}") !important; background-attachment: var(--cai-bg-attachment) !important; background-position: var(--cai-bg-position) !important; background-size: var(--cai-bg-size) !important; background-repeat: var(--cai-bg-repeat) !important; } `; } function computeUiOverrideCss(eff) { const disableBlur = !eff.blurBehindUI; const dimUI = !!eff.dimUI; return ` #cai-bg-ui-host { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; } #cai-bg-ui { pointer-events: auto; } ${disableBlur ? ` .max-w-2xl:has(textarea) > * { backdrop-filter: none !important; } ` : ""} .max-w-2xl:has(textarea) > * { background: transparent !important; } main > div:has(title) > div > div.pb-4 { background-color: transparent !important; } ${dimUI ? ` main [class*="bg-"], header [class*="bg-"] { background-color: rgba(0,0,0,0.25) !important; } ` : ""} `; } let applySeq = 0; let applyTimer = null; function requestApply() { clearTimeout(applyTimer); applyTimer = setTimeout(() => applyStyles(), 30); } async function applyStyles() { const seq = ++applySeq; const eff = getEffectiveSettings(); const target = findAppContainer(); target.classList.add("cai-bg-target"); const imageUrl = await getEffectiveImageUrl(eff); if (seq !== applySeq) return; let styleEl = document.getElementById(STYLE_ID); if (!styleEl) { styleEl = document.createElement("style"); styleEl.id = STYLE_ID; document.head.appendChild(styleEl); } styleEl.textContent = ` ${computeBackgroundCss(eff, imageUrl)} ${computeUiOverrideCss(eff)} `; } /** --------------------------- * Import / Export * --------------------------- */ async function buildExportObject() { const key = await loadUnsplashKey(); const chats = getAllChatOverridesMap(); // gather referenced upload IDs (global + all chat overrides) const ids = new Set(); if (globalSettings.uploadImageId) ids.add(globalSettings.uploadImageId); Object.values(chats).forEach((o) => { if (o?.uploadImageId) ids.add(o.uploadImageId); }); const uploads = []; for (const id of ids) { const blob = await getUploadedImageBlob(id); if (!blob) continue; uploads.push({ id, name: "", // name is stored in settings, not IDB (but we don't need it here) type: blob.type || "image/*", dataUrl: await blobToDataUrl(blob) }); } return { format: "cai-bg-customizer", version: VERSION, exportedAt: new Date().toISOString(), global: { ...globalSettings }, chats, secrets: { unsplashAccessKey: key || "" }, assets: { uploads } }; } async function importFromObject(obj) { if (!obj || obj.format !== "cai-bg-customizer") { throw new Error("Not a cai-bg-customizer export."); } // overwrite global globalSettings = migrateSettings(obj.global || {}); saveGlobalSettings(globalSettings); // overwrite all chat overrides clearAllChatOverrides(); const chats = obj.chats || {}; for (const [chatId, overrides] of Object.entries(chats)) { saveChatOverrides(chatId, overrides || {}); } // restore key if (obj?.secrets?.unsplashAccessKey) { await saveUnsplashKey(obj.secrets.unsplashAccessKey); } // restore uploads (keep same IDs so references stay valid) const uploads = obj?.assets?.uploads || []; for (const up of uploads) { if (!up?.id || !up?.dataUrl) continue; const blob = await dataUrlToBlob(up.dataUrl); await storeUploadedBlob({ id: up.id, name: up.name || "", type: up.type || blob.type, blob }); } } /** --------------------------- * UI (Shadow DOM) * --------------------------- */ const UI_HOST_ID = "cai-bg-ui-host"; const UI_ID = "cai-bg-ui"; const PANEL_ID = "cai-bg-panel"; const BTN_ID = "cai-bg-btn"; const TOAST_ID = "cai-bg-toast"; function ensureUI() { let host = document.getElementById(UI_HOST_ID); if (host) return host; host = document.createElement("div"); host.id = UI_HOST_ID; const ui = document.createElement("div"); ui.id = UI_ID; const shadow = ui.attachShadow({ mode: "open" }); shadow.innerHTML = `