diff --git a/docs/providers-and-models.md b/docs/providers-and-models.md index 163240de0..d5b6d3db0 100644 --- a/docs/providers-and-models.md +++ b/docs/providers-and-models.md @@ -6,13 +6,49 @@ **Settings → Providers** selects the main model for conversation, planning and final replies. **Settings → Assistive Models** groups Vision (including screenshot -limits and redaction), Speech to text, and Jev (TypeSafe). Configuring an assistive +limits and redaction), Speech to text, Jev (TypeSafe), and SafeSocial. Configuring an assistive model does not replace the active provider. Jev is outside the dynamic provider list; its verification, fast-classification and experimental browser switches are independent opt-ins. See [the settings guide](https://webbrain.one/docs/settings/#multimodal) and [data flow](privacy-and-data-flow.md#optional-jev-typesafe-scheduled-task-verification) for setup and disclosure details. Existing `#multimodal` settings links still work. +### SafeSocial image classifier (experimental) + +**Settings → Assistive Models → SafeSocial** optionally filters Instagram images +and video posters using the local, multilabel +[EfficientNet-Lite0 classifier](https://huggingface.co/webbrain-one/safesocial-trigger-classifier-efficientnet-lite0). +It is off by default and does not replace the chat or screenshot vision model. +Choose categories, an absolute score threshold (default 95%), and blur, hide, +dim, or warning. Every filtered image has a reveal button. Videos without a +poster and video frames are not classified in this first integration. + +Enabling downloads approximately 13.6 MB of model data from Hugging Face. Files +are pinned to revision `d39182d06486b237ba33bc675b9302a206182460`, verified with +SHA-256 and cached in this browser. JavaScript/WASM uses the existing packaged +ONNX runtime; no remote executable code or bundled model weights are added. +Inference runs in a dedicated CPU/WASM worker on both Chrome and Firefox. +Images are fetched from Instagram's image CDNs without cookies and processed +locally; they are not uploaded to Hugging Face or an inference service. + +Turning SafeSocial off cancels pending work, unloads the worker and restores +filtered media. Cached weights remain until **Remove downloaded model** is +clicked while disabled. A failed download or inference leaves media unchanged +and displays an error; there is no mock/keyword fallback. This is an experimental +social-comparison classifier, not a general content-safety or NSFW detector. +Scores can be wrong. English and Turkish copy is provided, with English fallback +for other interface languages. + +Validation: `npm run test:safesocial` covers configuration, URL/caller gates, +cancellation and Chrome/Firefox parity. `npm run test:safesocial:ui` exercises +responsive settings and media lifecycle in both browsers. Set +`SAFESOCIAL_MODEL_DIR` to a directory containing the pinned bundle under +`webbrain-safesocial-model.onnx` and `webbrain-safesocial-model.json` to also run +real ONNX/WASM inference in that browser test without live network downloads. +With the same bundle, `npm run test:safesocial:extension` checks the complete +Chrome MV3 settings/background/offscreen path in an isolated browser profile, +including cached reactivation and model removal. + ## Provider Interface (`providers/base.js`) Every LLM provider implements the `BaseLLMProvider` interface: diff --git a/package.json b/package.json index 9f060779e..886eaa825 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "test": "npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:provider-limits && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:build-unpacked && npm run test:attachment-drop && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", + "test": "npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:provider-limits && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", "test:provider-limits": "node test/provider-model-limits.mjs", "test:accessibility-tree-benchmark": "node --test test/llm/lib/accessibility-tree-formats.test.mjs && node test/llm/accessibility-tree-benchmark.mjs --no-exact-tokenizer --check", "test:attachment-drop": "node test/attachment-drop.mjs", @@ -51,7 +51,10 @@ "test:systemone": "node --test test/systemone.mjs", "test:systemone:ui": "node test/systemone-ui.mjs", "test:systemone:fast": "node --test test/systemone-fast.mjs && node test/systemone-fast-dom.mjs && node --test test/jev/fixtures.test.mjs", - "benchmark:jev": "node test/jev/benchmark.mjs" + "benchmark:jev": "node test/jev/benchmark.mjs", + "test:safesocial": "node --test test/safesocial.mjs", + "test:safesocial:ui": "node test/safesocial-ui.mjs", + "test:safesocial:extension": "node test/safesocial-extension.mjs" }, "devDependencies": { "playwright": "^1.48.0", diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index f0a629035..a9f8d9111 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -89,6 +89,19 @@ "js": ["src/agent/social-media-downloader.js"], "run_at": "document_start", "world": "MAIN" + }, + { + "matches": [ + "https://www.instagram.com/*", + "https://instagram.com/*" + ], + "js": [ + "src/safesocial/content.js" + ], + "css": [ + "src/safesocial/content.css" + ], + "run_at": "document_idle" } ], "options_page": "src/ui/settings.html", diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 84e6bb67e..879769a33 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -1,3 +1,4 @@ +import { installSafeSocialBackground } from './safesocial/background.js'; import { ProviderManager } from './providers/manager.js'; import { WEBGPU_COMPASS_TINY_V2_MODEL_ID, @@ -118,6 +119,21 @@ import { * Routes messages between side panel, content scripts, and the agent. */ +// SafeSocial never creates an inference worker until an opted-in request arrives. +let safeSocialHostOpening; +installSafeSocialBackground(chrome, async (command, payload = {}) => { + if (command === 'stop') { + if (safeSocialHostOpening) await safeSocialHostOpening; + if (!await chrome.offscreen.hasDocument()) return { status: 'idle' }; + } else { + safeSocialHostOpening ||= ensureOffscreen().finally(() => { safeSocialHostOpening = null; }); + await safeSocialHostOpening; + } + const result = await chrome.runtime.sendMessage({ target: 'safesocial-host', command, ...payload }); + if (!result?.ok) throw new Error(result?.error || 'Classifier host unavailable.'); + return result; +}); + const providerManager = new ProviderManager(); const apocalypseController = createApocalypseController(chrome); const VISION_OFFSCREEN_URL = chrome.runtime.getURL('src/offscreen/offscreen.html'); diff --git a/src/chrome/src/config-transfer.js b/src/chrome/src/config-transfer.js index d0c90d260..1aac9a21b 100644 --- a/src/chrome/src/config-transfer.js +++ b/src/chrome/src/config-transfer.js @@ -11,6 +11,7 @@ import { USER_MEMORY_STORAGE_KEY, } from './agent/user-memory.js'; import { AUTO_GROUP_TABS_KEY } from './tab-group-preference.js'; +import { normalizeSettings as normalizeSafeSocialSettings, SETTINGS_KEY as SAFE_SOCIAL_SETTINGS_KEY } from './safesocial/config.js'; import { normalizeUiScale, UI_SCALE_STORAGE_KEY } from './ui/ui-scale.js'; export const CONFIG_SCHEMA = 'webbrain-config/1'; @@ -85,6 +86,7 @@ export const DEFAULT_CONFIG_SETTINGS = Object.freeze({ systemOneWatchThreshold: 0.7, systemOneCompletionThreshold: 0.7, typesafeApiKey: '', + [SAFE_SOCIAL_SETTINGS_KEY]: normalizeSafeSocialSettings(), }); export const CONFIG_STORAGE_KEYS = Object.freeze(Object.keys(DEFAULT_CONFIG_SETTINGS)); @@ -190,7 +192,7 @@ function validSettingValue(key, value) { if (STRING_KEYS.has(key)) return typeof value === 'string'; if (ARRAY_KEYS.has(key)) return Array.isArray(value); if (NULLABLE_OBJECT_KEYS.has(key)) return value === null || isPlainObject(value); - if (key === 'providers' || key === USER_MEMORY_STORAGE_KEY) return isPlainObject(value); + if (key === 'providers' || key === USER_MEMORY_STORAGE_KEY || key === SAFE_SOCIAL_SETTINGS_KEY) return isPlainObject(value); return true; } @@ -208,6 +210,14 @@ function normalizeSettings(source, { strict = false } = {}) { settings[key] = normalizeUiScale(value); continue; } + if (key === SAFE_SOCIAL_SETTINGS_KEY) { + if (!isPlainObject(value)) { + if (strict) throw new Error(`Invalid value for configuration setting "${key}".`); + continue; + } + settings[key] = normalizeSafeSocialSettings(value); + continue; + } if (!validSettingValue(key, value)) { if (strict) throw new Error(`Invalid value for configuration setting "${key}".`); continue; @@ -287,6 +297,8 @@ export function parseConfigPatchImport(json) { } settings[key] = key === UI_SCALE_STORAGE_KEY ? normalizeUiScale(value) + : key === SAFE_SOCIAL_SETTINGS_KEY + ? normalizeSafeSocialSettings(value) : key === 'providers' ? sanitizeProviders(value, { strict: true }) : clone(value); diff --git a/src/chrome/src/offscreen/offscreen.html b/src/chrome/src/offscreen/offscreen.html index d53ba0b9f..a19e8f458 100644 --- a/src/chrome/src/offscreen/offscreen.html +++ b/src/chrome/src/offscreen/offscreen.html @@ -24,6 +24,7 @@ AUDIO_PLAYBACK (watch alerts). --> + diff --git a/src/chrome/src/safesocial/background.js b/src/chrome/src/safesocial/background.js new file mode 100644 index 000000000..a4507a45f --- /dev/null +++ b/src/chrome/src/safesocial/background.js @@ -0,0 +1,53 @@ +import { SETTINGS_KEY, CACHE_NAME, normalizeSettings, isInstagramUrl, isMediaUrl, matchingLabels } from './config.js'; + +export function installSafeSocialBackground(api, runHost) { + let generation = 0; + let transition = Promise.resolve(); + const settingsUrl = api.runtime.getURL('src/ui/settings.html'); + api.storage.onChanged.addListener((changes, area) => { + if (area !== 'local' || !changes[SETTINGS_KEY]) return; + generation++; + // Serialize stop against a rapid off/on toggle and pending host creation. + if (changes[SETTINGS_KEY].newValue?.enabled !== true) { + transition = transition.then(() => runHost('stop')).catch(() => {}); + } + }); + async function handle(message, sender) { + if (sender.id !== api.runtime.id) throw new Error('Invalid classifier caller.'); + const trustedSettings = String(sender.url || '').split(/[?#]/)[0] === settingsUrl; + if (message.command === 'classify') { + if (!sender.tab || sender.frameId !== 0 || !isInstagramUrl(sender.url) || !isMediaUrl(message.url)) { + throw new Error('Classifier is limited to Instagram images.'); + } + } else if (!trustedSettings || !['prepare', 'status', 'remove'].includes(message.command)) { + throw new Error('Open SafeSocial settings to manage the classifier.'); + } + await transition; + const epoch = generation; + const settings = normalizeSettings((await api.storage.local.get(SETTINGS_KEY))[SETTINGS_KEY]); + if (message.command === 'remove') { + if (settings.enabled) throw new Error('Disable SafeSocial before removing its model.'); + if (epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + // A new enable/prepare must wait until deletion has completed. + const removal = transition.then(async () => { + await runHost('stop'); + await caches.delete(CACHE_NAME); + }); + transition = removal.catch(() => {}); + await removal; + return { ok: true, status: 'idle' }; + } + if (!settings.enabled || epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + const result = await runHost(message.command, { url: message.url }); + // A settings change or disabling the feature invalidates every queued result. + if (epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + return { ok: true, ...result, ...(message.command === 'classify' + ? { labels: matchingLabels(result.scores, settings), action: settings.action } : {}) }; + } + api.runtime.onMessage.addListener((message, sender, respond) => { + if (message?.target !== 'safesocial') return false; + handle(message, sender).then(respond, error => respond({ ok: false, error: error.message })); + return true; + }); + return { handle }; +} diff --git a/src/chrome/src/safesocial/config.js b/src/chrome/src/safesocial/config.js new file mode 100644 index 000000000..4f066cb7b --- /dev/null +++ b/src/chrome/src/safesocial/config.js @@ -0,0 +1,68 @@ +/** SafeSocial is an optional, local multilabel image classifier, not a chat provider. */ +export const SETTINGS_KEY = 'safeSocialSettings'; +export const CACHE_NAME = 'webbrain-safesocial-v1'; +export const MODEL_ID = 'webbrain-one/safesocial-trigger-classifier-efficientnet-lite0'; +export const MODEL_REVISION = 'd39182d06486b237ba33bc675b9302a206182460'; +export const MODEL_BASE = `https://huggingface.co/${MODEL_ID}/resolve/${MODEL_REVISION}/`; +export const MODEL_FILES = Object.freeze({ + 'safesocial-model.json': { bytes: 6260, sha256: '650152199b9b17426f2c40b03d237c3c6bed88101c069da0f38444ef563cee14' }, + 'model.onnx': { bytes: 13584716, sha256: 'fd37c1cd4aafa2bc318b7d29725fa6be3931d09cc1723bb30be3ee819ee22933' }, +}); +export const LABELS = Object.freeze([ + 'romance_jealousy', 'social_fomo', 'luxury_status', 'travel_lifestyle', + 'body_beauty_comparison', 'achievement_status', 'social_proof_popularity', 'exclusivity_access', +]); +export const DEFAULT_LABELS = Object.freeze(Object.fromEntries(LABELS.map(label => [label, + ['romance_jealousy', 'luxury_status', 'travel_lifestyle'].includes(label)]))); + +export function normalizeSettings(value = {}) { + const threshold = value?.threshold; + return { + enabled: value?.enabled === true, + action: ['blur', 'hide', 'dim', 'warn'].includes(value?.action) ? value.action : 'blur', + // Match the current SafeSocial prototype's absolute probability cutoff. + threshold: typeof threshold === 'number' && Number.isFinite(threshold) + ? Math.min(0.99, Math.max(0.5, threshold)) : 0.95, + labels: Object.fromEntries(LABELS.map(label => [label, + typeof value?.labels?.[label] === 'boolean' ? value.labels[label] : DEFAULT_LABELS[label]])), + }; +} + +export function isInstagramUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port + && ['www.instagram.com', 'instagram.com'].includes(url.hostname); + } catch { return false; } +} + +export function isMediaUrl(value) { + try { + if (typeof value !== 'string' || value.length > 8192) return false; + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port + && ['cdninstagram.com', 'fbcdn.net'].some(domain => url.hostname === domain || url.hostname.endsWith(`.${domain}`)); + } catch { return false; } +} + +export function matchingLabels(scores, settings) { + return LABELS.filter(label => settings.labels[label] && Number.isFinite(scores?.[label]) + && scores[label] >= settings.threshold && scores[label] <= 1); +} + +export function centerCrop(width, height, { image_size: size, resize }) { + // Training evaluation resizes the short edge to 256, then center-crops 224. + const crop = Math.min(width, height) * size / resize; + return { x: (width - crop) / 2, y: (height - crop) / 2, size: crop }; +} + +export function normalizedPixels(pixels, { image_size: size, mean, std }) { + const plane = size * size; + const values = new Float32Array(3 * plane); + for (let index = 0; index < plane; index++) { + for (let channel = 0; channel < 3; channel++) { + values[channel * plane + index] = (pixels[index * 4 + channel] / 255 - mean[channel]) / std[channel]; + } + } + return values; +} diff --git a/src/chrome/src/safesocial/content.css b/src/chrome/src/safesocial/content.css new file mode 100644 index 000000000..54495b002 --- /dev/null +++ b/src/chrome/src/safesocial/content.css @@ -0,0 +1,20 @@ +.wb-safesocial-blur { filter: blur(24px) !important; } +.wb-safesocial-hide { opacity: 0 !important; } +.wb-safesocial-dim { opacity: 0.18 !important; } +.wb-safesocial-overlay { + position: fixed !important; z-index: 2147483646 !important; + transform: translate(-50%, -50%) !important; max-width: 90vw; +} +.wb-safesocial-overlay[hidden] { display: none !important; } +.wb-safesocial-overlay button, .wb-safesocial-notice { + font: 13px/1.5 system-ui, sans-serif !important; + color: #fff !important; background: #202833 !important; + border: 1px solid #8995a5 !important; border-radius: 8px !important; + padding: 9px 14px !important; box-shadow: 0 3px 12px #0004 !important; +} +.wb-safesocial-overlay button { cursor: pointer !important; } +.wb-safesocial-overlay button:focus-visible { outline: 3px solid #80c7ff !important; } +.wb-safesocial-notice { + position: fixed !important; bottom: 16px !important; right: 16px !important; + z-index: 2147483646 !important; max-width: min(360px, 85vw) !important; +} diff --git a/src/chrome/src/safesocial/content.js b/src/chrome/src/safesocial/content.js new file mode 100644 index 000000000..a66c8cde7 --- /dev/null +++ b/src/chrome/src/safesocial/content.js @@ -0,0 +1,142 @@ +(() => { + const api = globalThis.browser || chrome; + const KEY = 'safeSocialSettings'; + const SELECTOR = 'article img, main img, article video[poster], main video[poster]'; + const records = new Map(); + const revealed = new WeakMap(); + let enabled = false; + let epoch = 0; + let timer; + let observer; + let busy = false; + let rescan = false; + let strings = { show: 'Show image', filtered: 'SafeSocial', unavailable: 'SafeSocial: classifier unavailable. Open WebBrain settings to retry.' }; + let notice; + const source = media => media.tagName === 'VIDEO' ? media.poster : media.currentSrc || media.src; + + function clear(media, record) { + media.classList.remove('wb-safesocial-blur', 'wb-safesocial-hide', 'wb-safesocial-dim'); + record?.overlay?.remove(); + } + function reset() { + epoch++; + clearTimeout(timer); + observer?.disconnect(); + observer = null; + for (const [media, record] of records) clear(media, record); + records.clear(); + notice?.remove(); notice = null; + } + function visible(media) { + const rect = media.getBoundingClientRect(); + return media.isConnected && rect.width >= 140 && rect.height >= 140 + && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth; + } + function position(media, record) { + if (!record.overlay) return; + const rect = media.getBoundingClientRect(); + record.overlay.hidden = !visible(media); + record.overlay.style.left = `${(Math.max(0, rect.left) + Math.min(rect.right, innerWidth)) / 2}px`; + record.overlay.style.top = `${(Math.max(0, rect.top) + Math.min(rect.bottom, innerHeight)) / 2}px`; + } + function apply(media, record, result) { + if (!result.labels?.length || revealed.get(media) === record.url) return; + if (['blur', 'hide', 'dim'].includes(result.action)) media.classList.add(`wb-safesocial-${result.action}`); + const overlay = document.createElement('div'); + overlay.className = 'wb-safesocial-overlay'; + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = `${strings.filtered} · ${strings.show}`; + button.addEventListener('click', event => { + event.preventDefault(); event.stopPropagation(); + revealed.set(media, record.url); + clear(media, record); + }); + overlay.append(button); + document.body.append(overlay); + record.overlay = overlay; + position(media, record); + } + function showUnavailable() { + if (notice) return; + notice = document.createElement('div'); + notice.className = 'wb-safesocial-notice'; + notice.setAttribute('role', 'status'); + notice.textContent = strings.unavailable; + document.body.append(notice); + } + async function scan() { + if (!enabled || document.hidden) return; + if (busy) { rescan = true; return; } + busy = true; + rescan = false; + const runEpoch = epoch; + try { + for (const [media, record] of records) { + if (!media.isConnected || source(media) !== record.url) { clear(media, record); records.delete(media); } + else position(media, record); + } + // Work only on visible, sufficiently large images and video posters. + for (const media of document.querySelectorAll(SELECTOR)) { + if (!enabled || epoch !== runEpoch || document.hidden) break; + if (!visible(media)) continue; + const url = source(media); + if (!url || revealed.get(media) === url) continue; + const previous = records.get(media); + if (previous?.url === url && (!previous.retryAt || previous.retryAt > Date.now())) continue; + const record = { url }; + records.set(media, record); + let result; + try { result = await api.runtime.sendMessage({ target: 'safesocial', command: 'classify', url }); } + catch { result = { ok: false }; } + if (!enabled || epoch !== runEpoch || !media.isConnected || source(media) !== url) continue; + if (result?.ok) { + notice?.remove(); notice = null; + apply(media, record, result); + } else { + record.retryAt = Date.now() + 30_000; + if (!result?.disabled) showUnavailable(); + } + } + } finally { + busy = false; + if (enabled && (rescan || runEpoch !== epoch)) schedule(); + } + } + function schedule() { + if (!enabled) return; + clearTimeout(timer); + timer = setTimeout(() => { void scan(); }, 200); + } + function configure(value) { + reset(); + enabled = value?.enabled === true; + if (!enabled) return; + observer = new MutationObserver(schedule); + observer.observe(document.documentElement, { + childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset', 'poster'], + }); + schedule(); + } + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes[KEY]) configure(changes[KEY].newValue); + }); + addEventListener('scroll', () => { + for (const [media, record] of records) position(media, record); + schedule(); + }, { passive: true }); + addEventListener('resize', schedule); + document.addEventListener('load', schedule, true); + document.addEventListener('visibilitychange', schedule); + let settingsChanged = false; + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes[KEY]) settingsChanged = true; + }); + api.storage.local.get([KEY, 'wbLocale']).then(stored => { + if (stored.wbLocale === 'tr') strings = { + show: 'Görseli göster', filtered: 'SafeSocial', + unavailable: 'SafeSocial: sınıflandırıcı kullanılamıyor. WebBrain ayarlarından yeniden deneyin.', + }; + if (!settingsChanged) configure(stored[KEY]); + }).catch(() => {}); +})(); diff --git a/src/chrome/src/safesocial/host.js b/src/chrome/src/safesocial/host.js new file mode 100644 index 000000000..058dad015 --- /dev/null +++ b/src/chrome/src/safesocial/host.js @@ -0,0 +1,44 @@ +/** One isolated WASM worker per browser, shared by every Instagram tab. */ +export function createSafeSocialHost({ WorkerClass = globalThis.Worker } = {}) { + let worker; + let nextId = 0; + let state = { status: 'idle', progress: 0 }; + const pending = new Map(); + function stop() { + worker?.terminate(); + worker = null; + for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error('Classifier stopped.')); } + pending.clear(); + state = { status: 'idle', progress: 0 }; + } + function start() { + if (worker) return; + const current = worker = new WorkerClass(new URL('./worker.js', import.meta.url), { type: 'module' }); + current.onmessage = ({ data }) => { + if (worker !== current) return; + if (data.state) { state = data.state; return; } + const item = pending.get(data.id); + if (!item) return; + pending.delete(data.id); + clearTimeout(item.timer); + if (data.error) item.reject(new Error(data.error)); + else item.resolve(data.result); + }; + current.onerror = () => { stop(); state = { status: 'error', progress: 0 }; }; + } + return { + async handle(command, payload = {}) { + if (command === 'status') return { ...state }; + if (command === 'stop') { stop(); return { ...state }; } + if (!['prepare', 'classify'].includes(command)) throw new Error('Unknown classifier command.'); + start(); + if (pending.size >= 32) throw new Error('Classifier is busy.'); + const id = ++nextId; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { stop(); state = { status: 'error', progress: 0 }; }, 180_000); + pending.set(id, { resolve, reject, timer }); + worker.postMessage({ id, command, url: payload.url }); + }); + }, + }; +} diff --git a/src/chrome/src/safesocial/offscreen.js b/src/chrome/src/safesocial/offscreen.js new file mode 100644 index 000000000..331ce7e01 --- /dev/null +++ b/src/chrome/src/safesocial/offscreen.js @@ -0,0 +1,12 @@ +import { createSafeSocialHost } from './host.js'; +const host = createSafeSocialHost(); +chrome.runtime.onMessage.addListener((message, sender, respond) => { + if (message?.target !== 'safesocial-host') return false; + // Content scripts and other extension pages must pass the background gate. + if (sender.id !== chrome.runtime.id || sender.tab || + sender.url !== chrome.runtime.getURL('src/background.js')) return false; + host.handle(message.command, message).then( + result => respond({ ok: true, ...result }), error => respond({ ok: false, error: error.message }), + ); + return true; +}); diff --git a/src/chrome/src/safesocial/worker.js b/src/chrome/src/safesocial/worker.js new file mode 100644 index 000000000..59fe602bb --- /dev/null +++ b/src/chrome/src/safesocial/worker.js @@ -0,0 +1,124 @@ +import { CACHE_NAME, MODEL_BASE, MODEL_FILES, LABELS, isMediaUrl, centerCrop, normalizedPixels } from './config.js'; + +let sessionPromise; +let model; +let ort; +let queue = Promise.resolve(); +let queued = 0; +const report = (status, progress = 0) => self.postMessage({ state: { status, progress } }); + +async function checkedBytes(response, limit, onProgress = () => {}) { + if (!response.ok) throw new Error(`Download failed (${response.status}).`); + if (Number(response.headers.get('content-length')) > limit) throw new Error('File is too large.'); + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) throw new Error('File is too large.'); + chunks.push(value); + onProgress(size); + } + } finally { await reader.cancel().catch(() => {}); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return bytes; +} + +async function modelFile(name) { + const spec = MODEL_FILES[name]; + const url = MODEL_BASE + name; + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(url); + const response = cached || await fetch(url, { credentials: 'omit', signal: AbortSignal.timeout(120_000) }); + const bytes = await checkedBytes(response, spec.bytes, loaded => { + if (!cached && name === 'model.onnx') report('downloading', Math.round(loaded / spec.bytes * 100)); + }); + const hash = [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))] + .map(byte => byte.toString(16).padStart(2, '0')).join(''); + if (bytes.length !== spec.bytes || hash !== spec.sha256) { + await cache.delete(url); + throw new Error('Model verification failed. Retry the download.'); + } + if (!cached) await cache.put(url, new Response(bytes)); + return bytes; +} + +async function prepare() { + if (!sessionPromise) { + sessionPromise = (async () => { + report('downloading'); + model = JSON.parse(new TextDecoder().decode(await modelFile('safesocial-model.json'))); + const bytes = await modelFile('model.onnx'); + report('loading', 100); + // Executable JS/WASM stays packaged; only pinned model data comes from HF. + ort = await import('../../vendor/transformers/ort.webgpu.mjs'); + ort.env.wasm.numThreads = 1; + ort.env.wasm.proxy = false; + ort.env.wasm.wasmPaths = { + mjs: new URL('../../vendor/transformers/ort-wasm-simd-threaded.asyncify.mjs', import.meta.url).href, + wasm: new URL('../../vendor/transformers/ort-wasm-simd-threaded.asyncify.wasm', import.meta.url).href, + }; + const session = await ort.InferenceSession.create(bytes, { executionProviders: ['wasm'] }); + report('ready', 100); + return session; + })().catch(error => { sessionPromise = null; report('error'); throw error; }); + } + return sessionPromise; +} + +async function classify(url) { + if (!isMediaUrl(url)) throw new Error('Unsupported media URL.'); + const session = await prepare(); + // Do not turn the classifier into an arbitrary fetch proxy or follow redirects + // from a permitted CDN to an unrelated host. No page cookies are needed. + const response = await fetch(url, { + credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15_000), + }); + const type = response.headers.get('content-type') || ''; + if (!/^image\/(jpeg|png|webp|avif)(?:;|$)/i.test(type)) throw new Error('Unsupported image format.'); + const bytes = await checkedBytes(response, 12 * 1024 * 1024); + const bitmap = await createImageBitmap(new Blob([bytes], { type })); + let tensor; + let output; + try { + const size = model.preprocessing.image_size; + const canvas = new OffscreenCanvas(size, size); + const context = canvas.getContext('2d', { willReadFrequently: true }); + const crop = centerCrop(bitmap.width, bitmap.height, model.preprocessing); + context.imageSmoothingQuality = 'high'; + context.drawImage(bitmap, crop.x, crop.y, crop.size, crop.size, 0, 0, size, size); + const values = normalizedPixels(context.getImageData(0, 0, size, size).data, model.preprocessing); + tensor = new ort.Tensor('float32', values, [1, 3, size, size]); + output = await session.run({ [model.input_name]: tensor }); + const probabilities = output[model.output_name]?.data; + if (probabilities?.length !== model.labels.length) throw new Error('Unexpected classifier output.'); + const scores = Object.fromEntries(model.labels.map((label, index) => [label, Number(probabilities[index])])); + if (LABELS.some(label => !Number.isFinite(scores[label]) || scores[label] < 0 || scores[label] > 1)) { + throw new Error('Invalid classifier scores.'); + } + return { scores }; + } finally { + bitmap.close(); + tensor?.dispose(); + for (const value of Object.values(output || {})) value.dispose(); + } +} + +self.onmessage = ({ data }) => { + const { id, command, url } = data; + if (queued >= 32) { self.postMessage({ id, error: 'Classifier is busy. Try again shortly.' }); return; } + queued++; + queue = queue.then(async () => { + try { + const result = command === 'prepare' ? (await prepare(), {}) + : command === 'classify' ? await classify(url) : (() => { throw new Error('Unknown classifier command.'); })(); + self.postMessage({ id, result }); + } catch (error) { self.postMessage({ id, error: error.message }); } + finally { queued--; } + }); +}; diff --git a/src/chrome/src/ui/i18n.js b/src/chrome/src/ui/i18n.js index b7cb6dee2..011799a83 100644 --- a/src/chrome/src/ui/i18n.js +++ b/src/chrome/src/ui/i18n.js @@ -3,6 +3,7 @@ // Works identically in Chrome MV3 and Firefox MV2. import en from './locales/en.js'; +import { safeSocialEnglish, safeSocialTranslations } from './locales/safesocial-copy.mjs'; import es from './locales/es.js'; import fr from './locales/fr.js'; import tr from './locales/tr.js'; @@ -33,6 +34,8 @@ const DICTS = Object.fromEntries(Object.entries({ en, es, fr, tr, zh, ru, uk, ar ...dict, ...providerGuideEnglish, ...pdfViewerEnglish, + ...safeSocialEnglish, + ...(safeSocialTranslations[code] || {}), ...(providerGuideTranslations[code] || {}), }])); const LS_KEY = 'wbLocale'; diff --git a/src/chrome/src/ui/locales/safesocial-copy.mjs b/src/chrome/src/ui/locales/safesocial-copy.mjs new file mode 100644 index 000000000..c5bd4f3b6 --- /dev/null +++ b/src/chrome/src/ui/locales/safesocial-copy.mjs @@ -0,0 +1,66 @@ +export const safeSocialEnglish = { + "st.safesocial.heading": "SafeSocial · Image classifier", + "st.safesocial.enable": "Filter Instagram images", + "st.safesocial.desc": "A local classifier for social comparison themes. Choose which categories to soften in your feed.", + "st.safesocial.download": "Off by default. Enabling downloads a 13.6 MB model from Hugging Face once and keeps it in this browser. Images are classified on your device.", + "st.safesocial.model": "EfficientNet-Lite0 · View model on Hugging Face", + "st.safesocial.action": "When an image matches", + "st.safesocial.blur": "Blur", + "st.safesocial.hide": "Hide image", + "st.safesocial.dim": "Dim", + "st.safesocial.warn": "Show warning", + "st.safesocial.threshold": "Score threshold", + "st.safesocial.threshold_desc": "Higher values filter fewer images. Scores are model estimates; they are not a safety guarantee.", + "st.safesocial.categories": "Categories to filter", + "st.safesocial.scope": "Experimental · Instagram images and video cover images only. You can reveal any filtered image. Settings save automatically.", + "st.safesocial.disabled": "Off · No model download or classification. Cached files are kept until you remove them.", + "st.safesocial.idle": "Waiting for an image. Use Retry to prepare the classifier now.", + "st.safesocial.downloading": "Downloading model · {progress}%", + "st.safesocial.loading": "Preparing local classifier…", + "st.safesocial.ready": "Ready · Classification runs locally.", + "st.safesocial.error": "Classifier unavailable. Check your connection and retry.", + "st.safesocial.retry": "Retry / prepare model", + "st.safesocial.remove": "Remove downloaded model", + "st.safesocial.removed": "Downloaded model removed.", + "st.safesocial.label.romance_jealousy": "Romance & jealousy", + "st.safesocial.label.social_fomo": "Social FOMO", + "st.safesocial.label.luxury_status": "Luxury & status", + "st.safesocial.label.travel_lifestyle": "Travel & lifestyle", + "st.safesocial.label.body_beauty_comparison": "Body & beauty comparison", + "st.safesocial.label.achievement_status": "Achievement & success", + "st.safesocial.label.social_proof_popularity": "Popularity", + "st.safesocial.label.exclusivity_access": "Exclusivity & access" +}; +export const safeSocialTranslations = { tr: { + "st.safesocial.heading": "SafeSocial · Görsel sınıflandırıcı", + "st.safesocial.enable": "Instagram görsellerini filtrele", + "st.safesocial.desc": "Sosyal karşılaştırma temalarını cihazınızda sınıflandırır. Akışınızda azaltmak istediğiniz kategorileri seçin.", + "st.safesocial.download": "Varsayılan olarak kapalıdır. Açıldığında Hugging Face’ten 13,6 MB model bir kez indirilir ve bu tarayıcıda saklanır. Görseller cihazınızda sınıflandırılır.", + "st.safesocial.model": "EfficientNet-Lite0 · Modeli Hugging Face’te incele", + "st.safesocial.action": "Görsel eşleştiğinde", + "st.safesocial.blur": "Bulanıklaştır", + "st.safesocial.hide": "Görseli gizle", + "st.safesocial.dim": "Soluklaştır", + "st.safesocial.warn": "Uyarı göster", + "st.safesocial.threshold": "Skor eşiği", + "st.safesocial.threshold_desc": "Yüksek değerler daha az görseli filtreler. Skorlar model tahminidir; güvenlik garantisi değildir.", + "st.safesocial.categories": "Filtrelenecek kategoriler", + "st.safesocial.scope": "Deneysel · Yalnızca Instagram görselleri ve video kapakları. Filtrelenen her görseli tekrar açabilirsiniz. Ayarlar otomatik kaydedilir.", + "st.safesocial.disabled": "Kapalı · Model indirilmez ve sınıflandırma yapılmaz. İndirilen dosyalar siz kaldırana kadar saklanır.", + "st.safesocial.idle": "Görsel bekleniyor. Modeli şimdi hazırlamak için Yeniden dene’ye basın.", + "st.safesocial.downloading": "Model indiriliyor · %{progress}", + "st.safesocial.loading": "Yerel sınıflandırıcı hazırlanıyor…", + "st.safesocial.ready": "Hazır · Sınıflandırma cihazınızda yapılır.", + "st.safesocial.error": "Sınıflandırıcı kullanılamıyor. Bağlantınızı kontrol edip yeniden deneyin.", + "st.safesocial.retry": "Yeniden dene / modeli hazırla", + "st.safesocial.remove": "İndirilen modeli kaldır", + "st.safesocial.removed": "İndirilen model kaldırıldı.", + "st.safesocial.label.romance_jealousy": "Romantizm ve kıskançlık", + "st.safesocial.label.social_fomo": "Sosyal ortamları kaçırma kaygısı", + "st.safesocial.label.luxury_status": "Lüks ve statü", + "st.safesocial.label.travel_lifestyle": "Seyahat ve yaşam tarzı", + "st.safesocial.label.body_beauty_comparison": "Beden ve güzellik karşılaştırması", + "st.safesocial.label.achievement_status": "Başarı", + "st.safesocial.label.social_proof_popularity": "Popülerlik", + "st.safesocial.label.exclusivity_access": "Ayrıcalık ve özel erişim" +} }; diff --git a/src/chrome/src/ui/safesocial-settings.js b/src/chrome/src/ui/safesocial-settings.js new file mode 100644 index 000000000..097b85445 --- /dev/null +++ b/src/chrome/src/ui/safesocial-settings.js @@ -0,0 +1,96 @@ +import { t } from './i18n.js'; +import { LABELS, SETTINGS_KEY, normalizeSettings } from '../safesocial/config.js'; + +const api = globalThis.browser || chrome; +const card = document.getElementById('safesocial-card'); +const toggle = card.querySelector('#safesocial-enabled'); +const action = card.querySelector('#safesocial-action'); +const threshold = card.querySelector('#safesocial-threshold'); +const thresholdValue = card.querySelector('output'); +const status = card.querySelector('#safesocial-status'); +const retry = card.querySelector('#safesocial-retry'); +const remove = card.querySelector('#safesocial-remove'); +let settings = normalizeSettings(); +let requestEpoch = 0; +let polling; +const labels = card.querySelector('#safesocial-labels'); +for (const name of LABELS) { + const label = document.createElement('label'); + label.className = 'safesocial-label'; + const input = document.createElement('input'); + input.type = 'checkbox'; input.name = name; + const text = document.createElement('span'); + text.dataset.i18n = `st.safesocial.label.${name}`; + text.textContent = t(text.dataset.i18n); + label.append(input, text); labels.append(label); +} +const send = command => api.runtime.sendMessage({ target: 'safesocial', command }); +function render() { + toggle.checked = settings.enabled; + action.value = settings.action; + threshold.value = Math.round(settings.threshold * 100); + thresholdValue.textContent = `${threshold.value}%`; + for (const input of labels.querySelectorAll('input')) input.checked = settings.labels[input.name]; + remove.disabled = settings.enabled; + retry.hidden = !settings.enabled; +} +function showState(value) { + if (!settings.enabled) { status.textContent = t('st.safesocial.disabled'); return; } + const name = ['downloading', 'loading', 'ready', 'error'].includes(value?.status) ? value.status : 'idle'; + status.textContent = t(`st.safesocial.${name}`, { progress: value?.progress || 0 }); +} +async function poll() { + const epoch = requestEpoch; + try { const result = await send('status'); if (epoch === requestEpoch) showState(result); } catch { /* retry reports failures */ } +} +async function prepare() { + const epoch = ++requestEpoch; + clearInterval(polling); + if (!settings.enabled) { showState(); return; } + showState({ status: 'loading' }); + polling = setInterval(poll, 1000); + try { + const result = await send('prepare'); + if (epoch !== requestEpoch) return; + if (!result?.ok) throw new Error(result?.error || t('st.safesocial.error')); + showState({ status: 'ready' }); + } catch (error) { + if (epoch === requestEpoch) status.textContent = `${t('st.safesocial.error')} ${error.message}`; + } finally { + if (epoch === requestEpoch) clearInterval(polling); + } +} +let saveQueue = Promise.resolve(); +function save() { + const next = normalizeSettings({ + enabled: toggle.checked, action: action.value, threshold: Number(threshold.value) / 100, + labels: Object.fromEntries([...labels.querySelectorAll('input')].map(input => [input.name, input.checked])), + }); + saveQueue = saveQueue.catch(() => {}).then(() => api.storage.local.set({ [SETTINGS_KEY]: next })) + .catch(error => { status.textContent = error.message; }); +} +for (const input of [toggle, action, threshold, ...labels.querySelectorAll('input')]) input.addEventListener('change', save); +threshold.addEventListener('input', () => { thresholdValue.textContent = `${threshold.value}%`; }); +retry.addEventListener('click', prepare); +remove.addEventListener('click', async () => { + remove.disabled = true; + try { + const result = await send('remove'); + status.textContent = result?.ok ? t('st.safesocial.removed') : t('st.safesocial.error'); + } catch { status.textContent = t('st.safesocial.error'); } + finally { remove.disabled = settings.enabled; } +}); +let changed = false; +api.storage.onChanged.addListener((changes, area) => { + if (area !== 'local' || !changes[SETTINGS_KEY]) return; + changed = true; + settings = normalizeSettings(changes[SETTINGS_KEY].newValue); + render(); void prepare(); +}); +api.storage.local.get(SETTINGS_KEY).then(stored => { + if (changed) return; + settings = normalizeSettings(stored[SETTINGS_KEY]); render(); + // Opening settings is read-only. A prior opt-in can load on the next image. + if (settings.enabled) void poll(); else showState(); +}).catch(error => { status.textContent = error.message; }); +addEventListener('pagehide', () => { requestEpoch++; clearInterval(polling); }); diff --git a/src/chrome/src/ui/settings.html b/src/chrome/src/ui/settings.html index 161d6f31b..f3f40e4fd 100644 --- a/src/chrome/src/ui/settings.html +++ b/src/chrome/src/ui/settings.html @@ -1378,6 +1378,11 @@ :root[data-theme="light"] .btn-secondary:hover { background: rgba(89,55,25,0.06); } :root[data-theme="light"] .btn-sign-out:hover { background: rgba(89,55,25,0.06); } :root[data-theme="light"] .info-box code { background: rgba(89,55,25,0.08); } + .safesocial-labels-fieldset { border: 0; padding: 0; margin: 18px 0; min-width: 0; } + #safesocial-labels { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin-top: 10px; } + #safesocial-labels .safesocial-label { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text); } + #safesocial-labels input { width: auto; margin: 0; flex-shrink: 0; } + #safesocial-status { margin-top: 12px; } @@ -2055,6 +2060,49 @@

+ +

+
+
+
+
+
+
+ +
+

+

+
+ + +
+
+ +
+ + 95% +
+
+
+
+ +
+
+

+
+
+ + +
+
@@ -2269,5 +2317,6 @@

+ diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json index ed6607ed0..aee9c1e33 100644 --- a/src/firefox/manifest.json +++ b/src/firefox/manifest.json @@ -77,6 +77,19 @@ ], "js": ["src/agent/smd-loader.js"], "run_at": "document_start" + }, + { + "matches": [ + "https://www.instagram.com/*", + "https://instagram.com/*" + ], + "js": [ + "src/safesocial/content.js" + ], + "css": [ + "src/safesocial/content.css" + ], + "run_at": "document_idle" } ], "options_ui": { diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index bfd87d27a..13e821364 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -1,3 +1,5 @@ +import { installSafeSocialBackground } from './safesocial/background.js'; +import { createSafeSocialHost } from './safesocial/host.js'; import { firefoxBidi } from './bidi/client.js'; import { ProviderManager } from './providers/manager.js'; import { Agent } from './agent/agent.js'; @@ -108,6 +110,9 @@ import { * Routes messages between sidebar, content scripts, and the agent. */ +const safeSocialHost = createSafeSocialHost(); +installSafeSocialBackground(browser, (command, payload) => safeSocialHost.handle(command, payload)); + const providerManager = new ProviderManager(); const apocalypseController = createApocalypseController(browser); let emergencyDownloads = null; diff --git a/src/firefox/src/config-transfer.js b/src/firefox/src/config-transfer.js index d0c90d260..1aac9a21b 100644 --- a/src/firefox/src/config-transfer.js +++ b/src/firefox/src/config-transfer.js @@ -11,6 +11,7 @@ import { USER_MEMORY_STORAGE_KEY, } from './agent/user-memory.js'; import { AUTO_GROUP_TABS_KEY } from './tab-group-preference.js'; +import { normalizeSettings as normalizeSafeSocialSettings, SETTINGS_KEY as SAFE_SOCIAL_SETTINGS_KEY } from './safesocial/config.js'; import { normalizeUiScale, UI_SCALE_STORAGE_KEY } from './ui/ui-scale.js'; export const CONFIG_SCHEMA = 'webbrain-config/1'; @@ -85,6 +86,7 @@ export const DEFAULT_CONFIG_SETTINGS = Object.freeze({ systemOneWatchThreshold: 0.7, systemOneCompletionThreshold: 0.7, typesafeApiKey: '', + [SAFE_SOCIAL_SETTINGS_KEY]: normalizeSafeSocialSettings(), }); export const CONFIG_STORAGE_KEYS = Object.freeze(Object.keys(DEFAULT_CONFIG_SETTINGS)); @@ -190,7 +192,7 @@ function validSettingValue(key, value) { if (STRING_KEYS.has(key)) return typeof value === 'string'; if (ARRAY_KEYS.has(key)) return Array.isArray(value); if (NULLABLE_OBJECT_KEYS.has(key)) return value === null || isPlainObject(value); - if (key === 'providers' || key === USER_MEMORY_STORAGE_KEY) return isPlainObject(value); + if (key === 'providers' || key === USER_MEMORY_STORAGE_KEY || key === SAFE_SOCIAL_SETTINGS_KEY) return isPlainObject(value); return true; } @@ -208,6 +210,14 @@ function normalizeSettings(source, { strict = false } = {}) { settings[key] = normalizeUiScale(value); continue; } + if (key === SAFE_SOCIAL_SETTINGS_KEY) { + if (!isPlainObject(value)) { + if (strict) throw new Error(`Invalid value for configuration setting "${key}".`); + continue; + } + settings[key] = normalizeSafeSocialSettings(value); + continue; + } if (!validSettingValue(key, value)) { if (strict) throw new Error(`Invalid value for configuration setting "${key}".`); continue; @@ -287,6 +297,8 @@ export function parseConfigPatchImport(json) { } settings[key] = key === UI_SCALE_STORAGE_KEY ? normalizeUiScale(value) + : key === SAFE_SOCIAL_SETTINGS_KEY + ? normalizeSafeSocialSettings(value) : key === 'providers' ? sanitizeProviders(value, { strict: true }) : clone(value); diff --git a/src/firefox/src/safesocial/background.js b/src/firefox/src/safesocial/background.js new file mode 100644 index 000000000..a4507a45f --- /dev/null +++ b/src/firefox/src/safesocial/background.js @@ -0,0 +1,53 @@ +import { SETTINGS_KEY, CACHE_NAME, normalizeSettings, isInstagramUrl, isMediaUrl, matchingLabels } from './config.js'; + +export function installSafeSocialBackground(api, runHost) { + let generation = 0; + let transition = Promise.resolve(); + const settingsUrl = api.runtime.getURL('src/ui/settings.html'); + api.storage.onChanged.addListener((changes, area) => { + if (area !== 'local' || !changes[SETTINGS_KEY]) return; + generation++; + // Serialize stop against a rapid off/on toggle and pending host creation. + if (changes[SETTINGS_KEY].newValue?.enabled !== true) { + transition = transition.then(() => runHost('stop')).catch(() => {}); + } + }); + async function handle(message, sender) { + if (sender.id !== api.runtime.id) throw new Error('Invalid classifier caller.'); + const trustedSettings = String(sender.url || '').split(/[?#]/)[0] === settingsUrl; + if (message.command === 'classify') { + if (!sender.tab || sender.frameId !== 0 || !isInstagramUrl(sender.url) || !isMediaUrl(message.url)) { + throw new Error('Classifier is limited to Instagram images.'); + } + } else if (!trustedSettings || !['prepare', 'status', 'remove'].includes(message.command)) { + throw new Error('Open SafeSocial settings to manage the classifier.'); + } + await transition; + const epoch = generation; + const settings = normalizeSettings((await api.storage.local.get(SETTINGS_KEY))[SETTINGS_KEY]); + if (message.command === 'remove') { + if (settings.enabled) throw new Error('Disable SafeSocial before removing its model.'); + if (epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + // A new enable/prepare must wait until deletion has completed. + const removal = transition.then(async () => { + await runHost('stop'); + await caches.delete(CACHE_NAME); + }); + transition = removal.catch(() => {}); + await removal; + return { ok: true, status: 'idle' }; + } + if (!settings.enabled || epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + const result = await runHost(message.command, { url: message.url }); + // A settings change or disabling the feature invalidates every queued result. + if (epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + return { ok: true, ...result, ...(message.command === 'classify' + ? { labels: matchingLabels(result.scores, settings), action: settings.action } : {}) }; + } + api.runtime.onMessage.addListener((message, sender, respond) => { + if (message?.target !== 'safesocial') return false; + handle(message, sender).then(respond, error => respond({ ok: false, error: error.message })); + return true; + }); + return { handle }; +} diff --git a/src/firefox/src/safesocial/config.js b/src/firefox/src/safesocial/config.js new file mode 100644 index 000000000..4f066cb7b --- /dev/null +++ b/src/firefox/src/safesocial/config.js @@ -0,0 +1,68 @@ +/** SafeSocial is an optional, local multilabel image classifier, not a chat provider. */ +export const SETTINGS_KEY = 'safeSocialSettings'; +export const CACHE_NAME = 'webbrain-safesocial-v1'; +export const MODEL_ID = 'webbrain-one/safesocial-trigger-classifier-efficientnet-lite0'; +export const MODEL_REVISION = 'd39182d06486b237ba33bc675b9302a206182460'; +export const MODEL_BASE = `https://huggingface.co/${MODEL_ID}/resolve/${MODEL_REVISION}/`; +export const MODEL_FILES = Object.freeze({ + 'safesocial-model.json': { bytes: 6260, sha256: '650152199b9b17426f2c40b03d237c3c6bed88101c069da0f38444ef563cee14' }, + 'model.onnx': { bytes: 13584716, sha256: 'fd37c1cd4aafa2bc318b7d29725fa6be3931d09cc1723bb30be3ee819ee22933' }, +}); +export const LABELS = Object.freeze([ + 'romance_jealousy', 'social_fomo', 'luxury_status', 'travel_lifestyle', + 'body_beauty_comparison', 'achievement_status', 'social_proof_popularity', 'exclusivity_access', +]); +export const DEFAULT_LABELS = Object.freeze(Object.fromEntries(LABELS.map(label => [label, + ['romance_jealousy', 'luxury_status', 'travel_lifestyle'].includes(label)]))); + +export function normalizeSettings(value = {}) { + const threshold = value?.threshold; + return { + enabled: value?.enabled === true, + action: ['blur', 'hide', 'dim', 'warn'].includes(value?.action) ? value.action : 'blur', + // Match the current SafeSocial prototype's absolute probability cutoff. + threshold: typeof threshold === 'number' && Number.isFinite(threshold) + ? Math.min(0.99, Math.max(0.5, threshold)) : 0.95, + labels: Object.fromEntries(LABELS.map(label => [label, + typeof value?.labels?.[label] === 'boolean' ? value.labels[label] : DEFAULT_LABELS[label]])), + }; +} + +export function isInstagramUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port + && ['www.instagram.com', 'instagram.com'].includes(url.hostname); + } catch { return false; } +} + +export function isMediaUrl(value) { + try { + if (typeof value !== 'string' || value.length > 8192) return false; + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port + && ['cdninstagram.com', 'fbcdn.net'].some(domain => url.hostname === domain || url.hostname.endsWith(`.${domain}`)); + } catch { return false; } +} + +export function matchingLabels(scores, settings) { + return LABELS.filter(label => settings.labels[label] && Number.isFinite(scores?.[label]) + && scores[label] >= settings.threshold && scores[label] <= 1); +} + +export function centerCrop(width, height, { image_size: size, resize }) { + // Training evaluation resizes the short edge to 256, then center-crops 224. + const crop = Math.min(width, height) * size / resize; + return { x: (width - crop) / 2, y: (height - crop) / 2, size: crop }; +} + +export function normalizedPixels(pixels, { image_size: size, mean, std }) { + const plane = size * size; + const values = new Float32Array(3 * plane); + for (let index = 0; index < plane; index++) { + for (let channel = 0; channel < 3; channel++) { + values[channel * plane + index] = (pixels[index * 4 + channel] / 255 - mean[channel]) / std[channel]; + } + } + return values; +} diff --git a/src/firefox/src/safesocial/content.css b/src/firefox/src/safesocial/content.css new file mode 100644 index 000000000..54495b002 --- /dev/null +++ b/src/firefox/src/safesocial/content.css @@ -0,0 +1,20 @@ +.wb-safesocial-blur { filter: blur(24px) !important; } +.wb-safesocial-hide { opacity: 0 !important; } +.wb-safesocial-dim { opacity: 0.18 !important; } +.wb-safesocial-overlay { + position: fixed !important; z-index: 2147483646 !important; + transform: translate(-50%, -50%) !important; max-width: 90vw; +} +.wb-safesocial-overlay[hidden] { display: none !important; } +.wb-safesocial-overlay button, .wb-safesocial-notice { + font: 13px/1.5 system-ui, sans-serif !important; + color: #fff !important; background: #202833 !important; + border: 1px solid #8995a5 !important; border-radius: 8px !important; + padding: 9px 14px !important; box-shadow: 0 3px 12px #0004 !important; +} +.wb-safesocial-overlay button { cursor: pointer !important; } +.wb-safesocial-overlay button:focus-visible { outline: 3px solid #80c7ff !important; } +.wb-safesocial-notice { + position: fixed !important; bottom: 16px !important; right: 16px !important; + z-index: 2147483646 !important; max-width: min(360px, 85vw) !important; +} diff --git a/src/firefox/src/safesocial/content.js b/src/firefox/src/safesocial/content.js new file mode 100644 index 000000000..a66c8cde7 --- /dev/null +++ b/src/firefox/src/safesocial/content.js @@ -0,0 +1,142 @@ +(() => { + const api = globalThis.browser || chrome; + const KEY = 'safeSocialSettings'; + const SELECTOR = 'article img, main img, article video[poster], main video[poster]'; + const records = new Map(); + const revealed = new WeakMap(); + let enabled = false; + let epoch = 0; + let timer; + let observer; + let busy = false; + let rescan = false; + let strings = { show: 'Show image', filtered: 'SafeSocial', unavailable: 'SafeSocial: classifier unavailable. Open WebBrain settings to retry.' }; + let notice; + const source = media => media.tagName === 'VIDEO' ? media.poster : media.currentSrc || media.src; + + function clear(media, record) { + media.classList.remove('wb-safesocial-blur', 'wb-safesocial-hide', 'wb-safesocial-dim'); + record?.overlay?.remove(); + } + function reset() { + epoch++; + clearTimeout(timer); + observer?.disconnect(); + observer = null; + for (const [media, record] of records) clear(media, record); + records.clear(); + notice?.remove(); notice = null; + } + function visible(media) { + const rect = media.getBoundingClientRect(); + return media.isConnected && rect.width >= 140 && rect.height >= 140 + && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth; + } + function position(media, record) { + if (!record.overlay) return; + const rect = media.getBoundingClientRect(); + record.overlay.hidden = !visible(media); + record.overlay.style.left = `${(Math.max(0, rect.left) + Math.min(rect.right, innerWidth)) / 2}px`; + record.overlay.style.top = `${(Math.max(0, rect.top) + Math.min(rect.bottom, innerHeight)) / 2}px`; + } + function apply(media, record, result) { + if (!result.labels?.length || revealed.get(media) === record.url) return; + if (['blur', 'hide', 'dim'].includes(result.action)) media.classList.add(`wb-safesocial-${result.action}`); + const overlay = document.createElement('div'); + overlay.className = 'wb-safesocial-overlay'; + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = `${strings.filtered} · ${strings.show}`; + button.addEventListener('click', event => { + event.preventDefault(); event.stopPropagation(); + revealed.set(media, record.url); + clear(media, record); + }); + overlay.append(button); + document.body.append(overlay); + record.overlay = overlay; + position(media, record); + } + function showUnavailable() { + if (notice) return; + notice = document.createElement('div'); + notice.className = 'wb-safesocial-notice'; + notice.setAttribute('role', 'status'); + notice.textContent = strings.unavailable; + document.body.append(notice); + } + async function scan() { + if (!enabled || document.hidden) return; + if (busy) { rescan = true; return; } + busy = true; + rescan = false; + const runEpoch = epoch; + try { + for (const [media, record] of records) { + if (!media.isConnected || source(media) !== record.url) { clear(media, record); records.delete(media); } + else position(media, record); + } + // Work only on visible, sufficiently large images and video posters. + for (const media of document.querySelectorAll(SELECTOR)) { + if (!enabled || epoch !== runEpoch || document.hidden) break; + if (!visible(media)) continue; + const url = source(media); + if (!url || revealed.get(media) === url) continue; + const previous = records.get(media); + if (previous?.url === url && (!previous.retryAt || previous.retryAt > Date.now())) continue; + const record = { url }; + records.set(media, record); + let result; + try { result = await api.runtime.sendMessage({ target: 'safesocial', command: 'classify', url }); } + catch { result = { ok: false }; } + if (!enabled || epoch !== runEpoch || !media.isConnected || source(media) !== url) continue; + if (result?.ok) { + notice?.remove(); notice = null; + apply(media, record, result); + } else { + record.retryAt = Date.now() + 30_000; + if (!result?.disabled) showUnavailable(); + } + } + } finally { + busy = false; + if (enabled && (rescan || runEpoch !== epoch)) schedule(); + } + } + function schedule() { + if (!enabled) return; + clearTimeout(timer); + timer = setTimeout(() => { void scan(); }, 200); + } + function configure(value) { + reset(); + enabled = value?.enabled === true; + if (!enabled) return; + observer = new MutationObserver(schedule); + observer.observe(document.documentElement, { + childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset', 'poster'], + }); + schedule(); + } + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes[KEY]) configure(changes[KEY].newValue); + }); + addEventListener('scroll', () => { + for (const [media, record] of records) position(media, record); + schedule(); + }, { passive: true }); + addEventListener('resize', schedule); + document.addEventListener('load', schedule, true); + document.addEventListener('visibilitychange', schedule); + let settingsChanged = false; + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes[KEY]) settingsChanged = true; + }); + api.storage.local.get([KEY, 'wbLocale']).then(stored => { + if (stored.wbLocale === 'tr') strings = { + show: 'Görseli göster', filtered: 'SafeSocial', + unavailable: 'SafeSocial: sınıflandırıcı kullanılamıyor. WebBrain ayarlarından yeniden deneyin.', + }; + if (!settingsChanged) configure(stored[KEY]); + }).catch(() => {}); +})(); diff --git a/src/firefox/src/safesocial/host.js b/src/firefox/src/safesocial/host.js new file mode 100644 index 000000000..058dad015 --- /dev/null +++ b/src/firefox/src/safesocial/host.js @@ -0,0 +1,44 @@ +/** One isolated WASM worker per browser, shared by every Instagram tab. */ +export function createSafeSocialHost({ WorkerClass = globalThis.Worker } = {}) { + let worker; + let nextId = 0; + let state = { status: 'idle', progress: 0 }; + const pending = new Map(); + function stop() { + worker?.terminate(); + worker = null; + for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error('Classifier stopped.')); } + pending.clear(); + state = { status: 'idle', progress: 0 }; + } + function start() { + if (worker) return; + const current = worker = new WorkerClass(new URL('./worker.js', import.meta.url), { type: 'module' }); + current.onmessage = ({ data }) => { + if (worker !== current) return; + if (data.state) { state = data.state; return; } + const item = pending.get(data.id); + if (!item) return; + pending.delete(data.id); + clearTimeout(item.timer); + if (data.error) item.reject(new Error(data.error)); + else item.resolve(data.result); + }; + current.onerror = () => { stop(); state = { status: 'error', progress: 0 }; }; + } + return { + async handle(command, payload = {}) { + if (command === 'status') return { ...state }; + if (command === 'stop') { stop(); return { ...state }; } + if (!['prepare', 'classify'].includes(command)) throw new Error('Unknown classifier command.'); + start(); + if (pending.size >= 32) throw new Error('Classifier is busy.'); + const id = ++nextId; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { stop(); state = { status: 'error', progress: 0 }; }, 180_000); + pending.set(id, { resolve, reject, timer }); + worker.postMessage({ id, command, url: payload.url }); + }); + }, + }; +} diff --git a/src/firefox/src/safesocial/worker.js b/src/firefox/src/safesocial/worker.js new file mode 100644 index 000000000..59fe602bb --- /dev/null +++ b/src/firefox/src/safesocial/worker.js @@ -0,0 +1,124 @@ +import { CACHE_NAME, MODEL_BASE, MODEL_FILES, LABELS, isMediaUrl, centerCrop, normalizedPixels } from './config.js'; + +let sessionPromise; +let model; +let ort; +let queue = Promise.resolve(); +let queued = 0; +const report = (status, progress = 0) => self.postMessage({ state: { status, progress } }); + +async function checkedBytes(response, limit, onProgress = () => {}) { + if (!response.ok) throw new Error(`Download failed (${response.status}).`); + if (Number(response.headers.get('content-length')) > limit) throw new Error('File is too large.'); + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) throw new Error('File is too large.'); + chunks.push(value); + onProgress(size); + } + } finally { await reader.cancel().catch(() => {}); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return bytes; +} + +async function modelFile(name) { + const spec = MODEL_FILES[name]; + const url = MODEL_BASE + name; + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(url); + const response = cached || await fetch(url, { credentials: 'omit', signal: AbortSignal.timeout(120_000) }); + const bytes = await checkedBytes(response, spec.bytes, loaded => { + if (!cached && name === 'model.onnx') report('downloading', Math.round(loaded / spec.bytes * 100)); + }); + const hash = [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))] + .map(byte => byte.toString(16).padStart(2, '0')).join(''); + if (bytes.length !== spec.bytes || hash !== spec.sha256) { + await cache.delete(url); + throw new Error('Model verification failed. Retry the download.'); + } + if (!cached) await cache.put(url, new Response(bytes)); + return bytes; +} + +async function prepare() { + if (!sessionPromise) { + sessionPromise = (async () => { + report('downloading'); + model = JSON.parse(new TextDecoder().decode(await modelFile('safesocial-model.json'))); + const bytes = await modelFile('model.onnx'); + report('loading', 100); + // Executable JS/WASM stays packaged; only pinned model data comes from HF. + ort = await import('../../vendor/transformers/ort.webgpu.mjs'); + ort.env.wasm.numThreads = 1; + ort.env.wasm.proxy = false; + ort.env.wasm.wasmPaths = { + mjs: new URL('../../vendor/transformers/ort-wasm-simd-threaded.asyncify.mjs', import.meta.url).href, + wasm: new URL('../../vendor/transformers/ort-wasm-simd-threaded.asyncify.wasm', import.meta.url).href, + }; + const session = await ort.InferenceSession.create(bytes, { executionProviders: ['wasm'] }); + report('ready', 100); + return session; + })().catch(error => { sessionPromise = null; report('error'); throw error; }); + } + return sessionPromise; +} + +async function classify(url) { + if (!isMediaUrl(url)) throw new Error('Unsupported media URL.'); + const session = await prepare(); + // Do not turn the classifier into an arbitrary fetch proxy or follow redirects + // from a permitted CDN to an unrelated host. No page cookies are needed. + const response = await fetch(url, { + credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15_000), + }); + const type = response.headers.get('content-type') || ''; + if (!/^image\/(jpeg|png|webp|avif)(?:;|$)/i.test(type)) throw new Error('Unsupported image format.'); + const bytes = await checkedBytes(response, 12 * 1024 * 1024); + const bitmap = await createImageBitmap(new Blob([bytes], { type })); + let tensor; + let output; + try { + const size = model.preprocessing.image_size; + const canvas = new OffscreenCanvas(size, size); + const context = canvas.getContext('2d', { willReadFrequently: true }); + const crop = centerCrop(bitmap.width, bitmap.height, model.preprocessing); + context.imageSmoothingQuality = 'high'; + context.drawImage(bitmap, crop.x, crop.y, crop.size, crop.size, 0, 0, size, size); + const values = normalizedPixels(context.getImageData(0, 0, size, size).data, model.preprocessing); + tensor = new ort.Tensor('float32', values, [1, 3, size, size]); + output = await session.run({ [model.input_name]: tensor }); + const probabilities = output[model.output_name]?.data; + if (probabilities?.length !== model.labels.length) throw new Error('Unexpected classifier output.'); + const scores = Object.fromEntries(model.labels.map((label, index) => [label, Number(probabilities[index])])); + if (LABELS.some(label => !Number.isFinite(scores[label]) || scores[label] < 0 || scores[label] > 1)) { + throw new Error('Invalid classifier scores.'); + } + return { scores }; + } finally { + bitmap.close(); + tensor?.dispose(); + for (const value of Object.values(output || {})) value.dispose(); + } +} + +self.onmessage = ({ data }) => { + const { id, command, url } = data; + if (queued >= 32) { self.postMessage({ id, error: 'Classifier is busy. Try again shortly.' }); return; } + queued++; + queue = queue.then(async () => { + try { + const result = command === 'prepare' ? (await prepare(), {}) + : command === 'classify' ? await classify(url) : (() => { throw new Error('Unknown classifier command.'); })(); + self.postMessage({ id, result }); + } catch (error) { self.postMessage({ id, error: error.message }); } + finally { queued--; } + }); +}; diff --git a/src/firefox/src/ui/i18n.js b/src/firefox/src/ui/i18n.js index 3fcfdff79..2851cdfa8 100644 --- a/src/firefox/src/ui/i18n.js +++ b/src/firefox/src/ui/i18n.js @@ -3,6 +3,7 @@ // Works identically in Chrome MV3 and Firefox MV2. import en from './locales/en.js'; +import { safeSocialEnglish, safeSocialTranslations } from './locales/safesocial-copy.mjs'; import es from './locales/es.js'; import fr from './locales/fr.js'; import tr from './locales/tr.js'; @@ -31,6 +32,8 @@ const DICTS = Object.fromEntries(Object.entries({ en, es, fr, tr, zh, ru, uk, ar .map(([code, dict]) => [code, { ...dict, ...providerGuideEnglish, + ...safeSocialEnglish, + ...(safeSocialTranslations[code] || {}), ...(providerGuideTranslations[code] || {}), }])); const LS_KEY = 'wbLocale'; diff --git a/src/firefox/src/ui/locales/safesocial-copy.mjs b/src/firefox/src/ui/locales/safesocial-copy.mjs new file mode 100644 index 000000000..c5bd4f3b6 --- /dev/null +++ b/src/firefox/src/ui/locales/safesocial-copy.mjs @@ -0,0 +1,66 @@ +export const safeSocialEnglish = { + "st.safesocial.heading": "SafeSocial · Image classifier", + "st.safesocial.enable": "Filter Instagram images", + "st.safesocial.desc": "A local classifier for social comparison themes. Choose which categories to soften in your feed.", + "st.safesocial.download": "Off by default. Enabling downloads a 13.6 MB model from Hugging Face once and keeps it in this browser. Images are classified on your device.", + "st.safesocial.model": "EfficientNet-Lite0 · View model on Hugging Face", + "st.safesocial.action": "When an image matches", + "st.safesocial.blur": "Blur", + "st.safesocial.hide": "Hide image", + "st.safesocial.dim": "Dim", + "st.safesocial.warn": "Show warning", + "st.safesocial.threshold": "Score threshold", + "st.safesocial.threshold_desc": "Higher values filter fewer images. Scores are model estimates; they are not a safety guarantee.", + "st.safesocial.categories": "Categories to filter", + "st.safesocial.scope": "Experimental · Instagram images and video cover images only. You can reveal any filtered image. Settings save automatically.", + "st.safesocial.disabled": "Off · No model download or classification. Cached files are kept until you remove them.", + "st.safesocial.idle": "Waiting for an image. Use Retry to prepare the classifier now.", + "st.safesocial.downloading": "Downloading model · {progress}%", + "st.safesocial.loading": "Preparing local classifier…", + "st.safesocial.ready": "Ready · Classification runs locally.", + "st.safesocial.error": "Classifier unavailable. Check your connection and retry.", + "st.safesocial.retry": "Retry / prepare model", + "st.safesocial.remove": "Remove downloaded model", + "st.safesocial.removed": "Downloaded model removed.", + "st.safesocial.label.romance_jealousy": "Romance & jealousy", + "st.safesocial.label.social_fomo": "Social FOMO", + "st.safesocial.label.luxury_status": "Luxury & status", + "st.safesocial.label.travel_lifestyle": "Travel & lifestyle", + "st.safesocial.label.body_beauty_comparison": "Body & beauty comparison", + "st.safesocial.label.achievement_status": "Achievement & success", + "st.safesocial.label.social_proof_popularity": "Popularity", + "st.safesocial.label.exclusivity_access": "Exclusivity & access" +}; +export const safeSocialTranslations = { tr: { + "st.safesocial.heading": "SafeSocial · Görsel sınıflandırıcı", + "st.safesocial.enable": "Instagram görsellerini filtrele", + "st.safesocial.desc": "Sosyal karşılaştırma temalarını cihazınızda sınıflandırır. Akışınızda azaltmak istediğiniz kategorileri seçin.", + "st.safesocial.download": "Varsayılan olarak kapalıdır. Açıldığında Hugging Face’ten 13,6 MB model bir kez indirilir ve bu tarayıcıda saklanır. Görseller cihazınızda sınıflandırılır.", + "st.safesocial.model": "EfficientNet-Lite0 · Modeli Hugging Face’te incele", + "st.safesocial.action": "Görsel eşleştiğinde", + "st.safesocial.blur": "Bulanıklaştır", + "st.safesocial.hide": "Görseli gizle", + "st.safesocial.dim": "Soluklaştır", + "st.safesocial.warn": "Uyarı göster", + "st.safesocial.threshold": "Skor eşiği", + "st.safesocial.threshold_desc": "Yüksek değerler daha az görseli filtreler. Skorlar model tahminidir; güvenlik garantisi değildir.", + "st.safesocial.categories": "Filtrelenecek kategoriler", + "st.safesocial.scope": "Deneysel · Yalnızca Instagram görselleri ve video kapakları. Filtrelenen her görseli tekrar açabilirsiniz. Ayarlar otomatik kaydedilir.", + "st.safesocial.disabled": "Kapalı · Model indirilmez ve sınıflandırma yapılmaz. İndirilen dosyalar siz kaldırana kadar saklanır.", + "st.safesocial.idle": "Görsel bekleniyor. Modeli şimdi hazırlamak için Yeniden dene’ye basın.", + "st.safesocial.downloading": "Model indiriliyor · %{progress}", + "st.safesocial.loading": "Yerel sınıflandırıcı hazırlanıyor…", + "st.safesocial.ready": "Hazır · Sınıflandırma cihazınızda yapılır.", + "st.safesocial.error": "Sınıflandırıcı kullanılamıyor. Bağlantınızı kontrol edip yeniden deneyin.", + "st.safesocial.retry": "Yeniden dene / modeli hazırla", + "st.safesocial.remove": "İndirilen modeli kaldır", + "st.safesocial.removed": "İndirilen model kaldırıldı.", + "st.safesocial.label.romance_jealousy": "Romantizm ve kıskançlık", + "st.safesocial.label.social_fomo": "Sosyal ortamları kaçırma kaygısı", + "st.safesocial.label.luxury_status": "Lüks ve statü", + "st.safesocial.label.travel_lifestyle": "Seyahat ve yaşam tarzı", + "st.safesocial.label.body_beauty_comparison": "Beden ve güzellik karşılaştırması", + "st.safesocial.label.achievement_status": "Başarı", + "st.safesocial.label.social_proof_popularity": "Popülerlik", + "st.safesocial.label.exclusivity_access": "Ayrıcalık ve özel erişim" +} }; diff --git a/src/firefox/src/ui/safesocial-settings.js b/src/firefox/src/ui/safesocial-settings.js new file mode 100644 index 000000000..097b85445 --- /dev/null +++ b/src/firefox/src/ui/safesocial-settings.js @@ -0,0 +1,96 @@ +import { t } from './i18n.js'; +import { LABELS, SETTINGS_KEY, normalizeSettings } from '../safesocial/config.js'; + +const api = globalThis.browser || chrome; +const card = document.getElementById('safesocial-card'); +const toggle = card.querySelector('#safesocial-enabled'); +const action = card.querySelector('#safesocial-action'); +const threshold = card.querySelector('#safesocial-threshold'); +const thresholdValue = card.querySelector('output'); +const status = card.querySelector('#safesocial-status'); +const retry = card.querySelector('#safesocial-retry'); +const remove = card.querySelector('#safesocial-remove'); +let settings = normalizeSettings(); +let requestEpoch = 0; +let polling; +const labels = card.querySelector('#safesocial-labels'); +for (const name of LABELS) { + const label = document.createElement('label'); + label.className = 'safesocial-label'; + const input = document.createElement('input'); + input.type = 'checkbox'; input.name = name; + const text = document.createElement('span'); + text.dataset.i18n = `st.safesocial.label.${name}`; + text.textContent = t(text.dataset.i18n); + label.append(input, text); labels.append(label); +} +const send = command => api.runtime.sendMessage({ target: 'safesocial', command }); +function render() { + toggle.checked = settings.enabled; + action.value = settings.action; + threshold.value = Math.round(settings.threshold * 100); + thresholdValue.textContent = `${threshold.value}%`; + for (const input of labels.querySelectorAll('input')) input.checked = settings.labels[input.name]; + remove.disabled = settings.enabled; + retry.hidden = !settings.enabled; +} +function showState(value) { + if (!settings.enabled) { status.textContent = t('st.safesocial.disabled'); return; } + const name = ['downloading', 'loading', 'ready', 'error'].includes(value?.status) ? value.status : 'idle'; + status.textContent = t(`st.safesocial.${name}`, { progress: value?.progress || 0 }); +} +async function poll() { + const epoch = requestEpoch; + try { const result = await send('status'); if (epoch === requestEpoch) showState(result); } catch { /* retry reports failures */ } +} +async function prepare() { + const epoch = ++requestEpoch; + clearInterval(polling); + if (!settings.enabled) { showState(); return; } + showState({ status: 'loading' }); + polling = setInterval(poll, 1000); + try { + const result = await send('prepare'); + if (epoch !== requestEpoch) return; + if (!result?.ok) throw new Error(result?.error || t('st.safesocial.error')); + showState({ status: 'ready' }); + } catch (error) { + if (epoch === requestEpoch) status.textContent = `${t('st.safesocial.error')} ${error.message}`; + } finally { + if (epoch === requestEpoch) clearInterval(polling); + } +} +let saveQueue = Promise.resolve(); +function save() { + const next = normalizeSettings({ + enabled: toggle.checked, action: action.value, threshold: Number(threshold.value) / 100, + labels: Object.fromEntries([...labels.querySelectorAll('input')].map(input => [input.name, input.checked])), + }); + saveQueue = saveQueue.catch(() => {}).then(() => api.storage.local.set({ [SETTINGS_KEY]: next })) + .catch(error => { status.textContent = error.message; }); +} +for (const input of [toggle, action, threshold, ...labels.querySelectorAll('input')]) input.addEventListener('change', save); +threshold.addEventListener('input', () => { thresholdValue.textContent = `${threshold.value}%`; }); +retry.addEventListener('click', prepare); +remove.addEventListener('click', async () => { + remove.disabled = true; + try { + const result = await send('remove'); + status.textContent = result?.ok ? t('st.safesocial.removed') : t('st.safesocial.error'); + } catch { status.textContent = t('st.safesocial.error'); } + finally { remove.disabled = settings.enabled; } +}); +let changed = false; +api.storage.onChanged.addListener((changes, area) => { + if (area !== 'local' || !changes[SETTINGS_KEY]) return; + changed = true; + settings = normalizeSettings(changes[SETTINGS_KEY].newValue); + render(); void prepare(); +}); +api.storage.local.get(SETTINGS_KEY).then(stored => { + if (changed) return; + settings = normalizeSettings(stored[SETTINGS_KEY]); render(); + // Opening settings is read-only. A prior opt-in can load on the next image. + if (settings.enabled) void poll(); else showState(); +}).catch(error => { status.textContent = error.message; }); +addEventListener('pagehide', () => { requestEpoch++; clearInterval(polling); }); diff --git a/src/firefox/src/ui/settings.html b/src/firefox/src/ui/settings.html index 028ba2735..5301982f4 100644 --- a/src/firefox/src/ui/settings.html +++ b/src/firefox/src/ui/settings.html @@ -1218,6 +1218,11 @@ :root[data-theme="light"] .btn-secondary:hover { background: rgba(89,55,25,0.06); } :root[data-theme="light"] .btn-sign-out:hover { background: rgba(89,55,25,0.06); } :root[data-theme="light"] .info-box code { background: rgba(89,55,25,0.08); } + .safesocial-labels-fieldset { border: 0; padding: 0; margin: 18px 0; min-width: 0; } + #safesocial-labels { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin-top: 10px; } + #safesocial-labels .safesocial-label { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text); } + #safesocial-labels input { width: auto; margin: 0; flex-shrink: 0; } + #safesocial-status { margin-top: 12px; } @@ -1839,6 +1844,49 @@

+ +

+
+
+
+
+
+
+ +
+

+

+
+ + +
+
+ +
+ + 95% +
+
+
+
+ +
+
+

+
+
+ + +
+
@@ -2053,5 +2101,6 @@

+ diff --git a/test/safesocial-extension.mjs b/test/safesocial-extension.mjs new file mode 100644 index 000000000..7c65fc353 --- /dev/null +++ b/test/safesocial-extension.mjs @@ -0,0 +1,47 @@ +import { chromium } from 'playwright'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import assert from 'node:assert/strict'; +const root=resolve('.'); +const modelDir=process.env.SAFESOCIAL_MODEL_DIR; +if (!modelDir) throw new Error('Set SAFESOCIAL_MODEL_DIR to the pinned test bundle directory.'); +let downloads=0; +const profile=await mkdtemp(join(tmpdir(),'wb-safesocial-profile-')); +const context=await chromium.launchPersistentContext(profile,{headless:true,channel:'chromium',args:[`--disable-extensions-except=${root}/src/chrome`,`--load-extension=${root}/src/chrome`]}); +try { + const worker=context.serviceWorkers()[0]||await context.waitForEvent('serviceworker'); + const id=new URL(worker.url()).host; + console.log('isolated extension started',id); + await context.route('https://huggingface.co/**', async route=>{ + downloads++; + const name=new URL(route.request().url()).pathname.split('/').at(-1); + await route.fulfill({path:resolve(modelDir,(name==='model.onnx'?'webbrain-safesocial-model.onnx':'webbrain-safesocial-model.json')),headers:{'Access-Control-Allow-Origin':'*'}}); + }); + const page=await context.newPage(); + await page.goto(`chrome-extension://${id}/src/ui/settings.html#multimodal`); + await page.waitForFunction(()=>document.querySelector('#safesocial-status')?.textContent.length>0); + assert.equal(await page.evaluate(()=>chrome.storage.local.get('safeSocialSettings').then(x=>x.safeSocialSettings?.enabled===true)),false); + const noWork=await page.evaluate(()=>chrome.runtime.sendMessage({target:'safesocial',command:'prepare'})); + assert.equal(noWork.disabled,true); + await page.locator('#safesocial-enabled').locator('..').click(); + await page.waitForFunction(()=>document.querySelector('#safesocial-status').textContent.includes('Ready')||document.querySelector('#safesocial-status').textContent.includes('unavailable'),{},{timeout:180000}); + const status=await page.locator('#safesocial-status').textContent();console.log('status:',status); + assert.ok(status.includes('Ready'),status); + const state=await page.evaluate(()=>chrome.runtime.sendMessage({target:'safesocial',command:'status'})); + assert.equal(state.status,'ready'); + const bypass=await page.evaluate(()=>chrome.runtime.sendMessage({target:'safesocial-host',command:'classify',url:'https://scontent.cdninstagram.com/test.png'}).catch(()=>null)); + assert.ok(bypass == null,'settings cannot bypass background classifier gate'); + await page.locator('#safesocial-enabled').locator('..').click(); + await page.waitForFunction(()=>!document.querySelector('#safesocial-enabled').checked); + const downloaded=downloads; + await page.locator('#safesocial-enabled').locator('..').click(); + await page.waitForFunction(()=>document.querySelector('#safesocial-status').textContent.includes('Ready')); + assert.equal(downloads,downloaded,'re-enabling uses cached model data'); + await page.locator('#safesocial-enabled').locator('..').click(); + await page.waitForFunction(()=>!document.querySelector('#safesocial-enabled').checked); + await page.locator('#safesocial-remove').click(); + await page.waitForFunction(()=>document.querySelector('#safesocial-status').textContent.includes('removed')); + assert.equal(await page.evaluate(()=>caches.has('webbrain-safesocial-v1')),false); + console.log('real Chrome MV3: lazy opt-in, offscreen host, pinned download, ready, host gate, disable/remove passed'); +} finally {await context.close();await rm(profile,{recursive:true,force:true});} diff --git a/test/safesocial-ui.mjs b/test/safesocial-ui.mjs new file mode 100644 index 000000000..3045013de --- /dev/null +++ b/test/safesocial-ui.mjs @@ -0,0 +1,162 @@ +import { chromium, firefox } from 'playwright'; +import { createServer } from 'node:http'; +import { readFile, mkdir } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; +import assert from 'node:assert/strict'; +const root = resolve('.'); +const output = process.env.SAFESOCIAL_UI_OUTPUT || '/tmp/webbrain-safesocial-review'; +await mkdir(output, { recursive: true }); +const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.html': 'text/html', '.css': 'text/css', '.wasm': 'application/wasm', '.png': 'image/png' }; +const server = createServer(async (req, res) => { + try { + const path = new URL(req.url, 'http://localhost').pathname; + if (path === '/fixture') { res.setHeader('Content-Type', 'text/html'); res.end('
'); return; } + const file = resolve(root, '.' + path); + if (!file.startsWith(root + '/')) throw Error('outside root'); + res.setHeader('Content-Type', mime[extname(file)] || 'text/plain'); res.end(await readFile(file)); + } catch { res.statusCode = 404; res.end(); } +}); +await new Promise(r => server.listen(0, '127.0.0.1', r)); +const origin = `http://127.0.0.1:${server.address().port}`; +try { + for (const [build, engine] of [['chrome', chromium], ['firefox', firefox]]) { + const browser = await engine.launch(); + try { + for (const lang of ['en', 'tr']) for (const width of [390, 1280]) { + const page = await browser.newPage({ viewport: { width, height: 1000 } }); + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.addInitScript(({ build, lang }) => { + localStorage.setItem('wbLocale', lang); + const data = { wbLocale: lang }; const listeners = []; + window.testRequests = []; window.testStore = data; + const storage = { + async get(keys) { return keys == null ? { ...data } : Object.fromEntries((Array.isArray(keys) ? keys : typeof keys === 'string' ? [keys] : Object.keys(keys)).map(k => [k, data[k]])); }, + async set(values) { Object.assign(data, values); listeners.forEach(fn => fn(Object.fromEntries(Object.entries(values).map(([k,v]) => [k, { newValue: v }])), 'local')); }, + async remove(keys) { for (const key of Array.isArray(keys) ? keys : [keys]) delete data[key]; }, + }; + window.chrome = window.browser = { + storage: { local: storage, onChanged: { addListener: fn => listeners.push(fn) } }, + runtime: { getURL: path => `${location.origin}/src/${build}/${path}`, getManifest: () => ({ version: '36.7.5' }), onMessage: { addListener() {} }, sendMessage(msg, callback) { + testRequests.push(msg); + const result = msg.action === 'get_providers' ? { providers: {}, active: '' } + : msg.target === 'safesocial' ? { ok: !window.failModel, status: 'ready', error: window.failModel ? 'Synthetic download failure' : '' } : {}; + callback?.(result); return Promise.resolve(result); + } }, commands: { getAll: async () => [] }, tabs: { create: async () => ({}) }, + }; + }, { build, lang }); + await page.goto(`${origin}/src/${build}/src/ui/settings.html#multimodal`); + await page.waitForFunction(() => document.querySelector('#safesocial-status')?.textContent.length > 0); + const card = page.locator('#safesocial-card'); + assert.equal(await page.locator('#safesocial-heading').textContent(), lang === 'tr' + ? 'SafeSocial · Görsel sınıflandırıcı' : 'SafeSocial · Image classifier'); + assert.equal(await card.evaluate(el => el.textContent.includes('st.safesocial.')), false, 'all copy resolves'); + await card.scrollIntoViewIfNeeded(); + assert.equal(await page.locator('#safesocial-enabled').isChecked(), false); + assert.equal(await page.evaluate(() => testRequests.filter(x => x.target === 'safesocial').length), 0, 'opening disabled settings does no work'); + assert.equal(await card.locator('input[type=checkbox]:checked').count(), 3); + await page.locator('#safesocial-enabled').locator('..').click(); + await page.waitForFunction(() => testStore.safeSocialSettings?.enabled && testRequests.some(x => x.command === 'prepare')); + assert.equal(await page.locator('#safesocial-remove').isDisabled(), true); + await page.locator('#safesocial-action').selectOption('hide'); + await page.locator('input[name=body_beauty_comparison]').check(); + await page.waitForFunction(() => testStore.safeSocialSettings.action === 'hide' && testStore.safeSocialSettings.labels.body_beauty_comparison); + await page.locator('#safesocial-threshold').fill('80'); + await page.locator('#safesocial-threshold').dispatchEvent('change'); + await page.waitForFunction(() => testStore.safeSocialSettings.threshold === .8); + await page.evaluate(() => { window.failModel = true; }); + await page.locator('#safesocial-retry').click(); + await page.waitForFunction(() => document.querySelector('#safesocial-status').textContent.includes('Synthetic download failure')); + await page.locator('#safesocial-enabled').locator('..').click(); + await page.waitForFunction(() => !testStore.safeSocialSettings.enabled); + await page.evaluate(() => { window.failModel = false; }); + await page.locator('#safesocial-remove').click(); + assert.equal(await page.evaluate(() => testRequests.at(-1).command), 'remove'); + await page.locator('#safesocial-heading').scrollIntoViewIfNeeded(); + await page.screenshot({ path: `${output}/${build}-${lang}-${width}.png` }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + assert.deepEqual(errors, []); + await page.close(); + } + const page = await browser.newPage({ viewport: { width: 800, height: 800 } }); + await page.route('https://scontent.cdninstagram.com/**', route => route.fulfill({ path: resolve('src/chrome/icons/icon128.png'), contentType: 'image/png' })); + await page.goto(`${origin}/fixture`); + await page.addStyleTag({ path: `src/${build}/src/safesocial/content.css` }); + await page.evaluate(() => { + let settings = {}; const listeners = []; + window.calls = []; window.classifyWait = null; window.failClassification = false; + const storage = { + get: async () => ({ safeSocialSettings: settings }), + set: async patch => { settings = patch.safeSocialSettings; listeners.forEach(fn => fn({ safeSocialSettings: { newValue: settings } }, 'local')); }, + }; + window.chrome = window.browser = { storage: { local: storage, onChanged: { addListener: fn => listeners.push(fn) } }, runtime: { + async sendMessage(message) { + calls.push(message); + if (window.classifyWait) await window.classifyWait; + return window.failClassification ? { ok: false, error: 'unavailable' } : { ok: true, labels: ['luxury_status'], action: settings.action || 'blur' }; + }, + } }; + }); + await page.addScriptTag({ path: `src/${build}/src/safesocial/content.js` }); + assert.equal(await page.evaluate(() => calls.length), 0); + await page.evaluate(() => chrome.storage.local.set({ safeSocialSettings: { enabled: true } })); + await page.waitForSelector('.wb-safesocial-blur'); + await page.locator('.wb-safesocial-overlay button').click(); + assert.equal(await page.locator('.wb-safesocial-blur').count(), 0); + await page.evaluate(() => { document.querySelector('#media').src = 'https://scontent.cdninstagram.com/second.png'; }); + await page.waitForSelector('.wb-safesocial-blur'); + await page.evaluate(() => chrome.storage.local.set({ safeSocialSettings: { enabled: false } })); + assert.equal(await page.locator('.wb-safesocial-overlay').count(), 0); + assert.equal(await page.locator('#media').evaluate(e => getComputedStyle(e).filter), 'none'); + // A result delivered after opt-out must never touch the page. + await page.evaluate(() => { + window.classifyWait = new Promise(resolve => { window.finishClassification = resolve; }); + return chrome.storage.local.set({ safeSocialSettings: { enabled: true } }); + }); + await page.waitForFunction(() => calls.length >= 3); + await page.evaluate(() => chrome.storage.local.set({ safeSocialSettings: { enabled: false } })); + await page.evaluate(() => { finishClassification(); window.classifyWait = null; }); + await page.waitForTimeout(250); + assert.equal(await page.locator('.wb-safesocial-blur').count(), 0); + await page.evaluate(() => { window.failClassification = true; return chrome.storage.local.set({ safeSocialSettings: { enabled: true } }); }); + await page.waitForSelector('.wb-safesocial-notice'); + assert.equal(await page.locator('.wb-safesocial-blur').count(), 0, 'no mock classifications on failure'); + await page.evaluate(() => chrome.storage.local.set({ safeSocialSettings: { enabled: false } })); + assert.equal(await page.locator('.wb-safesocial-notice').count(), 0); + await page.close(); + console.log(`${build}: responsive EN/TR settings and feed lifecycle passed`); + + if (process.env.SAFESOCIAL_MODEL_DIR) { + const modelPage = await browser.newPage(); + await modelPage.route('https://huggingface.co/**', async route => { + const name = new URL(route.request().url()).pathname.split('/').at(-1); + const file = name === 'model.onnx' ? 'webbrain-safesocial-model.onnx' : 'webbrain-safesocial-model.json'; + await route.fulfill({ path: resolve(process.env.SAFESOCIAL_MODEL_DIR, file), headers: { 'Access-Control-Allow-Origin': '*' } }); + }); + await modelPage.route('https://scontent.cdninstagram.com/**', route => route.fulfill({ path: resolve('src/chrome/icons/icon128.png'), contentType: 'image/png', headers: { 'Access-Control-Allow-Origin': '*' } })); + await modelPage.goto(`${origin}/fixture`); + const actual = await modelPage.evaluate(async ({ build }) => { + const { createSafeSocialHost } = await import(`/src/${build}/src/safesocial/host.js`); + const host = createSafeSocialHost(); + await host.handle('prepare'); + const status = await host.handle('status'); + const result = await host.handle('classify', { url: 'https://scontent.cdninstagram.com/fixture.png' }); + await host.handle('stop'); + const { CACHE_NAME, MODEL_BASE } = await import(`/src/${build}/src/safesocial/config.js`); + const cache = await caches.open(CACHE_NAME); + await cache.put(MODEL_BASE + 'model.onnx', new Response(new Uint8Array([1, 2, 3]))); + let verificationError; + try { await host.handle('prepare'); } catch (error) { verificationError = error.message; } + await host.handle('prepare'); + await host.handle('stop'); + return { status, result, verificationError }; + }, { build }); + assert.equal(actual.status.status, 'ready'); + assert.equal(Object.keys(actual.result.scores).length, 10); + assert.ok(Object.values(actual.result.scores).every(value => Number.isFinite(value) && value >= 0 && value <= 1)); + assert.match(actual.verificationError, /verification failed/, 'corrupt cached weights cannot run and a retry recovers'); + console.log(`${build}: actual pinned ONNX/WASM inference passed (10 finite scores)`); + await modelPage.close(); + } + } finally { await browser.close(); } + } +} finally { server.close(); } diff --git a/test/safesocial.mjs b/test/safesocial.mjs new file mode 100644 index 000000000..b4adcf88b --- /dev/null +++ b/test/safesocial.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFile } from 'node:fs/promises'; + +for (const build of ['chrome', 'firefox']) { + const config = await import(`../src/${build}/src/safesocial/config.js`); + const configTransfer = await import(`../src/${build}/src/config-transfer.js`); + const { installSafeSocialBackground } = await import(`../src/${build}/src/safesocial/background.js`); + const { createSafeSocialHost } = await import(`../src/${build}/src/safesocial/host.js`); + test(`${build}: opt-in defaults, score selection, strict URL boundaries and training preprocessing`, () => { + const defaults = config.normalizeSettings(); + assert.equal(defaults.enabled, false); + assert.equal(defaults.threshold, .95); + assert.deepEqual(config.matchingLabels({ luxury_status: .96, social_fomo: .99, none: 1 }, defaults), ['luxury_status']); + assert.deepEqual(config.matchingLabels({ luxury_status: NaN, travel_lifestyle: 4 }, defaults), []); + assert.equal(config.normalizeSettings({ enabled: 'true', threshold: NaN, action: 'script' }).enabled, false); + assert.equal(config.normalizeSettings({ threshold: NaN }).threshold, .95); + assert.equal(config.isInstagramUrl('https://www.instagram.com/p/1'), true); + assert.equal(config.isMediaUrl('https://scontent.cdninstagram.com/image.jpg'), true); + for (const url of ['http://cdninstagram.com/x', 'https://cdninstagram.com.evil.test/x', 'https://localhost/x', + 'https://a:secret@cdninstagram.com/x', 'https://cdninstagram.com:8443/x', 'data:image/png,abc']) { + assert.equal(config.isMediaUrl(url), false, url); + } + assert.equal(config.isInstagramUrl('https://instagram.com.evil.test/'), false); + assert.deepEqual(config.centerCrop(512, 256, { image_size: 224, resize: 256 }), { x: 144, y: 16, size: 224 }); + const pixels = config.normalizedPixels([255, 0, 128, 255], { image_size: 1, mean: [0, .5, 0], std: [1, .5, 1] }); + assert.equal(pixels[0], 1); assert.equal(pixels[1], -1); assert.ok(Math.abs(pixels[2] - 128 / 255) < 1e-7); + }); + test(`${build}: configuration transfer preserves normalized SafeSocial settings`, () => { + const exported = configTransfer.createConfigExport({ + [config.SETTINGS_KEY]: { + enabled: true, + action: 'hide', + threshold: 2, + labels: { social_fomo: true, luxury_status: false }, + }, + }); + assert.deepEqual(exported.settings[config.SETTINGS_KEY], config.normalizeSettings({ + enabled: true, + action: 'hide', + threshold: 2, + labels: { social_fomo: true, luxury_status: false }, + })); + const restored = configTransfer.parseConfigImport(JSON.stringify(exported)); + assert.deepEqual(restored.settings[config.SETTINGS_KEY], exported.settings[config.SETTINGS_KEY]); + const patch = configTransfer.parseConfigPatchImport(JSON.stringify({ + ...exported, + settings: { [config.SETTINGS_KEY]: { enabled: true, threshold: 0 } }, + })); + assert.deepEqual(patch.settings[config.SETTINGS_KEY], config.normalizeSettings({ enabled: true, threshold: 0 })); + assert.throws(() => configTransfer.parseConfigImport(JSON.stringify({ + ...exported, + settings: { [config.SETTINGS_KEY]: true }, + })), /safeSocialSettings/); + }); + function harness() { + let settings = config.normalizeSettings(); + let onChange; + const calls = []; + const api = { + runtime: { id: 'wb', getURL: p => `chrome-extension://wb/${p}`, onMessage: { addListener() {} } }, + storage: { local: { get: async () => ({ [config.SETTINGS_KEY]: settings }) }, + onChanged: { addListener: fn => { onChange = fn; } } }, + }; + let implementation = async command => command === 'classify' ? { scores: { luxury_status: .98 } } : { status: 'idle' }; + const controller = installSafeSocialBackground(api, async (...args) => { calls.push(args); return implementation(...args); }); + return { calls, controller, setHost: fn => { implementation = fn; }, + set(value) { settings = config.normalizeSettings(value); onChange({ [config.SETTINGS_KEY]: { newValue: settings } }, 'local'); } }; + } + const settingsSender = { id: 'wb', url: 'chrome-extension://wb/src/ui/settings.html#multimodal', tab: { id: 2 }, frameId: 0 }; + const contentSender = { id: 'wb', url: 'https://www.instagram.com/', tab: { id: 1 }, frameId: 0 }; + const classify = { command: 'classify', url: 'https://scontent.cdninstagram.com/image.jpg' }; + test(`${build}: disabled and unauthorized callers never start inference or download`, async () => { + const h = harness(); + assert.equal((await h.controller.handle({ command: 'prepare' }, settingsSender)).disabled, true); + assert.equal((await h.controller.handle(classify, contentSender)).disabled, true); + assert.equal(h.calls.length, 0); + h.set({ enabled: true }); + for (const sender of [{ ...contentSender, url: 'https://evil.test/' }, { ...contentSender, frameId: 3 }, + { ...contentSender, id: 'other' }, { ...contentSender, tab: undefined }]) { + await assert.rejects(h.controller.handle(classify, sender)); + } + await assert.rejects(h.controller.handle({ command: 'prepare' }, contentSender)); + await assert.rejects(h.controller.handle({ ...classify, url: 'https://localhost/x' }, contentSender)); + assert.ok(h.calls.every(([command]) => command === 'stop')); + const result = await h.controller.handle(classify, contentSender); + assert.deepEqual(result.labels, ['luxury_status']); + assert.equal(result.action, 'blur'); + }); + test(`${build}: disabled-in-flight results and changed categories are never applied`, async () => { + const h = harness(); + let finish; + let entered; + const started = new Promise(resolve => { entered = resolve; }); + h.setHost(async command => { + if (command !== 'classify') return {}; + entered(); return new Promise(resolve => { finish = resolve; }); + }); + h.set({ enabled: true }); + const pending = h.controller.handle(classify, contentSender); + await started; + h.set({ enabled: false }); + finish({ scores: { luxury_status: .99 } }); + assert.equal((await pending).disabled, true); + h.setHost(async () => ({ scores: { luxury_status: .99 } })); + h.set({ enabled: true, labels: { luxury_status: false } }); + assert.deepEqual((await h.controller.handle(classify, contentSender)).labels, []); + await assert.rejects(h.controller.handle({ command: 'remove' }, settingsSender), /Disable/); + }); + test(`${build}: host is lazy, shares a worker and terminates pending work on disable`, async () => { + const workers = []; + class FakeWorker { + constructor() { workers.push(this); this.messages = []; } + postMessage(message) { this.messages.push(message); } + terminate() { this.stopped = true; } + } + const host = createSafeSocialHost({ WorkerClass: FakeWorker }); + await host.handle('status'); await host.handle('stop'); + assert.equal(workers.length, 0); + const preparing = host.handle('prepare'); + const inference = host.handle('classify', { url: classify.url }); + assert.equal(workers.length, 1); + const rejected = Promise.all([assert.rejects(preparing, /stopped/), assert.rejects(inference, /stopped/)]); + await host.handle('stop'); await rejected; + assert.equal(workers[0].stopped, true); + workers[0].onmessage({ data: { state: { status: 'ready' } } }); + assert.equal((await host.handle('status')).status, 'idle'); + const restarted = host.handle('prepare'); + const message = workers[1].messages[0]; + workers[1].onmessage({ data: { id: message.id, result: {} } }); + await restarted; await host.handle('stop'); + }); +} + +test('browser builds share the classifier, settings and locale implementation', async () => { + for (const path of ['safesocial/config.js', 'safesocial/worker.js', 'safesocial/host.js', 'safesocial/background.js', + 'safesocial/content.js', 'safesocial/content.css', 'ui/safesocial-settings.js', 'ui/locales/safesocial-copy.mjs']) { + assert.equal(await readFile(`src/chrome/src/${path}`, 'utf8'), await readFile(`src/firefox/src/${path}`, 'utf8'), path); + } +}); diff --git a/test/systemone-ui.mjs b/test/systemone-ui.mjs index f2263afd1..c9ed1658f 100644 --- a/test/systemone-ui.mjs +++ b/test/systemone-ui.mjs @@ -79,7 +79,7 @@ try { await page.screenshot({ animations: 'disabled', path: `${output}/${build}-${lang}-${width}-tabs.png` }); assert.equal(await page.locator('[data-panel="providers"] #system-one-card').count(), 0); assert.deepEqual(await page.locator('[data-panel="multimodal"] > .provider-card').evaluateAll(cards => cards.map(card => card.id)), [ - 'vision-card', 'image-budget-card', 'redaction-card', 'transcription-card', 'system-one-card', + 'vision-card', 'image-budget-card', 'redaction-card', 'transcription-card', 'system-one-card', 'safesocial-card', ]); // A label change must retain saved tab selection as well as old deep links. await page.evaluate(() => history.replaceState(null, '', location.pathname));