// ==UserScript== // @name UserScript Finder // @namespace http://tampermonkey.net/ // @version 1.28.0 // @description Finds userscripts and extension alternatives for the current domain // @author SysAdminDoc // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_openInTab // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @icon https://raw.githubusercontent.com/SysAdminDoc/UserScript-Finder/main/img/icon.png // @connect greasyfork.org // @connect update.greasyfork.org // @connect sleazyfork.org // @connect openuserjs.org // @connect chromewebstore.google.com // @connect addons.mozilla.org // @connect www.tampermonkey.net // @connect gist.github.com // @connect gist.githubusercontent.com // @connect api.github.com // @connect raw.githubusercontent.com // @license MIT // @run-at document-idle // @downloadURL https://raw.githubusercontent.com/SysAdminDoc/UserScript-Finder/main/UserScript-Finder.user.js // @updateURL https://raw.githubusercontent.com/SysAdminDoc/UserScript-Finder/main/UserScript-Finder.user.js // @homepageURL https://github.com/SysAdminDoc/UserScript-Finder // @supportURL https://github.com/SysAdminDoc/UserScript-Finder/issues // ==/UserScript== (function() { "use strict"; try { if (window.self !== window.top) return; } catch(e) { return; } // ── TrustedHTML policy ────────────────────────────────────────────── const COMPAT_STATE = { trustedTypes: { status: "unavailable", policyName: null, error: null } }; function _createTrustedTypesPolicy() { const fallback = { createHTML: s => s }; if (typeof trustedTypes === "undefined" || typeof trustedTypes.createPolicy !== "function") return fallback; try { const policy = trustedTypes.createPolicy("gf-script-finder", { createHTML: s => s }); COMPAT_STATE.trustedTypes = { status: "ok", policyName: "gf-script-finder", error: null }; return policy; } catch(firstErr) { const alternateName = `gf-script-finder-${Date.now().toString(36)}`; try { const policy = trustedTypes.createPolicy(alternateName, { createHTML: s => s }); COMPAT_STATE.trustedTypes = { status: "renamed", policyName: alternateName, error: firstErr?.message || "base policy unavailable" }; return policy; } catch(secondErr) { COMPAT_STATE.trustedTypes = { status: "fallback", policyName: null, error: secondErr?.message || firstErr?.message || "policy unavailable" }; return fallback; } } } const _ttPolicy = _createTrustedTypesPolicy(); function _safeHTML(el, html) { el.innerHTML = _ttPolicy.createHTML(html); } const SOURCE_META = { greasyfork: { label: "GreasyFork", tab: "GreasyFork", menuKind: "Scripts", menuName: "GreasyFork", footerUrl: "https://greasyfork.org", unit: "script" }, sleazyfork: { label: "SleazyFork", tab: "SleazyFork", menuKind: "Scripts", menuName: "SleazyFork", footerUrl: "https://sleazyfork.org", unit: "script" }, openuserjs: { label: "OpenUserJS", tab: "OpenUserJS", menuKind: "Scripts", menuName: "OpenUserJS", footerUrl: "https://openuserjs.org", unit: "script" }, chromewebstore: { label: "Chrome Web Store", tab: "Chrome", menuKind: "Extensions", menuName: "Chrome Web Store", footerUrl: "https://chromewebstore.google.com", unit: "extension" }, mozillaaddons: { label: "Mozilla Add-ons", tab: "Firefox", menuKind: "Extensions", menuName: "Mozilla AMO", footerUrl: "https://addons.mozilla.org", unit: "extension" }, catalogs: { label: "Script Catalogs", tab: "Catalogs", menuKind: "Catalogs", menuName: "Awesome/Tampermonkey", footerUrl: "https://github.com/awesome-scripts/awesome-userscripts", unit: "catalog result" }, githubgist: { label: "GitHub Gists", tab: "Gists", menuKind: "Scripts", menuName: "GitHub Gists", footerUrl: "https://gist.github.com", unit: "gist" }, github: { label: "GitHub", tab: "GitHub", menuKind: "Scripts", menuName: "GitHub", footerUrl: "https://github.com", unit: "repo" } }; const SOURCE_CONNECT = { greasyfork: ["greasyfork.org", "update.greasyfork.org"], sleazyfork: ["sleazyfork.org"], openuserjs: ["openuserjs.org"], chromewebstore: ["chromewebstore.google.com"], mozillaaddons: ["addons.mozilla.org"], catalogs: ["raw.githubusercontent.com", "www.tampermonkey.net"], githubgist: ["gist.github.com", "gist.githubusercontent.com"], github: ["api.github.com"] }; const SOURCE_ORDER = Object.keys(SOURCE_META); const DEFAULT_SOURCE_SETTINGS = SOURCE_ORDER.reduce((settings, source) => { settings[source] = true; return settings; }, {}); const DEFAULT_SENSITIVE_HOST_PATTERNS = [ "localhost", "*.localhost", "127.*", "10.*", "192.168.*", "172.16.*", "172.17.*", "172.18.*", "172.19.*", "172.20.*", "172.21.*", "172.22.*", "172.23.*", "172.24.*", "172.25.*", "172.26.*", "172.27.*", "172.28.*", "172.29.*", "172.30.*", "172.31.*", "*.local", "*.lan", "*.home", "*.internal", "*.intranet", "*.corp", "*.gov", "*.gov.*", "*.mil", "*.mil.*", "*bank*", "*creditunion*", "login.*", "*.login.*", "auth.*", "*.auth.*", "sso.*", "*.sso.*", "identity.*", "*.identity.*", "admin.*", "*.admin.*", "router.*", "*.router.*", "gateway.*", "*.gateway.*", "firewall.*", "*.firewall.*" ]; // ── Default Settings ──────────────────────────────────────────────── const DEFAULT_SETTINGS = { cacheDuration: 5 * 60 * 1000, defaultSort: "daily", denseMode: false, lastService: "greasyfork", sources: DEFAULT_SOURCE_SETTINGS, sensitiveHostProtection: true, sensitiveHostPatterns: "", sensitiveHostOverrides: [], disclosureAckedSources: [] }; const STRINGS = { modalTitleScripts: (host) => `Scripts for ${host}`, modalTitleExtensions: (host) => `Extensions for ${host}`, modalTitleCatalogs: (host) => `Catalogs for ${host}`, modalTitleGists: (host) => `Gists for ${host}`, modalTitleResults: (host) => `Results for ${host}`, modalTitleCompat: "Manager compatibility", modalTitleDisclosure: "Network disclosure", emptyNoScripts: (host, label) => `Nothing matched ${host} on ${label}.`, emptyNoMatches: "No matches", emptyNoMatchFilter: (query) => `No scripts match "${query}"`, emptyNoMatchFilters: "No scripts match the active filters.", emptySourceDisabled: "Source disabled", emptySourceDisabledText: "Enable this source in settings to search it.", emptyHostBlocked: "Search disabled on sensitive host", loadingSearch: (label) => `Searching ${label}...`, loadingAllSources: "Searching all sources...", toastDiagCopied: "Diagnostics copied", toastDiagConsole: "Copy failed — select and copy manually", toastDiagManual: "Copy diagnostics manually", toastSettingsExported: "Settings exported", toastSettingsImported: "Settings imported", toastImportFailed: "Import failed: invalid JSON", toastInstallBlocked: (reason) => `Install blocked: ${reason}`, toastInstallMetaMissing: "Install blocked: .user.js metadata block missing.", toastGrantWarning: (grants) => `Warning: requests ${grants}`, toastQueued: "Queued to try later", toastUnqueued: "Removed from queue", btnSearchManually: "Search manually", btnTryAgain: "Try again", btnContinue: "Continue", btnShowAll: "Show all", btnRetryCopy: "Retry copy", btnExport: "Export settings", btnImport: "Import settings", labelDismissedCount: (n) => `${n} hidden`, disclosureTitle: "Source network destinations", disclosureText: "Enabled sources will contact external registries and stores to search for scripts and extensions. Review and disable any sources you prefer not to contact before continuing.", compatTitle: "Userscript manager degraded mode", filterPlaceholder: "Filter... (author: license: source: name: url:)", staleWarning: "Last updated more than 2 years ago" }; // ── Catppuccin Mocha + OLED palette ───────────────────────────────── const THEME = { base: '#0a0a0f', mantle: '#0f0f17', crust: '#06060a', surface0: '#14141f', surface1: '#1a1a2a', surface2: '#232336', overlay0: '#2e2e44', overlay1: '#3a3a55', text: '#cdd6f4', subtext1: '#bac2de', subtext0: '#a6adc8', overlay: '#7f849c', green: '#a6e3a1', greenDim: '#40b65e', teal: '#94e2d5', purple: '#cba6f7', purpleDim:'#a855c7', mauve: '#b4befe', red: '#f38ba8', peach: '#fab387', yellow: '#f9e2af', blue: '#89b4fa', sky: '#89dceb', flamingo: '#f2cdcd', rosewater:'#f5e0dc', glass: 'rgba(14, 14, 22, 0.82)', glassBorder: 'rgba(255, 255, 255, 0.06)', glassHover: 'rgba(255, 255, 255, 0.03)', glow: 'rgba(166, 227, 161, 0.15)', glowPurple: 'rgba(203, 166, 247, 0.15)', openuserjs: '#89b4fa', openuserjsDim: '#4f7fd8', glowOpenUserJS: 'rgba(137, 180, 250, 0.15)', chromewebstore: '#f9e2af', chromewebstoreDim: '#fbbc04', glowChromeWebStore: 'rgba(249, 226, 175, 0.15)', mozillaaddons: '#ff8a3d', mozillaaddonsDim: '#e66000', glowMozillaAddons: 'rgba(255, 138, 61, 0.15)', catalogs: '#89dceb', catalogsDim: '#3db9b7', glowCatalogs: 'rgba(137, 220, 235, 0.15)', githubgist: '#f2cdcd', githubgistDim: '#e78284', glowGitHubGist: 'rgba(242, 205, 205, 0.15)', github: '#f0883e', githubDim: '#d2691e', glowGithub: 'rgba(240, 136, 62, 0.15)', shadow: 'rgba(0, 0, 0, 0.5)' }; // ── Icons (Phosphor) ──────────────────────────────────────────────── const ICONS = { moon: '', search: '', scales: '', user: '', gitBranch: '', download: '', chartBar: '', star: '', flame: '', clockwise: '', calendarPlus: '', install: '', gear: '', x: '', eyeSlash: '', arrowsHorizontal: '', rows: '', undo: '', warning: '', githubLogo: '', gitFork: '' }; function getIcon(name) { return ICONS[name] || ''; } // ── Utility ───────────────────────────────────────────────────────── function cleanText(text) { return String(text || "").replace(/\s+/g, " ").trim(); } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text || ""; return div.innerHTML; } function relativeTime(iso) { if (!iso) return null; const d = new Date(iso); if (isNaN(d.getTime())) return null; const now = Date.now(); const diff = now - d.getTime(); if (diff < 0) return 'just now'; const mins = Math.floor(diff / 60000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 30) return `${days}d ago`; const months = Math.floor(days / 30); if (months < 12) return `${months}mo ago`; return `${Math.floor(months / 12)}y ago`; } function formatNumber(num) { if (num === null || num === undefined || num === "") return null; const n = Number(num); if (!Number.isFinite(n)) return null; if (n >= 1000000) return (n / 1000000).toFixed(1).replace('.0', '') + 'M'; if (n >= 1000) return (n / 1000).toFixed(1).replace('.0', '') + 'k'; return n.toString(); } function reputationScore(script) { const installs = Number(script?.total_installs) || 0; const ratings = Number(script?.good_ratings ?? script?._rating_count ?? script?._stars) || 0; const quality = Number(script?.fan_score ?? script?._rating) || 0; const stars = Number(script?._stars) || 0; const forks = Number(script?._forks) || 0; const curated = script?._catalog_source === "Awesome Userscripts" ? 100000 : script?._catalog_source ? 30000 : 0; return curated + (Math.log10(installs + 1) * 1000) + (ratings * 10) + (quality * 250) + (stars * 15) + (forks * 25); } function normalizedRating(script) { if (script?._rating != null) return Number(script._rating); if (script?.fan_score != null) return Number(script.fan_score) / 2; return null; } function matchesLanguageFilter(script, filterValue) { if (filterValue === "any") return true; const locale = String(script?.locale || script?._locale || "").toLowerCase(); if (filterValue === "browser") { const browserLang = (navigator.language || "en").split("-")[0].toLowerCase(); if (locale) return locale.startsWith(browserLang); if (browserLang === "en") return _looksLatin(script); return true; } if (locale) return locale.startsWith("en"); return _looksLatin(script); } function _looksLatin(script) { const text = `${script?.name || ""} ${script?.description || ""}`; if (!text.trim()) return true; const latin = (text.match(/[A-Za-z]/g) || []).length; const nonLatin = (text.match(/[^\x00-\x7F]/g) || []).length; return nonLatin === 0 || latin / Math.max(latin + nonLatin, 1) >= 0.8; } function normalizeStringList(value) { const items = []; const add = item => { if (item === null || item === undefined || item === false) return; if (Array.isArray(item)) { item.forEach(add); return; } if (typeof item === "object") { Object.values(item).forEach(add); return; } String(item).split(/\r?\n|,\s*/).forEach(part => { const text = part.replace(/\s+/g, " ").trim(); if (text) items.push(text); }); }; add(value); return Array.from(new Set(items)); } function firstString(...values) { for (const value of values) { if (value === null || value === undefined || value === false) continue; if (Array.isArray(value)) { const found = firstString(...value); if (found) return found; continue; } if (typeof value === "object") { const found = firstString(...Object.values(value)); if (found) return found; continue; } const text = String(value).replace(/\s+/g, " ").trim(); if (text) return text; } return null; } function isExtensionHostPermission(permission) { const text = String(permission || "").trim().toLowerCase(); return text === "" || /^(\*|https?|file|ftp):\/\//.test(text); } function hasBroadHostAccess(hostPermissions) { return normalizeStringList(hostPermissions).some(permission => { const text = permission.toLowerCase().replace(/\s+/g, ""); return text === "" || text === "*://*/*" || text === "*://*" || text === "http://*/*" || text === "https://*/*" || /^(\*|https?):\/\/\*\//.test(text); }); } function splitExtensionPermissions(permissions, hostPermissions = []) { const direct = normalizeStringList(permissions); const explicitHosts = normalizeStringList(hostPermissions); return { permissions: direct.filter(permission => !isExtensionHostPermission(permission)), hostPermissions: Array.from(new Set([ ...explicitHosts, ...direct.filter(isExtensionHostPermission) ])) }; } function listSummary(label, values) { const list = normalizeStringList(values); const shown = list.slice(0, 6).join(", "); const suffix = list.length > 6 ? `, +${list.length - 6} more` : ""; return `${label}: ${shown}${suffix}`; } function extensionTrustBadges(script, now = Date.now()) { const permissions = normalizeStringList(script?._permissions); const optionalPermissions = normalizeStringList(script?._optional_permissions); const hostPermissions = normalizeStringList(script?._host_permissions); const dataCollection = normalizeStringList(script?._data_collection); const privacyPolicy = firstString(script?._privacy_policy_url); const promoted = firstString(script?._promoted); const badges = []; if (permissions.length) { badges.push({ icon: "gear", text: `${permissions.length} ${permissions.length === 1 ? "perm" : "perms"}`, title: listSummary("Permissions", permissions), cls: "trust-info" }); } if (optionalPermissions.length) { badges.push({ icon: "gear", text: `${optionalPermissions.length} optional`, title: listSummary("Optional permissions", optionalPermissions), cls: "trust-info" }); } if (hostPermissions.length) { const broad = hasBroadHostAccess(hostPermissions); badges.push({ icon: "search", text: broad ? "All sites" : `${hostPermissions.length} ${hostPermissions.length === 1 ? "host" : "hosts"}`, title: listSummary("Host permissions", hostPermissions), cls: broad ? "trust-bad" : "trust-warn" }); } badges.push(privacyPolicy ? { icon: "scales", text: "Privacy", title: `Privacy policy: ${privacyPolicy}`, cls: "trust-ok" } : { icon: "scales", text: "No privacy", title: "No privacy policy was found in the extension metadata.", cls: "trust-warn" }); if (dataCollection.length) { badges.push({ icon: "eyeSlash", text: "Data", title: listSummary("Data collection", dataCollection), cls: "trust-warn" }); } if (promoted) { badges.push({ icon: "star", text: String(promoted).toLowerCase() === "recommended" ? "Recommended" : "Promoted", title: `Store status: ${promoted}`, cls: "trust-ok" }); } const updatedAt = Date.parse(script?.code_updated_at || ""); if (Number.isFinite(updatedAt) && now - updatedAt > 730 * 24 * 60 * 60 * 1000) { badges.push({ icon: "clockwise", text: "Stale", title: "Extension metadata has not been updated in more than two years.", cls: "trust-bad" }); } return badges; } function gmFunction(name) { const fn = globalThis?.[name]; return typeof fn === "function" ? fn : null; } function gmGetValue(key, fallback) { const fn = gmFunction("GM_getValue"); if (!fn) return fallback; try { return fn(key, fallback); } catch { return fallback; } } function gmSetValue(key, value) { const fn = gmFunction("GM_setValue"); if (!fn) return false; try { fn(key, value); return true; } catch { return false; } } function gmDeleteValue(key) { const fn = gmFunction("GM_deleteValue"); if (!fn) return false; try { fn(key); return true; } catch { return false; } } const ManagerCompatibility = { required: ["GM_xmlhttpRequest", "GM_registerMenuCommand"], degraded: ["GM_openInTab", "GM_getValue", "GM_setValue", "GM_deleteValue", "GM_unregisterMenuCommand"], report() { const names = [...this.required, ...this.degraded]; const available = names.reduce((result, name) => { result[name] = !!gmFunction(name); return result; }, {}); const missingRequired = this.required.filter(name => !available[name]); const warnings = []; if (!available.GM_openInTab) warnings.push("GM_openInTab unavailable; external pages fall back to window.open."); if (!available.GM_getValue || !available.GM_setValue) warnings.push("GM storage unavailable; settings may not persist in this manager."); if (!available.GM_deleteValue) warnings.push("GM_deleteValue unavailable; reset may only reload default settings in this manager."); if (!available.GM_unregisterMenuCommand) warnings.push("GM_unregisterMenuCommand unavailable; stale menu entries may remain until reload."); if (COMPAT_STATE.trustedTypes.status === "renamed") warnings.push("Trusted Types base policy already existed; using a per-run fallback policy name."); if (COMPAT_STATE.trustedTypes.status === "fallback") warnings.push("Trusted Types policy creation failed; strict CSP pages may block modal rendering."); return { ok: missingRequired.length === 0, canMenu: available.GM_registerMenuCommand, canFetch: available.GM_xmlhttpRequest, missingRequired, warnings, available, trustedTypes: { ...COMPAT_STATE.trustedTypes }, manager: this.managerInfo() }; }, managerInfo() { const info = globalThis?.GM_info || {}; return { handler: info.scriptHandler || info.script_handler || info.handler || "unknown", version: info.version || info.scriptHandlerVersion || "unknown", scriptName: info.script?.name || "UserScript Finder" }; }, diagnosticLines(report = this.report()) { return [ `manager=${report.manager.handler}`, `managerVersion=${report.manager.version}`, ...Object.keys(report.available).sort().map(name => `${name}=${report.available[name] ? "ok" : "missing"}`), `trustedTypes=${report.trustedTypes.status}`, `trustedTypesPolicy=${report.trustedTypes.policyName || "none"}`, `compatWarnings=${report.warnings.length}`, `compatMissingRequired=${report.missingRequired.join(",") || "none"}` ]; } }; const MatchCoverage = { evaluate(source, displayHost, currentUrl) { const meta = this.extractUserScriptMetadata(source); const coverage = [ ...meta.match.map(pattern => ({ type: "match", pattern })), ...meta.include.map(pattern => ({ type: "include", pattern })) ]; const matching = coverage.filter(entry => this.patternCoversUrl(entry.pattern, currentUrl, entry.type)); const excluded = meta.exclude.filter(pattern => this.patternCoversUrl(pattern, currentUrl, "exclude")); if (!coverage.length) { return { status: "warn", title: "No match metadata found", detail: "This script has no @match or @include lines in the metadata block.", patterns: [] }; } if (excluded.length) { return { status: "bad", title: `Excluded on ${displayHost}`, detail: "An @exclude pattern matches this page.", patterns: excluded.slice(0, 3) }; } if (matching.length) { return { status: "good", title: `Covers ${displayHost}`, detail: "At least one @match or @include entry applies to this page.", patterns: matching.map(entry => entry.pattern).slice(0, 3) }; } return { status: "bad", title: `No match for ${displayHost}`, detail: "The metadata block did not include a pattern that applies to this page.", patterns: coverage.map(entry => entry.pattern).slice(0, 3) }; }, extractUserScriptMetadata(source) { const meta = { match: [], include: [], exclude: [] }; const lines = String(source || "").split(/\r?\n/).slice(0, 400); for (const line of lines) { if (/==\/UserScript==/.test(line)) break; const found = line.match(/^\s*\/\/\s*@(match|include|exclude)\s+(.+?)\s*$/i); if (found) meta[found[1].toLowerCase()].push(found[2].trim()); } return meta; }, patternCoversUrl(pattern, currentUrl, type = "include") { const trimmed = String(pattern || "").trim(); if (!trimmed) return false; if (trimmed === "") return true; const url = this.parseUrl(currentUrl); if (!url) return false; if (type === "match") return this.matchBrowserPattern(trimmed, url); return this.matchIncludePattern(trimmed, url); }, parseUrl(currentUrl) { try { return new URL(currentUrl); } catch { return null; } }, matchBrowserPattern(pattern, url) { const found = String(pattern || "").match(/^(\*|https?|file|ftp):\/\/([^/]*)(\/.*)?$/i); if (!found) return false; const [, rawScheme, rawHost, rawPath] = found; const scheme = rawScheme.toLowerCase(); const urlScheme = url.protocol.replace(/:$/, "").toLowerCase(); if (scheme === "*" ? !["http", "https"].includes(urlScheme) : scheme !== urlScheme) return false; if (urlScheme !== "file" && !this.hostMatchesPattern(url.hostname, rawHost)) return false; return this.wildcardMatches(rawPath || "/", this.urlPath(url)); }, matchIncludePattern(pattern, url) { if (/^\/.+\/[a-z]*$/i.test(pattern)) { const lastSlash = pattern.lastIndexOf("/"); try { return new RegExp(pattern.slice(1, lastSlash), pattern.slice(lastSlash + 1)).test(url.href); } catch { return false; } } return this.wildcardMatches(pattern, url.href); }, hostMatchesPattern(host, pattern) { const current = String(host || "").toLowerCase(); const candidate = String(pattern || "").toLowerCase(); if (candidate === "*" || candidate === current) return true; if (candidate.startsWith("*.")) { const root = candidate.slice(2); return current === root || current.endsWith(`.${root}`); } if (candidate.startsWith("*")) return current.endsWith(candidate.slice(1)); return false; }, wildcardMatches(pattern, value) { const regex = "^" + String(pattern || "").split("*").map(part => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"; try { return new RegExp(regex, "i").test(String(value || "")); } catch { return false; } }, urlPath(url) { return `${url.pathname || "/"}${url.search || ""}${url.hash || ""}`; } }; if (typeof window !== "undefined" && window.__SF_TEST_HOOKS__) { window.__SF_TEST_HOOKS__.MatchCoverage = MatchCoverage; } const SourceRuntime = { timeoutMs: 12000, sourceLabels: SOURCE_ORDER.reduce((labels, source) => { labels[source] = SOURCE_META[source].label; return labels; }, {}), label(service) { return this.sourceLabels[service?.serviceName] || service?.serviceName || "Source"; }, freshCache(service, cacheKey, settings) { const cacheDuration = settings.get("cacheDuration"); const cached = service.cache.get(cacheKey); if (cached && Date.now() - cached.timestamp < cacheDuration) return { cached, data: this.withHealth(cached.data, { type: "cached", title: `${this.label(service)} cache hit`, detail: `Using cached results from ${this.ageLabel(cached.timestamp)}.`, checkedAt: Date.now(), cachedAt: cached.timestamp }) }; return { cached, data: null }; }, backoffFallback(service, cacheKey, cached) { const until = service._sfBackoff?.get(cacheKey) || 0; if (until <= Date.now()) return null; const seconds = Math.max(1, Math.ceil((until - Date.now()) / 1000)); if (cached) return this.withStatus(cached.data, { type: "stale", title: `${this.label(service)} is backing off`, detail: `Showing cached results from ${this.ageLabel(cached.timestamp)}. Retry opens in ${seconds}s.`, checkedAt: Date.now(), cachedAt: cached.timestamp }); throw this.error(this.label(service), `Waiting ${seconds}s before retrying after a source failure.`, "backoff"); }, saveCache(service, cacheKey, data) { const clean = Array.isArray(data) ? data.slice() : data; const timestamp = Date.now(); service.cache.set(cacheKey, { data: clean, timestamp }); return this.withHealth(data, { type: "ok", title: `${this.label(service)} loaded`, detail: "Fresh source results loaded.", checkedAt: timestamp, cachedAt: timestamp }); }, staleOrThrow(service, cacheKey, cached, err) { this.noteBackoff(service, cacheKey, err); if (cached) return this.withStatus(cached.data, { type: "stale", title: `${this.label(service)} unavailable`, detail: `Showing cached results from ${this.ageLabel(cached.timestamp)} because ${this.cleanMessage(err)}.`, checkedAt: Date.now(), cachedAt: cached.timestamp }); throw err; }, noteBackoff(service, cacheKey, err) { const retryMs = err?.retryMs || (err?.kind === "rate-limit" ? 60000 : err?.kind === "timeout" ? 20000 : 10000); if (!service._sfBackoff) service._sfBackoff = new Map(); service._sfBackoff.set(cacheKey, Date.now() + retryMs); }, withStatus(data, status) { if (!Array.isArray(data)) return data; const copy = data.slice(); Object.defineProperty(copy, "_sfStatus", { value: status, enumerable: false }); Object.defineProperty(copy, "_sfHealth", { value: status, enumerable: false }); return copy; }, withHealth(data, health) { if (!Array.isArray(data)) return data; const copy = data.slice(); Object.defineProperty(copy, "_sfHealth", { value: health, enumerable: false }); return copy; }, ageLabel(timestamp) { const seconds = Math.max(1, Math.floor((Date.now() - timestamp) / 1000)); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); return `${hours}h ago`; }, cleanMessage(err) { return String(err?.message || "the source request failed").replace(/\.$/, ""); }, error(source, message, kind = "network", retryMs = null) { const err = new Error(`${source}: ${message}`); err.kind = kind; if (retryMs) err.retryMs = retryMs; return err; }, httpError(source, status) { if (status === 403 || status === 429) return this.error(source, `rate limited (HTTP ${status})`, "rate-limit", 60000); if (status >= 500) return this.error(source, `temporary server error (HTTP ${status})`, "server", 20000); return this.error(source, `HTTP ${status}`, "http", 10000); }, requestText(url, { source, headers = {}, notFound = "" } = {}) { return new Promise((resolve, reject) => { const request = gmFunction("GM_xmlhttpRequest"); if (!request) { reject(this.error(source, "GM_xmlhttpRequest is unavailable in this userscript manager", "compat", 0)); return; } let settled = false; const done = (fn, value) => { if (settled) return; settled = true; fn(value); }; request({ method: "GET", url, headers, timeout: this.timeoutMs, onload: r => { if (r.status >= 200 && r.status < 300) done(resolve, r.responseText || ""); else if (r.status === 404) done(resolve, notFound); else done(reject, this.httpError(source, r.status)); }, onerror: () => done(reject, this.error(source, "network request failed", "network", 10000)), ontimeout: () => done(reject, this.error(source, `timed out after ${Math.round(this.timeoutMs / 1000)}s`, "timeout", 20000)) }); }); }, async requestJson(url, options = {}) { const text = await this.requestText(url, options); if (typeof text !== "string") return text; try { return JSON.parse(text || "null"); } catch(e) { throw this.error(options.source, `invalid JSON (${e.message})`, "parse", 10000); } } }; if (typeof window !== "undefined" && window.__SF_TEST_HOOKS__) { window.__SF_TEST_HOOKS__.SourceRuntime = SourceRuntime; window.__SF_TEST_HOOKS__.SOURCE_META = SOURCE_META; window.__SF_TEST_HOOKS__.SOURCE_ORDER = SOURCE_ORDER; window.__SF_TEST_HOOKS__.ManagerCompatibility = ManagerCompatibility; } const InstallSafety = { allowlists: { greasyfork: ["https://update.greasyfork.org", "https://greasyfork.org"], sleazyfork: ["https://update.greasyfork.org", "https://sleazyfork.org"], openuserjs: ["https://openuserjs.org"], catalogs: [ "https://update.greasyfork.org", "https://greasyfork.org", "https://sleazyfork.org", "https://openuserjs.org", "https://raw.githubusercontent.com", "https://gist.githubusercontent.com" ], githubgist: ["https://gist.githubusercontent.com"] }, validateInstallUrl(script, rawUrl) { if (!rawUrl) return { ok: false, reason: "Missing install URL." }; let url; try { url = new URL(rawUrl); } catch { return { ok: false, reason: "Install URL is not a valid URL." }; } if (url.protocol !== "https:") return { ok: false, reason: "Install URL must use HTTPS." }; const source = script?._source || "greasyfork"; const allowed = this.allowlists[source] || []; if (!allowed.includes(url.origin)) return { ok: false, reason: `Install origin ${url.origin} is not trusted for ${SourceRuntime.sourceLabels[source] || source}.` }; if (!this.looksLikeUserScriptUrl(url)) return { ok: false, reason: "Install URL does not point to a .user.js payload." }; return { ok: true, url: url.href }; }, looksLikeUserScriptUrl(url) { const path = decodeURIComponent(url.pathname || ""); return /\.user\.js$/i.test(path); }, hasUserScriptMetadata(source) { const text = String(source || ""); const block = text.match(/==UserScript==([\s\S]{0,12000}?)==\/UserScript==/i); return !!(block && /\/\/\s*@name\s+\S+/i.test(block[1])); }, dangerousGrants(source) { const dangerous = ["GM_xmlhttpRequest", "GM.xmlHttpRequest", "unsafeWindow", "window.close", "window.focus"]; const text = String(source || ""); const block = text.match(/==UserScript==([\s\S]{0,12000}?)==\/UserScript==/i); if (!block) return []; const grants = [...block[1].matchAll(/\/\/\s*@grant\s+(\S+)/gi)].map(m => m[1]); return grants.filter(g => dangerous.includes(g)); } }; if (typeof window !== "undefined" && window.__SF_TEST_HOOKS__) { window.__SF_TEST_HOOKS__.InstallSafety = InstallSafety; } // ── Settings Service ──────────────────────────────────────────────── class SettingsService { constructor() { this.settings = this.loadSettings(); } loadSettings() { const saved = gmGetValue("sf_settings_v4", {}) || {}; const settings = { ...DEFAULT_SETTINGS, ...saved }; settings.sources = this.normalizeSources(saved.sources); settings.sensitiveHostProtection = saved.sensitiveHostProtection !== false; settings.sensitiveHostPatterns = typeof saved.sensitiveHostPatterns === "string" ? saved.sensitiveHostPatterns : ""; settings.sensitiveHostOverrides = this.normalizeHostList(saved.sensitiveHostOverrides); settings.disclosureAckedSources = Array.isArray(saved.disclosureAckedSources) ? saved.disclosureAckedSources.filter(s => SOURCE_ORDER.includes(s)) : []; const sourceChanged = SOURCE_ORDER.some(source => saved.sources?.[source] !== settings.sources[source]); const hostSettingsChanged = settings.sensitiveHostProtection !== saved.sensitiveHostProtection || settings.sensitiveHostPatterns !== saved.sensitiveHostPatterns || JSON.stringify(settings.sensitiveHostOverrides) !== JSON.stringify(saved.sensitiveHostOverrides || []); let serviceChanged = false; if (!settings.sources[settings.lastService]) { settings.lastService = SOURCE_ORDER.find(source => settings.sources[source]) || "greasyfork"; serviceChanged = true; } if (sourceChanged || serviceChanged || hostSettingsChanged) gmSetValue("sf_settings_v4", settings); return settings; } normalizeSources(savedSources = {}) { const safeSources = savedSources && typeof savedSources === "object" ? savedSources : {}; const normalized = {}; SOURCE_ORDER.forEach(source => { normalized[source] = safeSources[source] !== false; }); if (!SOURCE_ORDER.some(source => normalized[source])) normalized.greasyfork = true; return normalized; } normalizeHostList(value) { if (!Array.isArray(value)) return []; return [...new Set(value.map(host => HostService.normalizeHost(host)).filter(Boolean))]; } saveSettings() { gmSetValue("sf_settings_v4", this.settings); } get(key) { return this.settings[key]; } set(key, value) { this.settings[key] = value; this.saveSettings(); } watchForChanges(callback) { const listener = gmFunction("GM_addValueChangeListener"); if (listener) { try { listener("sf_settings_v4", (_name, _oldVal, newVal, remote) => { if (!remote || !newVal) return; this.settings = { ...DEFAULT_SETTINGS, ...newVal }; this.settings.sources = this.normalizeSources(newVal.sources); callback(); }); return; } catch {} } let lastJson = JSON.stringify(this.settings); setInterval(() => { const saved = gmGetValue("sf_settings_v4", {}) || {}; const json = JSON.stringify(saved); if (json !== lastJson) { lastJson = json; this.settings = { ...DEFAULT_SETTINGS, ...saved }; this.settings.sources = this.normalizeSources(saved.sources); callback(); } }, 3000); } } // ── Host Service ──────────────────────────────────────────────────── class HostService { static publicSuffixes = new Set([ "ac.uk", "co.uk", "gov.uk", "ltd.uk", "me.uk", "net.uk", "nhs.uk", "org.uk", "plc.uk", "sch.uk", "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au", "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "com.br", "net.br", "org.br", "gov.br", "com.cn", "net.cn", "org.cn", "gov.cn", "co.nz", "net.nz", "org.nz", "ac.nz", "govt.nz", "co.in", "firm.in", "net.in", "org.in", "gen.in", "ind.in", "com.mx", "org.mx", "gob.mx", "edu.mx", "net.mx", "com.tr", "net.tr", "org.tr", "gov.tr", "github.io", "gitlab.io", "pages.dev", "netlify.app", "vercel.app", "herokuapp.com", "blogspot.com", "wordpress.com", "firebaseapp.com", "web.app", "azurewebsites.net", "cloudfront.net" ]); static getCurrentHost() { return this.normalizeHost(window.location.hostname); } static normalizeHost(host) { let value = String(host || "").trim().toLowerCase().replace(/\.$/, ""); if (!value) return ""; if (value.startsWith("[") && value.endsWith("]")) return value.slice(1, -1); if (this.isIpAddress(value) || value === "localhost") return value; return value.replace(/^(www\.|m\.|mobile\.)/, ""); } static extractRootDomain(host) { const normalized = this.normalizeHost(host); if (!normalized || normalized === "localhost" || this.isIpAddress(normalized)) return normalized; const parts = normalized.split(".").filter(Boolean); if (parts.length <= 2) return normalized; const suffixLength = this.publicSuffixLength(parts); const rootLength = Math.min(parts.length, suffixLength + 1); return parts.slice(-rootLength).join("."); } static publicSuffixLength(parts) { for (let len = Math.min(parts.length, 3); len >= 2; len--) { if (this.publicSuffixes.has(parts.slice(-len).join("."))) return len; } return 1; } static isIpAddress(host) { return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) || /^[0-9a-f:]+$/i.test(host); } static patternList(value) { return String(value || "") .split(/[\n,]+/) .map(pattern => this.normalizePattern(pattern)) .filter(Boolean); } static normalizePattern(pattern) { let value = String(pattern || "").trim().toLowerCase(); if (!value || value.startsWith("#")) return ""; value = value.replace(/^[a-z]+:\/\//, "").split(/[/?#]/)[0].replace(/\.$/, ""); if (value.startsWith(".")) value = `*${value}`; return value; } static patternMatches(host, pattern) { const normalizedHost = this.normalizeHost(host); const normalizedPattern = this.normalizePattern(pattern); if (!normalizedHost || !normalizedPattern) return false; if (!normalizedPattern.includes("*")) { return normalizedHost === normalizedPattern || normalizedHost.endsWith(`.${normalizedPattern}`); } const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); return new RegExp(`^${escaped}$`).test(normalizedHost); } static isHostOverridden(host, settings) { const overrides = Array.isArray(settings?.get?.("sensitiveHostOverrides")) ? settings.get("sensitiveHostOverrides") : []; return overrides.some(pattern => this.patternMatches(host, pattern)); } static sensitiveHostMatch(host, settings) { const normalizedHost = this.normalizeHost(host); if (!normalizedHost || settings?.get?.("sensitiveHostProtection") === false) return null; if (this.isHostOverridden(normalizedHost, settings)) return null; const userPatterns = this.patternList(settings?.get?.("sensitiveHostPatterns")); const patterns = [...DEFAULT_SENSITIVE_HOST_PATTERNS, ...userPatterns]; const pattern = patterns.find(candidate => this.patternMatches(normalizedHost, candidate)); return pattern ? { host: normalizedHost, pattern } : null; } } if (typeof window !== "undefined" && window.__SF_TEST_HOOKS__) { window.__SF_TEST_HOOKS__.HostService = HostService; } // ── Script Service ────────────────────────────────────────────────── class ScriptService { constructor(baseUrl, serviceName) { this.baseUrl = baseUrl; this.serviceName = serviceName; this.cache = new Map(); } async searchScriptsByHost(host, settings) { const exactHost = HostService.normalizeHost(host); let scripts = await this._searchWithDomain(exactHost, settings); if (scripts.length === 0) { const root = HostService.extractRootDomain(exactHost); if (root !== exactHost) { const fallback = await this._searchWithDomain(root, settings); scripts = await this._labelRootFallbackCoverage(fallback, exactHost, root); } } return scripts; } async _searchWithDomain(domain, settings) { const cacheKey = `${this.serviceName}_${domain}`; const { cached, data } = SourceRuntime.freshCache(this, cacheKey, settings); if (data) return data; const backoff = SourceRuntime.backoffFallback(this, cacheKey, cached); if (backoff) return backoff; let scripts = []; try { scripts = await this._fetchBySite(domain); } catch(err) { try { scripts = await this._fetchSearch(domain); } catch(searchErr) { return SourceRuntime.staleOrThrow(this, cacheKey, cached, searchErr || err); } } const filtered = this._filter(scripts, domain); return SourceRuntime.saveCache(this, cacheKey, filtered); } _fetch(url) { return SourceRuntime.requestJson(url, { source: SourceRuntime.label(this), headers: { Accept: "application/json" }, notFound: "[]" }); } _fetchBySite(domain) { return this._fetch(`${this.baseUrl}/scripts/by-site/${domain}.json`); } _fetchSearch(domain) { return this._fetch(`${this.baseUrl}/scripts.json?q=${encodeURIComponent(domain)}&sort=updated`); } _filter(scripts, domain) { const root = HostService.extractRootDomain(domain); return scripts.filter(s => { if (!s.domains) return true; return s.domains.some(d => d === domain || d === `*.${domain}` || d === root || d === `*.${root}` || domain.includes(d.replace('*.','')) || d.replace('*.','').includes(domain) ); }).slice(0, 200); } async _labelRootFallbackCoverage(scripts, exactHost, rootHost) { if (!Array.isArray(scripts) || !scripts.length) return scripts; const labeled = await Promise.all(scripts.map(script => this._withRootFallbackCoverage(script, exactHost))); const counts = labeled.reduce((acc, script) => { acc[script._hostCoverage || "uncertain"] = (acc[script._hostCoverage || "uncertain"] || 0) + 1; return acc; }, {}); return SourceRuntime.withStatus(labeled, { type: "partial", title: "Root-domain fallback checked", detail: `No exact-host results for ${exactHost}; showing ${rootHost} results labeled by metadata coverage (${counts.exact || 0} exact, ${counts.broad || 0} broad, ${counts.uncertain || 0} uncertain).`, checkedAt: Date.now() }); } async _withRootFallbackCoverage(script, exactHost) { const copy = { ...script }; const install = copy.code_url ? InstallSafety.validateInstallUrl(copy, copy.code_url) : null; if (!install?.ok) { return this._setHostCoverage(copy, "uncertain", "Coverage uncertain", install?.reason || "No install URL available for metadata coverage."); } try { const source = await SourceRuntime.requestText(install.url, { source: SourceRuntime.label(this), headers: { Accept: "text/plain,*/*" }, notFound: "" }); const coverage = MatchCoverage.evaluate(source, exactHost, this._currentUrlForHost(exactHost)); copy._matchPreview = coverage; if (coverage.status === "good") return this._setHostCoverage(copy, "exact", "Exact host", coverage.title); if (coverage.status === "bad") return this._setHostCoverage(copy, "broad", "Broad/root match", coverage.title); return this._setHostCoverage(copy, "uncertain", "Coverage uncertain", coverage.title); } catch(err) { return this._setHostCoverage(copy, "uncertain", "Coverage uncertain", err?.message || "Metadata coverage check failed."); } } _setHostCoverage(script, kind, label, detail) { script._hostCoverage = kind; script._hostCoverageLabel = label; script._hostCoverageDetail = detail; script._hostCoverageClass = kind === "exact" ? "coverage-exact" : kind === "broad" ? "coverage-broad" : "coverage-uncertain"; return script; } _currentUrlForHost(host) { try { const current = new URL(window.location.href); current.hostname = host; return current.href; } catch { return `https://${host}/`; } } getDirectSearchUrl(domain) { return `${this.baseUrl}/scripts/by-site/${domain}`; } } // OpenUserJS search is HTML-only; parse its script table into the shared result shape. class OpenUserJSScriptService { constructor() { this.serviceName = "openuserjs"; this.baseUrl = "https://openuserjs.org"; this.cache = new Map(); } async searchScriptsByHost(host, settings) { let scripts = await this._searchWithDomain(host, settings); if (scripts.length === 0) { const root = HostService.extractRootDomain(host); if (root !== host) scripts = await this._searchWithDomain(root, settings); } return scripts; } async _searchWithDomain(domain, settings) { const cacheKey = `openuserjs_${domain}`; const { cached, data } = SourceRuntime.freshCache(this, cacheKey, settings); if (data) return data; const backoff = SourceRuntime.backoffFallback(this, cacheKey, cached); if (backoff) return backoff; let scripts = []; try { const html = await this._fetchSearch(domain); scripts = this._parseSearchResults(html); } catch(err) { return SourceRuntime.staleOrThrow(this, cacheKey, cached, err); } const filtered = this._filter(scripts, domain); return SourceRuntime.saveCache(this, cacheKey, filtered); } _fetchSearch(domain) { return this._fetch(`${this.baseUrl}/?q=${encodeURIComponent(domain)}&orderBy=updated&orderDir=desc`); } _fetch(url) { return SourceRuntime.requestText(url, { source: SourceRuntime.label(this), headers: { Accept: "text/html,application/xhtml+xml" }, notFound: "" }); } _parseSearchResults(html) { if (!html || typeof DOMParser !== "function") return []; const doc = new DOMParser().parseFromString(html, "text/html"); const rows = Array.from(doc.querySelectorAll("tbody tr.tr-link")); const seen = new Set(); return rows.map(row => this._normalizeRow(row)).filter(script => { if (!script || seen.has(script.url)) return false; seen.add(script.url); return true; }).slice(0, 100); } _normalizeRow(row) { const link = row.querySelector('a.tr-link-a[href^="/scripts/"]') || row.querySelector('a[href^="/scripts/"]'); if (!link) return null; const pagePath = link.getAttribute("href"); const cells = Array.from(row.querySelectorAll("td")); const infoCell = cells[0] || row; const authorLink = infoCell.querySelector('span.inline-block a[href^="/users/"]') || infoCell.querySelector('a[href^="/users/"]'); const versionEl = infoCell.querySelector(".script-version"); const descEl = infoCell.querySelector("p"); const updatedEl = row.querySelector("time[datetime]"); const authorFromPath = this._decodePathSegment(pagePath.split("/")[2]); const installPath = pagePath.replace(/^\/scripts\//, "/install/") + ".user.js"; return { _source: "openuserjs", name: cleanText(link.textContent) || "Untitled", description: cleanText(descEl?.textContent) || "", url: this.baseUrl + pagePath, code_url: this.baseUrl + installPath, version: cleanText(versionEl?.textContent) || null, license: null, users: [{ name: cleanText(authorLink?.textContent) || authorFromPath || null }], daily_installs: null, total_installs: this._parseNumber(cells[1]?.textContent), good_ratings: this._parseNumber(cells[2]?.textContent), fan_score: null, code_updated_at: updatedEl?.getAttribute("datetime") || null, created_at: null, _full_name: pagePath.replace(/^\/scripts\//, ""), _topics: [] }; } _filter(scripts) { return scripts.slice(0, 100); } _parseNumber(text) { const cleaned = String(text || "").replace(/,/g, "").match(/\d+(?:\.\d+)?/); return cleaned ? Number(cleaned[0]) : null; } _decodePathSegment(segment) { if (!segment) return null; try { return decodeURIComponent(segment.replace(/\+/g, "%20")); } catch { return segment; } } getDirectSearchUrl(domain) { return `${this.baseUrl}/?q=${encodeURIComponent(domain)}`; } } // Chrome Web Store has no public search API; parse embedded extension result records. class ChromeWebStoreService { constructor() { this.serviceName = "chromewebstore"; this.baseUrl = "https://chromewebstore.google.com"; this.cache = new Map(); } async searchScriptsByHost(host, settings) { let extensions = await this._searchWithDomain(host, settings); if (extensions.length === 0) { const root = HostService.extractRootDomain(host); if (root !== host) extensions = await this._searchWithDomain(root, settings); } return extensions; } async _searchWithDomain(domain, settings) { const cacheKey = `chromewebstore_${domain}`; const { cached, data } = SourceRuntime.freshCache(this, cacheKey, settings); if (data) return data; const backoff = SourceRuntime.backoffFallback(this, cacheKey, cached); if (backoff) return backoff; let extensions = []; try { const html = await this._fetchSearch(domain); extensions = this._parseSearchResults(html); } catch(err) { return SourceRuntime.staleOrThrow(this, cacheKey, cached, err); } return SourceRuntime.saveCache(this, cacheKey, extensions); } _fetchSearch(domain) { return this._fetch(this.getDirectSearchUrl(domain)); } _fetch(url) { return SourceRuntime.requestText(url, { source: SourceRuntime.label(this), headers: { Accept: "text/html,application/xhtml+xml" }, notFound: "" }); } _parseSearchResults(html) { const records = this._extractExtensionRecords(html); return records.map(record => this._normalize(record)).filter(Boolean).slice(0, 25); } _extractExtensionRecords(html) { if (!html) return []; const records = []; const seen = new Set(); const re = /\[\[\["([a-p]{32})",/g; let match; while ((match = re.exec(html)) && records.length < 30) { const start = match.index + 2; const text = this._extractBalancedArray(html, start); if (!text) continue; try { const record = JSON.parse(text); if (record?.[0] && !seen.has(record[0])) { seen.add(record[0]); records.push(record); } } catch { /* Ignore malformed embedded records. */ } } return records; } _extractBalancedArray(text, start) { let depth = 0; let inString = false; let escape = false; for (let i = start; i < text.length; i++) { const ch = text[i]; if (inString) { if (escape) escape = false; else if (ch === "\\") escape = true; else if (ch === '"') inString = false; continue; } if (ch === '"') inString = true; else if (ch === "[") depth++; else if (ch === "]") { depth--; if (depth === 0) return text.slice(start, i + 1); } } return null; } _normalize(record) { const id = record[0]; const name = cleanText(record[2] || record[19]); if (!id || !name) return null; const manifest = this._parseManifest(record[18]); const manifestPermissions = splitExtensionPermissions(manifest.permissions, [ manifest.host_permissions, manifest.optional_host_permissions, (manifest.content_scripts || []).map(script => script?.matches || []) ]); const optionalPermissions = splitExtensionPermissions(manifest.optional_permissions); const updated = Array.isArray(record[17]) && Number.isFinite(record[17][0]) ? new Date((record[17][0] * 1000) + Math.round((record[17][1] || 0) / 1000000)).toISOString() : null; return { _source: "chromewebstore", name, description: cleanText(record[6] || manifest.description) || "", url: `${this.baseUrl}/detail/${this._slugify(name)}/${id}`, code_url: null, version: manifest.version || null, license: null, users: [{ name: manifest.author || null }], daily_installs: null, total_installs: Number.isFinite(record[14]) ? record[14] : null, good_ratings: Number.isFinite(record[3]) ? record[3] : null, fan_score: null, code_updated_at: updated, created_at: null, _rating: Number.isFinite(record[3]) ? record[3] : null, _rating_count: Number.isFinite(record[4]) ? record[4] : null, _category: Array.isArray(record[11]) ? record[11][0] : null, _image: record[1] || null, _full_name: id, _topics: [], _permissions: manifestPermissions.permissions, _optional_permissions: optionalPermissions.permissions, _host_permissions: manifestPermissions.hostPermissions, _privacy_policy_url: firstString(manifest.privacy_policy_url, manifest.privacy_policy), _data_collection: normalizeStringList(manifest.data_collection || manifest.dataCollection || manifest.data_collection_permissions), _promoted: firstString(manifest.promoted_status, manifest.promoted, manifest.featured ? "Featured" : null) }; } _parseManifest(text) { if (!text) return {}; try { return JSON.parse(text); } catch { return {}; } } _slugify(text) { return cleanText(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "extension"; } getDirectSearchUrl(domain) { return `${this.baseUrl}/search/${encodeURIComponent(domain)}?hl=en`; } } class MozillaAddonsService { constructor() { this.serviceName = "mozillaaddons"; this.baseUrl = "https://addons.mozilla.org"; this.cache = new Map(); } async searchScriptsByHost(host, settings) { let extensions = await this._searchWithDomain(host, settings); if (extensions.length === 0) { const root = HostService.extractRootDomain(host); if (root !== host) extensions = await this._searchWithDomain(root, settings); } return extensions; } async _searchWithDomain(domain, settings) { const cacheKey = `mozillaaddons_${domain}`; const { cached, data } = SourceRuntime.freshCache(this, cacheKey, settings); if (data) return data; const backoff = SourceRuntime.backoffFallback(this, cacheKey, cached); if (backoff) return backoff; let extensions = []; try { const data = await this._fetchSearch(domain); extensions = (data?.results || []).map(addon => this._normalize(addon)).filter(Boolean).slice(0, 25); } catch(err) { return SourceRuntime.staleOrThrow(this, cacheKey, cached, err); } return SourceRuntime.saveCache(this, cacheKey, extensions); } _fetchSearch(domain) { return this._fetch(`${this.baseUrl}/api/v5/addons/search/?q=${encodeURIComponent(domain)}&type=extension&page_size=25`); } _fetch(url) { return SourceRuntime.requestJson(url, { source: SourceRuntime.label(this), headers: { Accept: "application/json" }, notFound: "{\"results\":[]}" }); } _normalize(addon) { const name = this._localized(addon.name, addon.default_locale); if (!name || !addon.url) return null; const rating = Number(addon.ratings?.average); const ratingCount = Number(addon.ratings?.count); const users = Number(addon.average_daily_users); const weeklyDownloads = Number(addon.weekly_downloads); const author = addon.authors?.[0]?.name || addon.authors?.[0]?.username || null; const license = addon.current_version?.license; const currentFile = Array.isArray(addon.current_version?.files) ? addon.current_version.files[0] : null; const requiredPermissions = splitExtensionPermissions([addon.permissions, currentFile?.permissions], [ addon.host_permissions, currentFile?.host_permissions, currentFile?.origins, currentFile?.matches ]); const optionalPermissions = splitExtensionPermissions([addon.optional_permissions, currentFile?.optional_permissions], [ addon.optional_host_permissions, currentFile?.optional_host_permissions ]); return { _source: "mozillaaddons", name, description: this._stripHtml(this._localized(addon.summary, addon.default_locale) || this._localized(addon.description, addon.default_locale)), url: addon.url, code_url: null, version: addon.current_version?.version || null, license: this._localized(license?.name, addon.default_locale) || license?.slug || null, users: [{ name: author }], daily_installs: null, total_installs: Number.isFinite(users) ? users : null, good_ratings: Number.isFinite(rating) ? rating : null, fan_score: null, code_updated_at: addon.last_updated || addon.current_version?.reviewed || null, created_at: addon.created || null, _rating: Number.isFinite(rating) ? rating : null, _rating_count: Number.isFinite(ratingCount) ? ratingCount : null, _weekly_downloads: Number.isFinite(weeklyDownloads) ? weeklyDownloads : null, _category: addon.categories?.[0] || null, _image: addon.icon_url || null, _full_name: addon.slug || String(addon.id || ""), _topics: [...(addon.tags || []), ...(addon.categories || [])], _permissions: requiredPermissions.permissions, _optional_permissions: optionalPermissions.permissions, _host_permissions: [...requiredPermissions.hostPermissions, ...optionalPermissions.hostPermissions], _privacy_policy_url: firstString(addon.privacy_policy_url, addon.privacy_policy, addon.privacy?.policy_url), _data_collection: normalizeStringList(addon.data_collection_permissions || addon.data_collection || addon.privacy?.data_collection || currentFile?.data_collection_permissions), _promoted: firstString(addon.promoted?.category, addon.promoted?.name) }; } _localized(value, locale) { if (!value) return ""; if (typeof value === "string") return value; return value["en-US"] || value[locale] || value[Object.keys(value)[0]] || ""; } _stripHtml(value) { const doc = new DOMParser().parseFromString(String(value || ""), "text/html"); return (doc.body?.textContent || "").replace(/\s+/g, " ").trim(); } getDirectSearchUrl(domain) { return `${this.baseUrl}/en-US/firefox/search/?q=${encodeURIComponent(domain)}&type=extension`; } } // ── GitHub Script Service ─────────────────────────────────────────── class GitHubScriptService { constructor() { this.serviceName = "github"; this.cache = new Map(); } async searchScriptsByHost(host, settings) { const cacheKey = `github_${host}`; const { cached, data } = SourceRuntime.freshCache(this, cacheKey, settings); if (data) return data; const backoff = SourceRuntime.backoffFallback(this, cacheKey, cached); if (backoff) return backoff; let results = []; const errors = []; const queries = [ `${host} userscript`, `${host} tampermonkey`, `${host} greasemonkey` ]; const seen = new Set(); for (const q of queries) { try { const data = await this._fetchAPI( `https://api.github.com/search/repositories?q=${encodeURIComponent(q)}+language:javascript&sort=stars&per_page=20` ); if (data?.items) { for (const repo of data.items) { if (!seen.has(repo.full_name)) { seen.add(repo.full_name); results.push(this._normalize(repo)); } } } } catch(err) { errors.push(err); if (err?.kind === "rate-limit") break; } } if (!results.length && errors.length) return SourceRuntime.staleOrThrow(this, cacheKey, cached, errors[0]); const saved = SourceRuntime.saveCache(this, cacheKey, results); if (errors.length) return SourceRuntime.withStatus(saved, { type: "partial", title: "GitHub partially loaded", detail: `${errors.length} search ${errors.length === 1 ? "query" : "queries"} failed; showing successful repository matches.`, checkedAt: Date.now(), cachedAt: Date.now() }); return saved; } _fetchAPI(url) { return SourceRuntime.requestJson(url, { source: SourceRuntime.label(this), headers: { Accept: "application/vnd.github.v3+json", "User-Agent": "ScriptFinder/4" }, notFound: "{\"items\":[]}" }); } _normalize(repo) { return { _source: "github", name: repo.name, description: repo.description || "", url: repo.html_url, code_url: null, version: null, license: repo.license?.spdx_id || null, users: [{ name: repo.owner?.login }], daily_installs: null, total_installs: null, good_ratings: repo.stargazers_count || 0, fan_score: null, code_updated_at: repo.updated_at, created_at: repo.created_at, _stars: repo.stargazers_count || 0, _forks: repo.forks_count || 0, _language: repo.language, _topics: repo.topics || [], _owner: repo.owner?.login, _full_name: repo.full_name }; } getDirectSearchUrl(domain) { return `https://github.com/search?q=${encodeURIComponent(domain + ' userscript')}&type=repositories&l=JavaScript`; } } // ── Toast Service ─────────────────────────────────────────────────── class CatalogScriptService { constructor() { this.serviceName = "catalogs"; this.awesomeUrl = "https://raw.githubusercontent.com/awesome-scripts/awesome-userscripts/master/README.md"; this.tampermonkeyUrl = "https://www.tampermonkey.net/scripts.php?locale=en"; this.cache = new Map(); } async searchScriptsByHost(host, settings) { const domain = HostService.extractRootDomain(host); const cacheKey = `catalogs_${domain}`; const { cached, data } = SourceRuntime.freshCache(this, cacheKey, settings); if (data) return data; const backoff = SourceRuntime.backoffFallback(this, cacheKey, cached); if (backoff) return backoff; const results = []; const seen = new Set(); const errors = []; try { const html = await this._fetchText(this.tampermonkeyUrl); for (const item of this._parseTampermonkeyCatalog(html, domain)) { if (!seen.has(item._full_name)) { seen.add(item._full_name); results.push(item); } } } catch(err) { errors.push(err); } try { const markdown = await this._fetchText(this.awesomeUrl); for (const item of this._parseAwesomeUserscripts(markdown, domain)) { if (!seen.has(item._full_name)) { seen.add(item._full_name); results.push(item); } } } catch(err) { errors.push(err); } if (!results.length && errors.length) return SourceRuntime.staleOrThrow(this, cacheKey, cached, errors[0]); const saved = SourceRuntime.saveCache(this, cacheKey, results); if (errors.length) return SourceRuntime.withStatus(saved, { type: "partial", title: "Catalogs partially loaded", detail: `${errors.length} catalog ${errors.length === 1 ? "source" : "sources"} failed; showing the catalog results that loaded.`, checkedAt: Date.now(), cachedAt: Date.now() }); return saved; } _fetchText(url) { return SourceRuntime.requestText(url, { source: SourceRuntime.label(this), headers: { Accept: "text/plain,text/html,application/xhtml+xml" }, notFound: "" }); } _parseTampermonkeyCatalog(html, domain) { if (!html || !/userscript\.zone/i.test(html)) return []; const searchUrl = this._userscriptZoneUrl(domain); return [{ _source: "catalogs", name: `Userscript.Zone search for ${domain}`, description: "Tampermonkey's catalog handoff for finding matching userscripts by domain, URL, or keyword.", url: searchUrl, code_url: null, version: null, license: null, users: [{ name: "Tampermonkey" }], daily_installs: null, total_installs: null, good_ratings: null, fan_score: null, code_updated_at: null, created_at: null, _catalog_source: "Tampermonkey", _category: "Search portal", _full_name: `tampermonkey/userscript-zone/${domain}`, _topics: ["tampermonkey", "userscript.zone", domain] }]; } _parseAwesomeUserscripts(markdown, domain) { if (!markdown || typeof DOMParser !== "function") return []; const siteKey = this._siteKey(domain); const detailMatches = Array.from(markdown.matchAll(//gi)); const results = []; for (const match of detailMatches) { const block = match[0]; const category = this._categoryBefore(markdown, match.index || 0); const categoryMatch = this._textMatches(category, domain, siteKey); if (!categoryMatch && !this._strongAwesomeMatch(block, domain, siteKey)) continue; const item = this._normalizeAwesomeBlock(block, category, siteKey); if (item) results.push(item); } return results.slice(0, 80); } _normalizeAwesomeBlock(block, category, siteKey) { const doc = new DOMParser().parseFromString(`
${block}
`, "text/html"); const summary = doc.querySelector("summary"); const mainLink = summary?.querySelector("a[href]"); if (!summary || !mainLink) return null; const name = cleanText(mainLink.textContent) || "Curated userscript"; const summaryText = cleanText(summary.textContent); const description = this._descriptionFromSummary(summaryText, name); const installLink = Array.from(doc.querySelectorAll("a[href]")).find(link => { const href = link.getAttribute("href") || ""; return /\.user\.js(?:[?#].*)?$/i.test(href) || /update\.greasyfork\.org\/scripts\//i.test(href); }); const pageUrl = this._absoluteUrl(mainLink.getAttribute("href")); const installUrl = installLink ? this._absoluteUrl(installLink.getAttribute("href")) : null; const fullName = installUrl || pageUrl || `${category}/${name}`; return { _source: "catalogs", name, description, url: pageUrl, code_url: installUrl, version: null, license: null, users: [{ name: category ? `Awesome: ${category}` : "Awesome Userscripts" }], daily_installs: null, total_installs: null, good_ratings: null, fan_score: null, code_updated_at: null, created_at: null, _catalog_source: "Awesome Userscripts", _category: category || (siteKey ? siteKey[0].toUpperCase() + siteKey.slice(1) : "Curated"), _full_name: fullName, _topics: ["awesome", category, siteKey].filter(Boolean) }; } _categoryBefore(markdown, index) { const before = markdown.slice(0, index); const headings = before.match(/^###\s+.*$/gmi); if (!headings?.length) return null; return cleanText(headings[headings.length - 1].replace(/^###\s+/, "").replace(/<[^>]*>/g, " ")); } _strongAwesomeMatch(block, domain, siteKey) { const doc = new DOMParser().parseFromString(`
${block}
`, "text/html"); const summaryTitle = cleanText(doc.querySelector("summary a[href]")?.textContent); const hrefs = Array.from(doc.querySelectorAll("a[href]")).map(link => link.getAttribute("href") || "").join(" "); return this._textMatches(`${summaryTitle} ${hrefs}`, domain, siteKey); } _textMatches(text, domain, siteKey) { const haystack = String(text || "").toLowerCase(); return haystack.includes(String(domain || "").toLowerCase()) || (siteKey && haystack.includes(siteKey)); } _descriptionFromSummary(summaryText, name) { const withoutName = summaryText.replace(name, "").replace(/^\s*[-–—]\s*/, ""); return withoutName || "Curated userscript entry from Awesome Userscripts."; } _siteKey(domain) { const label = String(domain || "").split(".").find(part => part && !["www", "com", "net", "org", "io", "co"].includes(part)); return label ? label.toLowerCase() : null; } _userscriptZoneUrl(domain) { return `https://www.userscript.zone/search?q=${encodeURIComponent(domain)}&utm_source=tm.net&utm_medium=search`; } _absoluteUrl(href) { try { return new URL(href, "https://github.com/awesome-scripts/awesome-userscripts/").href; } catch { return href || null; } } getDirectSearchUrl(domain) { return this._userscriptZoneUrl(domain); } } class GitHubGistService { constructor() { this.serviceName = "githubgist"; this.baseUrl = "https://gist.github.com"; this.cache = new Map(); } async searchScriptsByHost(host, settings) { let scripts = await this._searchWithDomain(host, settings); if (scripts.length === 0) { const root = HostService.extractRootDomain(host); if (root !== host) scripts = await this._searchWithDomain(root, settings); } return scripts; } async _searchWithDomain(domain, settings) { const cacheKey = `githubgist_${domain}`; const { cached, data } = SourceRuntime.freshCache(this, cacheKey, settings); if (data) return data; const backoff = SourceRuntime.backoffFallback(this, cacheKey, cached); if (backoff) return backoff; const queries = [ `${domain} userscript`, `${domain} tampermonkey`, `${domain} greasemonkey` ]; const results = []; const seen = new Set(); const errors = []; for (const query of queries) { try { const html = await this._fetchSearch(query); for (const script of this._parseSearchResults(html)) { const key = script._full_name; if (!seen.has(key)) { seen.add(key); results.push(script); } } } catch(err) { errors.push(err); if (err?.kind === "rate-limit") break; } } if (!results.length && errors.length) return SourceRuntime.staleOrThrow(this, cacheKey, cached, errors[0]); const saved = SourceRuntime.saveCache(this, cacheKey, results); if (errors.length) return SourceRuntime.withStatus(saved, { type: "partial", title: "GitHub Gists partially loaded", detail: `${errors.length} gist search ${errors.length === 1 ? "query" : "queries"} failed; showing successful gist matches.`, checkedAt: Date.now(), cachedAt: Date.now() }); return saved; } _fetchSearch(query) { return this._fetch(`${this.baseUrl}/search?q=${encodeURIComponent(query)}`); } _fetch(url) { return SourceRuntime.requestText(url, { source: SourceRuntime.label(this), headers: { Accept: "text/html,application/xhtml+xml" }, notFound: "" }); } _parseSearchResults(html) { if (!html || typeof DOMParser !== "function") return []; const doc = new DOMParser().parseFromString(html, "text/html"); const snippets = Array.from(doc.querySelectorAll(".gist-snippet")); const seen = new Set(); return snippets.map(snippet => this._normalizeSnippet(snippet)).filter(script => { if (!script || seen.has(script._full_name)) return false; seen.add(script._full_name); return true; }).slice(0, 100); } _normalizeSnippet(snippet) { const fileLink = Array.from(snippet.querySelectorAll('a[href]')).find(link => { const href = link.getAttribute("href") || ""; return /^\/[^/]+\/[a-f0-9]{32}$/i.test(href) && link.querySelector(".css-truncate-target"); }); if (!fileLink) return null; const path = fileLink.getAttribute("href"); const match = path.match(/^\/([^/]+)\/([a-f0-9]{32})$/i); if (!match) return null; const owner = this._decodePathSegment(match[1]); const id = match[2]; const fileName = cleanText(fileLink.querySelector(".css-truncate-target")?.textContent) || id; const updatedEl = snippet.querySelector("relative-time[datetime]"); const descEl = snippet.querySelector(".gist-snippet-meta span.f6.color-fg-muted"); const fileCount = this._parseStat(snippet, /files?/i); const description = cleanText(descEl?.textContent) || this._extractMetadata(snippet, "description") || `${this._fileLabel(fileCount)} on GitHub Gist`; const isUserScript = /\.user\.js$/i.test(fileName); return { _source: "githubgist", name: fileName, description, url: this.baseUrl + path, code_url: isUserScript ? this._rawUrl(owner, id, fileName) : null, version: this._extractMetadata(snippet, "version"), license: null, users: [{ name: owner }], daily_installs: null, total_installs: null, good_ratings: this._parseStat(snippet, /stars?/i), fan_score: null, code_updated_at: updatedEl?.getAttribute("datetime") || null, created_at: null, _stars: this._parseStat(snippet, /stars?/i), _forks: this._parseStat(snippet, /forks?/i), _comments: this._parseStat(snippet, /comments?/i), _files: fileCount, _owner: owner, _full_name: `${owner}/${id}`, _topics: [fileName, "gist"].filter(Boolean) }; } _extractMetadata(snippet, key) { const matcher = new RegExp(`@${key}\\s+([^\\n\\r<]+)`, "i"); const text = Array.from(snippet.querySelectorAll(".blob-code-inner, .blob-code")) .map(el => cleanText(el.textContent)) .join("\n"); const match = text.match(matcher); return match ? cleanText(match[1]) : null; } _parseStat(snippet, labelPattern) { const stat = Array.from(snippet.querySelectorAll(".gist-snippet-meta li")).find(li => labelPattern.test(cleanText(li.textContent)) ); return this._parseNumber(stat?.textContent); } _parseNumber(text) { const match = String(text || "").replace(/,/g, "").match(/(\d+(?:\.\d+)?)\s*([kKmM])?/); if (!match) return null; const multiplier = match[2]?.toLowerCase() === "m" ? 1000000 : match[2]?.toLowerCase() === "k" ? 1000 : 1; return Number(match[1]) * multiplier; } _fileLabel(count) { if (!count) return "Gist"; return `${count} ${count === 1 ? "file" : "files"}`; } _rawUrl(owner, id, fileName) { return `https://gist.githubusercontent.com/${encodeURIComponent(owner)}/${id}/raw/${encodeURIComponent(fileName)}`; } _decodePathSegment(segment) { if (!segment) return null; try { return decodeURIComponent(segment.replace(/\+/g, "%20")); } catch { return segment; } } getDirectSearchUrl(domain) { return `${this.baseUrl}/search?q=${encodeURIComponent(domain + ' userscript')}`; } } if (typeof window !== "undefined" && window.__SF_TEST_HOOKS__) { Object.assign(window.__SF_TEST_HOOKS__, { ScriptService, OpenUserJSScriptService, ChromeWebStoreService, MozillaAddonsService, GitHubScriptService, CatalogScriptService, GitHubGistService, reputationScore, normalizedRating, matchesLanguageFilter, extensionTrustBadges, normalizeStringList, hasBroadHostAccess }); } class ToastService { constructor(shadowRoot) { this.root = shadowRoot; this.el = null; this.timer = null; this.undoCallback = null; } show(message, undoCallback = null) { this.hide(); this.undoCallback = undoCallback; this.el = document.createElement("div"); this.el.className = "sf-toast"; const msgSpan = document.createElement("span"); msgSpan.textContent = message; this.el.appendChild(msgSpan); if (undoCallback) { const btn = document.createElement("button"); btn.className = "sf-toast-undo"; btn.textContent = "Undo"; btn.addEventListener("click", () => { undoCallback(); this.hide(); }); this.el.appendChild(btn); } this.root.appendChild(this.el); requestAnimationFrame(() => requestAnimationFrame(() => this.el.classList.add("show"))); this.timer = setTimeout(() => this.hide(), undoCallback ? 5000 : 3000); } hide() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } if (this.el) { this.el.classList.remove("show"); const old = this.el; setTimeout(() => old.remove(), 350); this.el = null; } } } // ── CSS ───────────────────────────────────────────────────────────── const CSS = ` /* ── HOST ── */ :host { all: initial !important; display: block !important; position: fixed !important; bottom: 0 !important; right: 0 !important; z-index: 2147483647 !important; font-family: -apple-system,BlinkMacSystemFont,system-ui,sans-serif !important; pointer-events: none !important; width: 0 !important; height: 0 !important; overflow: visible !important; } /* ── ANIMATIONS ── */ @keyframes sfSpin { to { transform: rotate(360deg); } } @keyframes sfSlideUp { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes sfFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } @keyframes sfShimmer { 0% { background-position: -200% center; } 100% { background-position: 200% center; } } @keyframes sfModalIn { from { opacity: 0; transform: translateY(16px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes sfModalOut { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(10px) scale(0.97); } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } button:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px solid ${THEME.green}; outline-offset: 2px; } /* ── TOAST ── */ .sf-toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%) translateY(-20px); background: ${THEME.surface1}; color: ${THEME.text}; padding: 12px 20px; border-radius: 12px; font: 600 13px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif; box-shadow: 0 12px 40px ${THEME.shadow}; border: 1px solid ${THEME.glassBorder}; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); opacity: 0; transition: all 0.35s cubic-bezier(0.34,1.56,0.64,1); z-index: 2147483647; pointer-events: auto; display: flex; align-items: center; gap: 12px; max-width: 440px; width: max-content; } .sf-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .sf-toast-undo { background: ${THEME.green}; color: ${THEME.base}; border: none; padding: 4px 12px; border-radius: 6px; font: 700 12px/1 inherit; cursor: pointer; transition: all 0.15s ease; white-space: nowrap; } .sf-toast-undo:hover { filter: brightness(1.1); transform: scale(1.04); } /* ── MODAL ── */ .sf-modal { position: fixed; bottom: 14px; right: 14px; width: min(500px, calc(100vw - 24px)); max-height: min(84vh, 800px); background: ${THEME.base}; border-radius: 16px; border: 1px solid ${THEME.glassBorder}; box-shadow: 0 32px 80px ${THEME.shadow}, 0 0 0 1px rgba(255,255,255,0.02); overflow: hidden; display: flex; flex-direction: column; opacity: 0; pointer-events: none; transform: translateY(10px) scale(0.97); transition: all 0.3s cubic-bezier(0.34,1.56,0.64,1); } .sf-modal.visible { opacity: 1; pointer-events: auto; transform: translateY(0) scale(1); } /* Header */ .sf-modal-header { padding: 16px 20px; position: relative; background: linear-gradient(180deg, ${THEME.surface0} 0%, ${THEME.base} 100%); border-bottom: 1px solid ${THEME.glassBorder}; } .sf-modal-header::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: linear-gradient(90deg, ${THEME.green}, ${THEME.teal}); transition: background 0.3s ease; } .sf-modal-header.sleazyfork::before { background: linear-gradient(90deg, ${THEME.purple}, ${THEME.mauve}); } .sf-modal-header.openuserjs::before { background: linear-gradient(90deg, ${THEME.openuserjsDim}, ${THEME.openuserjs}); } .sf-modal-header.chromewebstore::before { background: linear-gradient(90deg, ${THEME.chromewebstoreDim}, ${THEME.chromewebstore}); } .sf-modal-header.mozillaaddons::before { background: linear-gradient(90deg, ${THEME.mozillaaddonsDim}, ${THEME.mozillaaddons}); } .sf-modal-header.catalogs::before { background: linear-gradient(90deg, ${THEME.catalogsDim}, ${THEME.catalogs}); } .sf-modal-header.githubgist::before { background: linear-gradient(90deg, ${THEME.githubgistDim}, ${THEME.githubgist}); } .sf-modal-header.github::before { background: linear-gradient(90deg, ${THEME.github}, ${THEME.peach}); } .sf-header-row { display: flex; align-items: center; gap: 12px; } .sf-header-left { flex: 1; min-width: 0; } .sf-modal-title { font: 700 16px/1.3 -apple-system,BlinkMacSystemFont,system-ui,sans-serif; color: ${THEME.text}; margin: 0 0 4px 0; letter-spacing: -0.3px; background: linear-gradient(90deg, ${THEME.text}, ${THEME.subtext1}, ${THEME.text}); background-size: 200% auto; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; animation: sfShimmer 4s linear infinite; } .sf-modal-subtitle { font: 600 12px/1 inherit; color: ${THEME.subtext0}; margin: 0; display: flex; align-items: center; gap: 6px; } .sf-subtitle-count { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; background: ${THEME.surface2}; border-radius: 6px; font: 700 11px/1 inherit; color: ${THEME.text}; } .sf-header-btn { width: 32px; height: 32px; border-radius: 8px; border: 1px solid ${THEME.glassBorder}; cursor: pointer; background: ${THEME.surface1}; color: ${THEME.subtext0}; display: grid; place-items: center; transition: all 0.2s ease; flex-shrink: 0; } .sf-header-btn:hover { background: ${THEME.surface2}; color: ${THEME.text}; transform: scale(1.06); } .sf-header-btn:active { transform: scale(0.94); } .sf-header-btn svg { width: 14px; height: 14px; } /* Search bar */ .sf-search-wrap { padding: 0 20px 12px; background: transparent; margin-top: -2px; } .sf-search-box { display: flex; align-items: center; gap: 8px; background: ${THEME.surface0}; border: 1px solid ${THEME.glassBorder}; border-radius: 10px; padding: 0 12px; height: 36px; transition: all 0.2s ease; } .sf-search-box:focus-within { border-color: ${THEME.green}33; box-shadow: 0 0 0 3px ${THEME.green}11; } .sf-search-box.sleazyfork:focus-within { border-color: ${THEME.purple}33; box-shadow: 0 0 0 3px ${THEME.purple}11; } .sf-search-box.openuserjs:focus-within { border-color: ${THEME.openuserjs}33; box-shadow: 0 0 0 3px ${THEME.openuserjs}11; } .sf-search-box.chromewebstore:focus-within { border-color: ${THEME.chromewebstore}44; box-shadow: 0 0 0 3px ${THEME.chromewebstore}11; } .sf-search-box.mozillaaddons:focus-within { border-color: ${THEME.mozillaaddons}44; box-shadow: 0 0 0 3px ${THEME.mozillaaddons}11; } .sf-search-box.catalogs:focus-within { border-color: ${THEME.catalogs}44; box-shadow: 0 0 0 3px ${THEME.catalogs}11; } .sf-search-box.githubgist:focus-within { border-color: ${THEME.githubgist}44; box-shadow: 0 0 0 3px ${THEME.githubgist}11; } .sf-search-box.github:focus-within { border-color: ${THEME.github}33; box-shadow: 0 0 0 3px ${THEME.github}11; } .sf-search-box svg { width: 14px; height: 14px; color: ${THEME.overlay}; flex-shrink: 0; } .sf-search-input { flex: 1; border: none; background: transparent; outline: none; font: 500 13px/1 -apple-system,BlinkMacSystemFont,system-ui,sans-serif; color: ${THEME.text}; padding: 0; } .sf-search-input::placeholder { color: ${THEME.overlay}; } .sf-search-count { font: 600 11px/1 inherit; color: ${THEME.overlay}; white-space: nowrap; } /* Tabs */ .sf-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 10px 20px; background: ${THEME.surface0}44; border-bottom: 1px solid ${THEME.glassBorder}; } .sf-tab { padding: 9px 10px; border: 1px solid ${THEME.glassBorder}; border-radius: 8px; cursor: pointer; background: ${THEME.surface0}; font: 600 12px/1 inherit; color: ${THEME.subtext0}; transition: all 0.2s ease; position: relative; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sf-tab:hover { background: ${THEME.surface1}; color: ${THEME.text}; } .sf-tab[data-health-label]:not([data-health-label=""]) { padding-right: 42px; } .sf-tab[data-health-label]:not([data-health-label=""])::after { content: attr(data-health-label); position: absolute; top: 3px; right: 5px; font: 800 8px/1 inherit; letter-spacing: 0; opacity: 0.9; } .sf-tab.health-ok::after { color: ${THEME.green}; } .sf-tab.health-cached::after, .sf-tab.health-stale::after, .sf-tab.health-partial::after { color: ${THEME.yellow}; } .sf-tab.health-rate-limited::after, .sf-tab.health-failed::after { color: ${THEME.red}; } .sf-tab.active { background: linear-gradient(135deg, ${THEME.greenDim}, ${THEME.green}33); color: ${THEME.green}; border-color: ${THEME.green}33; box-shadow: 0 0 16px ${THEME.glow}; } .sf-tab.sleazyfork.active { background: linear-gradient(135deg, ${THEME.purpleDim}44, ${THEME.purple}22); color: ${THEME.purple}; border-color: ${THEME.purple}33; box-shadow: 0 0 16px ${THEME.glowPurple}; } .sf-tab.openuserjs.active { background: linear-gradient(135deg, ${THEME.openuserjsDim}44, ${THEME.openuserjs}22); color: ${THEME.openuserjs}; border-color: ${THEME.openuserjs}33; box-shadow: 0 0 16px ${THEME.glowOpenUserJS}; } .sf-tab.chromewebstore.active { background: linear-gradient(135deg, ${THEME.chromewebstoreDim}44, ${THEME.chromewebstore}22); color: ${THEME.chromewebstore}; border-color: ${THEME.chromewebstore}44; box-shadow: 0 0 16px ${THEME.glowChromeWebStore}; } .sf-tab.mozillaaddons.active { background: linear-gradient(135deg, ${THEME.mozillaaddonsDim}44, ${THEME.mozillaaddons}22); color: ${THEME.mozillaaddons}; border-color: ${THEME.mozillaaddons}44; box-shadow: 0 0 16px ${THEME.glowMozillaAddons}; } .sf-tab.catalogs.active { background: linear-gradient(135deg, ${THEME.catalogsDim}44, ${THEME.catalogs}22); color: ${THEME.catalogs}; border-color: ${THEME.catalogs}44; box-shadow: 0 0 16px ${THEME.glowCatalogs}; } .sf-tab.githubgist.active { background: linear-gradient(135deg, ${THEME.githubgistDim}44, ${THEME.githubgist}22); color: ${THEME.githubgist}; border-color: ${THEME.githubgist}44; box-shadow: 0 0 16px ${THEME.glowGitHubGist}; } .sf-tab.github.active { background: linear-gradient(135deg, ${THEME.githubDim}44, ${THEME.github}22); color: ${THEME.github}; border-color: ${THEME.github}33; box-shadow: 0 0 16px ${THEME.glowGithub}; } /* Sort bar */ .sf-sort-bar { padding: 10px 20px; background: ${THEME.surface0}22; border-bottom: 1px solid ${THEME.glassBorder}; display: flex; align-items: center; gap: 10px; } .sf-filter-bar { padding: 10px 20px; background: ${THEME.surface0}18; border-bottom: 1px solid ${THEME.glassBorder}; display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto; align-items: center; gap: 8px; } .sf-sort-label { font: 600 12px/1 inherit; color: ${THEME.subtext0}; flex-shrink: 0; } .sf-query-mode-select { padding: 7px 10px; border-radius: 8px; border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}; color: ${THEME.text}; font: 500 12px/1 inherit; cursor: pointer; outline: none; } .sf-query-mode-select option { background: ${THEME.surface1}; color: ${THEME.text}; } .sf-sort-select { flex: 1; padding: 7px 10px; border-radius: 8px; border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}; color: ${THEME.text}; font: 500 12px/1 inherit; cursor: pointer; outline: none; transition: border-color 0.2s ease; } .sf-filter-select, .sf-filter-toggle { min-width: 0; padding: 7px 9px; border-radius: 8px; border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}; color: ${THEME.text}; font: 600 11px/1 inherit; cursor: pointer; outline: none; } .sf-filter-toggle.active { background: ${THEME.green}22; color: ${THEME.green}; border-color: ${THEME.green}44; } .sf-sort-select option { background: ${THEME.surface1}; color: ${THEME.text}; } .sf-filter-select option { background: ${THEME.surface1}; color: ${THEME.text}; } .sf-sort-select:focus { border-color: ${THEME.green}44; } .sf-sort-select.sleazyfork:focus { border-color: ${THEME.purple}44; } .sf-sort-select.openuserjs:focus { border-color: ${THEME.openuserjs}44; } .sf-sort-select.chromewebstore:focus { border-color: ${THEME.chromewebstore}44; } .sf-sort-select.mozillaaddons:focus { border-color: ${THEME.mozillaaddons}44; } .sf-sort-select.catalogs:focus { border-color: ${THEME.catalogs}44; } .sf-sort-select.githubgist:focus { border-color: ${THEME.githubgist}44; } .sf-sort-select.github:focus { border-color: ${THEME.github}44; } /* Content */ .sf-content { flex: 1; overflow-y: auto; background: ${THEME.base}; scrollbar-width: thin; scrollbar-color: ${THEME.surface2} transparent; } .sf-content::-webkit-scrollbar { width: 6px; } .sf-content::-webkit-scrollbar-track { background: transparent; } .sf-content::-webkit-scrollbar-thumb { background: ${THEME.surface2}; border-radius: 3px; } .sf-content::-webkit-scrollbar-thumb:hover { background: ${THEME.overlay0}; } /* Script items */ .sf-item { padding: 14px 20px; border-bottom: 1px solid ${THEME.glassBorder}; cursor: pointer; position: relative; background: transparent; transition: all 0.2s ease; animation: sfFadeIn 0.3s ease both; } .sf-item:hover { background: ${THEME.glassHover}; transform: translateY(-1px); } .sf-item:hover::after { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: linear-gradient(180deg, ${THEME.green}, ${THEME.teal}); } .sf-item.sleazyfork:hover::after { background: linear-gradient(180deg, ${THEME.purple}, ${THEME.mauve}); } .sf-item.openuserjs:hover::after { background: linear-gradient(180deg, ${THEME.openuserjsDim}, ${THEME.openuserjs}); } .sf-item.chromewebstore:hover::after { background: linear-gradient(180deg, ${THEME.chromewebstoreDim}, ${THEME.chromewebstore}); } .sf-item.mozillaaddons:hover::after { background: linear-gradient(180deg, ${THEME.mozillaaddonsDim}, ${THEME.mozillaaddons}); } .sf-item.catalogs:hover::after { background: linear-gradient(180deg, ${THEME.catalogsDim}, ${THEME.catalogs}); } .sf-item.githubgist:hover::after { background: linear-gradient(180deg, ${THEME.githubgistDim}, ${THEME.githubgist}); } .sf-item.github:hover::after { background: linear-gradient(180deg, ${THEME.github}, ${THEME.peach}); } .sf-item:last-child { border-bottom: none; } /* Dense mode */ :host(.dense) .sf-item { padding: 10px 20px; } :host(.dense) .sf-script-title { font-size: 13px; } :host(.dense) .sf-script-desc { display: none; } :host(.dense) .sf-script-meta { gap: 4px; } :host(.dense) .sf-badge { padding: 3px 7px; font-size: 10px; } .sf-script-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 6px; } .sf-script-info { flex: 1; min-width: 0; } .sf-script-title { display: block; text-decoration: none; color: ${THEME.text}; font: 700 14px/1.4 inherit; transition: color 0.15s ease; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sf-script-title:hover { color: ${THEME.green}; } .sf-item.sleazyfork .sf-script-title:hover { color: ${THEME.purple}; } .sf-item.openuserjs .sf-script-title:hover { color: ${THEME.openuserjs}; } .sf-item.chromewebstore .sf-script-title:hover { color: ${THEME.chromewebstore}; } .sf-item.mozillaaddons .sf-script-title:hover { color: ${THEME.mozillaaddons}; } .sf-item.catalogs .sf-script-title:hover { color: ${THEME.catalogs}; } .sf-item.githubgist .sf-script-title:hover { color: ${THEME.githubgist}; } .sf-item.github .sf-script-title:hover { color: ${THEME.github}; } .sf-script-sub { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font: 500 11px/1.2 inherit; color: ${THEME.overlay}; margin-top: 3px; } .sf-script-sub svg { width: 12px; height: 12px; margin-right: 2px; vertical-align: -1px; } .sf-dot { opacity: 0.3; font-size: 8px; } .sf-script-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; } .sf-install-btn, .sf-preview-btn { flex-shrink: 0; display: flex; align-items: center; gap: 4px; padding: 6px 12px; border-radius: 8px; border: none; background: ${THEME.green}22; color: ${THEME.green}; font: 700 11px/1 inherit; cursor: pointer; transition: all 0.2s ease; white-space: nowrap; } .sf-install-btn:hover, .sf-preview-btn:hover { background: ${THEME.green}44; transform: scale(1.04); } .sf-install-btn:active, .sf-preview-btn:active { transform: scale(0.96); } .sf-install-btn svg, .sf-preview-btn svg { width: 14px; height: 14px; } .sf-item.sleazyfork .sf-install-btn { background: ${THEME.purple}22; color: ${THEME.purple}; } .sf-item.sleazyfork .sf-install-btn:hover { background: ${THEME.purple}44; } .sf-item.openuserjs .sf-install-btn { background: ${THEME.openuserjs}22; color: ${THEME.openuserjs}; } .sf-item.openuserjs .sf-install-btn:hover { background: ${THEME.openuserjs}44; } .sf-item.chromewebstore .sf-install-btn { background: ${THEME.chromewebstore}22; color: ${THEME.chromewebstore}; } .sf-item.chromewebstore .sf-install-btn:hover { background: ${THEME.chromewebstore}44; } .sf-item.mozillaaddons .sf-install-btn { background: ${THEME.mozillaaddons}22; color: ${THEME.mozillaaddons}; } .sf-item.mozillaaddons .sf-install-btn:hover { background: ${THEME.mozillaaddons}44; } .sf-item.catalogs .sf-install-btn { background: ${THEME.catalogs}22; color: ${THEME.catalogs}; } .sf-item.catalogs .sf-install-btn:hover { background: ${THEME.catalogs}44; } .sf-item.githubgist .sf-install-btn { background: ${THEME.githubgist}22; color: ${THEME.githubgist}; } .sf-item.githubgist .sf-install-btn:hover { background: ${THEME.githubgist}44; } .sf-item.github .sf-install-btn { background: ${THEME.github}22; color: ${THEME.github}; } .sf-item.github .sf-install-btn:hover { background: ${THEME.github}44; } .sf-preview-btn { background: ${THEME.surface1}; color: ${THEME.subtext1}; border: 1px solid ${THEME.glassBorder}; } .sf-preview-btn:hover { background: ${THEME.surface2}; color: ${THEME.text}; } .sf-match-preview { margin-top: 10px; padding: 10px 12px; border-radius: 8px; background: ${THEME.surface0}; border: 1px solid ${THEME.glassBorder}; color: ${THEME.subtext1}; font: 500 11px/1.45 inherit; } .sf-match-preview.hidden { display: none; } .sf-match-preview.good { border-color: ${THEME.green}33; color: ${THEME.green}; } .sf-match-preview.warn { border-color: ${THEME.yellow}33; color: ${THEME.yellow}; } .sf-match-preview.bad { border-color: ${THEME.red}33; color: ${THEME.red}; } .sf-match-preview-title { font-weight: 800; margin-bottom: 4px; color: ${THEME.text}; } .sf-match-preview code { color: ${THEME.subtext1}; word-break: break-all; } .sf-install-warning { margin-top: 8px; color: ${THEME.yellow}; font: 700 11px/1.4 inherit; } .sf-script-desc { color: ${THEME.subtext0}; font: 400 12px/1.5 inherit; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; } .sf-script-meta { display: flex; flex-wrap: wrap; gap: 5px; } .sf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 6px; font: 700 10px/1 inherit; background: ${THEME.surface1}; color: ${THEME.subtext1}; border: 1px solid ${THEME.glassBorder}; transition: all 0.15s ease; } .sf-badge svg { width: 12px; height: 12px; } .sf-badge:hover { background: ${THEME.surface2}; border-color: ${THEME.overlay0}; } .sf-badge.score-high { background: ${THEME.green}18; color: ${THEME.green}; border-color: ${THEME.green}33; } .sf-badge.score-mid { background: ${THEME.yellow}18; color: ${THEME.yellow}; border-color: ${THEME.yellow}33; } .sf-badge.score-low { background: ${THEME.red}18; color: ${THEME.red}; border-color: ${THEME.red}33; } .sf-badge.stale-flag { background: ${THEME.red}18; color: ${THEME.red}; border-color: ${THEME.red}33; } .sf-queue-btn { background: none; border: none; color: ${THEME.overlay}; cursor: pointer; padding: 8px; border-radius: 6px; transition: color 0.15s, background 0.15s; flex-shrink: 0; } .sf-queue-btn:hover { color: ${THEME.yellow}; background: ${THEME.yellow}18; } .sf-queue-btn.queued { color: ${THEME.yellow}; } .sf-dismiss-btn { background: none; border: none; color: ${THEME.overlay}; cursor: pointer; padding: 8px; border-radius: 6px; transition: color 0.15s, background 0.15s; flex-shrink: 0; } .sf-dismiss-btn:hover { color: ${THEME.red}; background: ${THEME.red}18; } .sf-source-badge { font: 600 10px/1 inherit; padding: 2px 6px; border-radius: 4px; background: ${THEME.surface1}; color: ${THEME.subtext0}; } .sf-source-badge.sleazyfork { color: ${THEME.purple}; } .sf-source-badge.openuserjs { color: ${THEME.openuserjs}; } .sf-source-badge.chromewebstore { color: ${THEME.chromewebstore}; } .sf-source-badge.mozillaaddons { color: ${THEME.mozillaaddons}; } .sf-source-badge.catalogs { color: ${THEME.catalogs}; } .sf-source-badge.githubgist { color: ${THEME.githubgist}; } .sf-source-badge.github { color: ${THEME.github}; } .sf-dismissed-notice { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 20px; color: ${THEME.subtext0}; font: 500 12px/1 inherit; border-top: 1px solid ${THEME.glassBorder}22; } .sf-restore-dismissed { background: none; border: none; color: ${THEME.green}; font: 600 12px/1 inherit; cursor: pointer; text-decoration: underline; } .sf-badge.coverage-exact { background: ${THEME.green}18; color: ${THEME.green}; border-color: ${THEME.green}33; } .sf-badge.coverage-broad { background: ${THEME.yellow}18; color: ${THEME.yellow}; border-color: ${THEME.yellow}33; } .sf-badge.coverage-uncertain { background: ${THEME.surface2}; color: ${THEME.subtext1}; border-color: ${THEME.glassBorder}; } .sf-badge.trust-ok { background: ${THEME.green}18; color: ${THEME.green}; border-color: ${THEME.green}33; } .sf-badge.trust-info { background: ${THEME.blue}18; color: ${THEME.blue}; border-color: ${THEME.blue}33; } .sf-badge.trust-warn { background: ${THEME.yellow}18; color: ${THEME.yellow}; border-color: ${THEME.yellow}33; } .sf-badge.trust-bad { background: ${THEME.red}18; color: ${THEME.red}; border-color: ${THEME.red}33; } /* Loading / empty / error */ .sf-loading { padding: 50px 20px; text-align: center; display: grid; gap: 14px; place-items: center; } .sf-spinner { width: 36px; height: 36px; border-radius: 50%; border: 3px solid ${THEME.surface2}; border-top-color: ${THEME.green}; animation: sfSpin 0.7s linear infinite; } .sf-spinner.sleazyfork { border-top-color: ${THEME.purple}; } .sf-spinner.openuserjs { border-top-color: ${THEME.openuserjs}; } .sf-spinner.chromewebstore { border-top-color: ${THEME.chromewebstore}; } .sf-spinner.mozillaaddons { border-top-color: ${THEME.mozillaaddons}; } .sf-spinner.catalogs { border-top-color: ${THEME.catalogs}; } .sf-spinner.githubgist { border-top-color: ${THEME.githubgist}; } .sf-spinner.github { border-top-color: ${THEME.github}; } .sf-loading-text { font: 500 13px/1 inherit; color: ${THEME.subtext0}; } .sf-empty, .sf-error { padding: 50px 28px; text-align: center; } .sf-empty-title, .sf-error-title { font: 700 15px/1.3 inherit; color: ${THEME.text}; margin-bottom: 8px; } .sf-error-title { color: ${THEME.red}; } .sf-empty-text, .sf-error-text { color: ${THEME.subtext0}; font: 400 13px/1.5 inherit; margin-bottom: 18px; } .sf-source-notice { margin: 12px 20px; padding: 12px 14px; border-radius: 10px; background: ${THEME.surface0}; border: 1px solid ${THEME.glassBorder}; color: ${THEME.subtext1}; font: 500 12px/1.45 inherit; } .sf-source-notice.warn { border-color: ${THEME.yellow}44; color: ${THEME.yellow}; } .sf-source-notice.bad { border-color: ${THEME.red}44; color: ${THEME.red}; } .sf-source-notice-title { color: ${THEME.text}; font-weight: 800; margin-bottom: 4px; } .sf-source-notice a { color: inherit; font-weight: 800; text-decoration: underline; } .sf-compat-list { margin: 10px auto 0; padding: 0; max-width: 420px; display: grid; gap: 6px; list-style: none; } .sf-compat-list li { color: ${THEME.subtext1}; font: 600 12px/1.4 inherit; } .sf-error-actions { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; } .sf-action-btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 18px; border-radius: 10px; background: linear-gradient(135deg, ${THEME.greenDim}, ${THEME.green}88); color: ${THEME.base}; font: 700 13px/1 inherit; border: none; cursor: pointer; transition: all 0.2s ease; text-decoration: none; } .sf-action-btn:hover { transform: translateY(-1px); filter: brightness(1.1); } .sf-action-btn.sleazyfork { background: linear-gradient(135deg, ${THEME.purpleDim}, ${THEME.purple}88); } .sf-action-btn.openuserjs { background: linear-gradient(135deg, ${THEME.openuserjsDim}, ${THEME.openuserjs}88); } .sf-action-btn.chromewebstore { background: linear-gradient(135deg, ${THEME.chromewebstoreDim}, ${THEME.chromewebstore}88); } .sf-action-btn.mozillaaddons { background: linear-gradient(135deg, ${THEME.mozillaaddonsDim}, ${THEME.mozillaaddons}88); } .sf-action-btn.catalogs { background: linear-gradient(135deg, ${THEME.catalogsDim}, ${THEME.catalogs}88); } .sf-action-btn.githubgist { background: linear-gradient(135deg, ${THEME.githubgistDim}, ${THEME.githubgist}88); } .sf-action-btn.github { background: linear-gradient(135deg, ${THEME.githubDim}, ${THEME.github}88); } /* Disclosure */ .sf-disclosure { padding: 30px 24px; text-align: left; } .sf-disclosure-title { font: 700 15px/1.3 inherit; color: ${THEME.text}; margin-bottom: 8px; } .sf-disclosure-text { color: ${THEME.subtext0}; font: 400 13px/1.5 inherit; margin-bottom: 16px; } .sf-disclosure-table { width: 100%; border-collapse: collapse; margin-bottom: 18px; } .sf-disclosure-table th { text-align: left; font: 700 11px/1 inherit; color: ${THEME.subtext1}; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 8px; border-bottom: 1px solid ${THEME.glassBorder}; } .sf-disclosure-table td { padding: 8px; border-bottom: 1px solid ${THEME.glassBorder}22; vertical-align: middle; } .sf-disclosure-source { display: flex; align-items: center; gap: 8px; font: 600 13px/1.3 inherit; color: ${THEME.text}; cursor: pointer; } .sf-disclosure-source input { accent-color: ${THEME.green}; width: 16px; height: 16px; cursor: pointer; } .sf-disclosure-hosts { font: 400 12px/1.4 inherit; color: ${THEME.subtext0}; font-family: monospace; } .sf-disclosure-actions { text-align: center; } /* Diagnostics fallback */ .sf-diagnostics-fallback { padding: 12px 20px; border-top: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}88; } .sf-diagnostics-fallback-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font: 600 12px/1 inherit; color: ${THEME.subtext1}; } .sf-diagnostics-fallback-close { background: none; border: none; color: ${THEME.subtext0}; cursor: pointer; padding: 4px; } .sf-diagnostics-textarea { width: 100%; height: 100px; resize: vertical; background: ${THEME.base}; color: ${THEME.text}; border: 1px solid ${THEME.glassBorder}; border-radius: 8px; padding: 8px; font: 400 11px/1.4 monospace; box-sizing: border-box; } .sf-diagnostics-fallback-actions { margin-top: 8px; text-align: right; } .sf-diagnostics-retry-btn { background: ${THEME.surface1}; color: ${THEME.text}; border: 1px solid ${THEME.glassBorder}; border-radius: 8px; padding: 6px 14px; font: 600 12px/1 inherit; cursor: pointer; } .sf-diagnostics-retry-btn:hover { background: ${THEME.surface2}; } /* Footer */ .sf-footer { padding: 12px 20px; border-top: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}44; display: flex; align-items: center; justify-content: space-between; gap: 10px; font: 600 11px/1 inherit; } .sf-footer-text { color: ${THEME.overlay}; display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; } .sf-footer a { color: ${THEME.green}; text-decoration: none; font-weight: 700; } .sf-footer a:hover { text-decoration: underline; } .sf-footer a.sleazyfork { color: ${THEME.purple}; } .sf-footer a.openuserjs { color: ${THEME.openuserjs}; } .sf-footer a.chromewebstore { color: ${THEME.chromewebstore}; } .sf-footer a.mozillaaddons { color: ${THEME.mozillaaddons}; } .sf-footer a.catalogs { color: ${THEME.catalogs}; } .sf-footer a.githubgist { color: ${THEME.githubgist}; } .sf-footer a.github { color: ${THEME.github}; } .sf-health-pill { display: inline-flex; align-items: center; padding: 3px 7px; border-radius: 6px; background: ${THEME.surface1}; color: ${THEME.subtext1}; border: 1px solid ${THEME.glassBorder}; font: 800 10px/1 inherit; white-space: nowrap; } .sf-health-pill.ok { color: ${THEME.green}; border-color: ${THEME.green}33; } .sf-health-pill.cached, .sf-health-pill.stale, .sf-health-pill.partial { color: ${THEME.yellow}; border-color: ${THEME.yellow}33; } .sf-health-pill.rate-limited, .sf-health-pill.failed { color: ${THEME.red}; border-color: ${THEME.red}33; } .sf-diagnostics-btn { border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface1}; color: ${THEME.subtext1}; border-radius: 7px; padding: 5px 9px; font: 800 10px/1 inherit; cursor: pointer; flex-shrink: 0; } .sf-diagnostics-btn:hover { color: ${THEME.text}; background: ${THEME.surface2}; } /* Settings panel */ .sf-settings { display: none; padding: 16px 20px; border-top: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}44; } .sf-settings.visible { display: block; max-height: min(36vh, 330px); overflow-y: auto; flex-shrink: 0; } .sf-settings::-webkit-scrollbar { width: 6px; } .sf-settings::-webkit-scrollbar-track { background: transparent; } .sf-settings::-webkit-scrollbar-thumb { background: ${THEME.surface2}; border-radius: 3px; } .sf-settings-title { font: 700 13px/1 inherit; color: ${THEME.text}; margin-bottom: 12px; } .sf-settings-subtitle { font: 800 10px/1 inherit; color: ${THEME.overlay}; text-transform: uppercase; margin: 14px 0 6px; letter-spacing: 0; } .sf-setting-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid ${THEME.glassBorder}; } .sf-setting-block { display: grid; gap: 8px; padding: 8px 0; border-bottom: 1px solid ${THEME.glassBorder}; } .sf-source-setting-row { padding: 6px 0; } .sf-setting-row:last-child { border-bottom: none; } .sf-setting-label { font: 500 12px/1.3 inherit; color: ${THEME.subtext1}; } .sf-setting-help { color: ${THEME.overlay}; font: 500 11px/1.35 inherit; } .sf-setting-textarea { min-height: 64px; resize: vertical; padding: 8px 9px; border-radius: 7px; border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}; color: ${THEME.text}; font: 500 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; outline: none; } .sf-setting-mini-btn { border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface1}; color: ${THEME.subtext1}; border-radius: 7px; padding: 6px 10px; font: 800 10px/1 inherit; cursor: pointer; } .sf-setting-mini-btn:hover { color: ${THEME.text}; background: ${THEME.surface2}; } .sf-settings-export-row { gap: 8px; } .sf-toggle { position: relative; width: 36px; height: 20px; border-radius: 10px; background: ${THEME.surface2}; cursor: pointer; transition: background 0.2s ease; border: none; padding: 0; } .sf-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: ${THEME.subtext0}; transition: all 0.2s ease; } .sf-toggle.on { background: ${THEME.green}55; } .sf-toggle.on::after { left: 18px; background: ${THEME.green}; } .sf-setting-select { padding: 4px 8px; border-radius: 6px; border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}; color: ${THEME.text}; font: 500 12px/1 inherit; cursor: pointer; outline: none; } .sf-setting-select option { background: ${THEME.surface1}; } /* Responsive */ @media (max-width: 520px) { .sf-modal { width: calc(100vw - 24px); right: 12px; max-height: min(88vh, 700px); } .sf-modal-header { padding: 14px 16px; } .sf-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 16px; } .sf-sort-bar, .sf-search-wrap { padding-left: 16px; padding-right: 16px; } .sf-sort-bar { flex-wrap: wrap; } .sf-sort-bar .sf-sort-label:first-child { display: none; } .sf-filter-bar { grid-template-columns: 1fr 1fr; padding-left: 16px; padding-right: 16px; } .sf-filter-bar .sf-sort-label { display: none; } .sf-item { padding: 12px 16px; } .sf-footer { padding: 10px 16px; flex-wrap: wrap; justify-content: center; } } `; // ── Main Controller ───────────────────────────────────────────────── class ScriptFinder { constructor() { this.settings = new SettingsService(); this.services = { greasyfork: new ScriptService("https://greasyfork.org", "greasyfork"), sleazyfork: new ScriptService("https://sleazyfork.org", "sleazyfork"), openuserjs: new OpenUserJSScriptService(), chromewebstore: new ChromeWebStoreService(), mozillaaddons: new MozillaAddonsService(), catalogs: new CatalogScriptService(), githubgist: new GitHubGistService(), github: new GitHubScriptService() }; this.currentService = this._firstEnabledSource(this.settings.get("lastService") || "greasyfork"); this.currentSort = this.settings.get("defaultSort"); this.filters = { updatedMonths: "any", minRating: "any", languageFilter: "any" }; this.queryMode = this.settings.get("queryMode") || "auto"; this.currentDomain = HostService.getCurrentHost(); this.isOpen = false; this.isLoading = false; this.allScripts = []; this.sourceStatus = null; this.sourceHealth = {}; this.searchQuery = ""; this.settingsOpen = false; this.previousFocus = null; this.uiBuilt = false; this._allLoadGen = 0; this.compatibility = ManagerCompatibility.report(); } init() { this.compatibility = ManagerCompatibility.report(); if (!this.compatibility.canMenu) { this._ensureUI(); this._openCompatibilityReport(); return; } this._setupMenuCommands(); this.settings.watchForChanges(() => this._onSettingsChanged()); } _onSettingsChanged() { this._ensureCurrentSource(); this._setupMenuCommands(); if (this.uiBuilt) { this._syncSettingsUi(); this._renderTabs(); this._updateTabs(); } } _syncSettingsUi() { this.modal.querySelectorAll(".sf-source-toggle").forEach(btn => { const source = btn.dataset.source; const enabled = this._isSourceEnabled(source); btn.classList.toggle("on", enabled); btn.setAttribute("aria-pressed", String(enabled)); }); this.modal.querySelectorAll(".sf-toggle[data-key]").forEach(btn => { const key = btn.dataset.key; const val = !!this.settings.get(key); btn.classList.toggle("on", val); btn.setAttribute("aria-pressed", String(val)); }); this.modal.querySelectorAll(".sf-setting-select").forEach(sel => { const key = sel.dataset.key; const val = this.settings.get(key); if (val != null) sel.value = String(val); }); const denseMode = this.settings.get("denseMode"); this.host.classList.toggle("dense", !!denseMode); } _ensureUI() { if (this.uiBuilt) return; this._buildUI(); this._setupEvents(); this.uiBuilt = true; } _serviceClass(serviceName) { return serviceName === "greasyfork" ? "" : serviceName; } _isSourceEnabled(serviceName) { return this.settings.get("sources")?.[serviceName] !== false; } _enabledSourceNames() { return SOURCE_ORDER.filter(source => this.services[source] && this._isSourceEnabled(source)); } _currentHostBlock() { return HostService.sensitiveHostMatch(HostService.getCurrentHost(), this.settings); } _currentHostOverridden() { return HostService.isHostOverridden(HostService.getCurrentHost(), this.settings); } _firstEnabledSource(preferred = null) { if (preferred && this.services[preferred] && this._isSourceEnabled(preferred)) return preferred; return this._enabledSourceNames()[0] || "greasyfork"; } _ensureCurrentSource() { const next = this._firstEnabledSource(this.currentService); if (next !== this.currentService) { this.currentService = next; this.settings.set("lastService", next); } return next; } _tabsHtml() { if (this._currentHostBlock()) return ""; this._ensureCurrentSource(); const allActive = this.currentService === "_all"; const allTab = ``; const sourceTabs = this._enabledSourceNames().map(source => { const active = source === this.currentService; const cls = ["sf-tab", this._serviceClass(source), active ? "active" : ""].filter(Boolean).join(" "); return ``; }).join(""); return allTab + sourceTabs; } _sourceSettingsHtml() { return SOURCE_ORDER.map(source => { const enabled = this._isSourceEnabled(source); return `
${escapeHtml(SOURCE_META[source].label)}
`; }).join(""); } _privacySettingsHtml() { const protectedHost = this._currentHostBlock(); const overridden = this._currentHostOverridden(); const host = HostService.getCurrentHost() || "this host"; const overrideLabel = overridden ? "Block this host again" : "Allow this host"; const overrideHelp = protectedHost ? `Blocked by ${protectedHost.pattern}. Allowing this host enables source menus and searches here only.` : (overridden ? "This host is currently allowed even if it matches a sensitive-host rule." : "Current host does not match the sensitive-host rules."); return `
Host privacy
Sensitive host protection
One host or wildcard pattern per line. Built-in rules already cover banks, government, identity, admin, and local network hosts.
${escapeHtml(host)}
${escapeHtml(overrideHelp)}
`; } _renderTabs() { if (!this.modal) return; const tabs = this.modal.querySelector(".sf-tabs"); if (tabs) _safeHTML(tabs, this._tabsHtml()); } _syncSourceControls() { if (!this.modal) return; this.modal.querySelectorAll(".sf-source-toggle").forEach(btn => { const enabled = this._isSourceEnabled(btn.dataset.source); btn.classList.toggle("on", enabled); btn.setAttribute("aria-pressed", String(enabled)); }); } _setSourceEnabled(source, enabled) { if (!SOURCE_META[source]) return; const sources = { ...this.settings.get("sources"), [source]: enabled }; if (!SOURCE_ORDER.some(name => sources[name])) { this.toast?.show("At least one source must stay enabled"); this._syncSourceControls(); return; } this.settings.set("sources", sources); let shouldLoad = false; if (!enabled && source === this.currentService) { this.currentService = this._firstEnabledSource(); this.settings.set("lastService", this.currentService); shouldLoad = this.isOpen; } this._renderTabs(); this._syncSourceControls(); this._setupMenuCommands(); this._updateTabs(); if (shouldLoad) this._loadScripts(); } _setCurrentHostOverride(allowed) { const host = HostService.normalizeHost(HostService.getCurrentHost()); if (!host) return; const overrides = new Set(this.settings.get("sensitiveHostOverrides") || []); if (allowed) overrides.add(host); else overrides.delete(host); this.settings.set("sensitiveHostOverrides", [...overrides]); this._syncHostPrivacyControls(); this._refreshHostProtection(); } _syncHostPrivacyControls() { if (!this.modal) return; const protection = this.settings.get("sensitiveHostProtection") !== false; const protectionBtn = this.modal.querySelector('.sf-toggle[data-key="sensitiveHostProtection"]'); if (protectionBtn) { protectionBtn.classList.toggle("on", protection); protectionBtn.setAttribute("aria-pressed", String(protection)); } const block = this._currentHostBlock(); const overridden = this._currentHostOverridden(); const btn = this.modal.querySelector(".sf-host-override-btn"); if (btn) btn.textContent = overridden ? "Block this host again" : "Allow this host"; const help = this.modal.querySelector(".sf-host-override-help"); if (help) { help.textContent = block ? `Blocked by ${block.pattern}. Allowing this host enables source menus and searches here only.` : (overridden ? "This host is currently allowed even if it matches a sensitive-host rule." : "Current host does not match the sensitive-host rules."); } } _refreshHostProtection() { this._setupMenuCommands(); this._renderTabs(); this._updateTabs(); if (this.isOpen) this._loadScripts(); } // ── UI Build ──────────────────────────────────────────────────── _buildUI() { this.host = document.createElement("div"); this.shadow = this.host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = CSS; this.shadow.appendChild(style); this.toast = new ToastService(this.shadow); // Modal this.modal = document.createElement("div"); this.modal.className = "sf-modal"; this.modal.setAttribute("role", "dialog"); this.modal.setAttribute("aria-modal", "true"); this.modal.setAttribute("aria-labelledby", "sf-modal-title"); this.modal.setAttribute("aria-describedby", "sf-modal-subtitle"); this.modal.setAttribute("tabindex", "-1"); _safeHTML(this.modal, `

Scripts for this site

0 scripts found

${this._tabsHtml()}
Mode Sort by
Filters
Searching...
Settings
Dense mode
Default sort
Cache (minutes)
${this._privacySettingsHtml()}
Sources
${this._sourceSettingsHtml()}
Data
`); this.shadow.appendChild(this.modal); // Refs this.content = this.modal.querySelector(".sf-content"); this.searchInput = this.modal.querySelector(".sf-search-input"); this.searchCount = this.modal.querySelector(".sf-search-count"); this.searchBox = this.modal.querySelector(".sf-search-box"); this.sortSelect = this.modal.querySelector(".sf-sort-select"); this.filterBar = this.modal.querySelector(".sf-filter-bar"); if (this.settings.get("denseMode")) this.host.classList.add("dense"); document.body.appendChild(this.host); } // ── Events ────────────────────────────────────────────────────── _setupEvents() { // Modal buttons this.modal.querySelector(".sf-btn-close").addEventListener("click", () => this._close()); this.modal.querySelector(".sf-btn-settings").addEventListener("click", () => this._toggleSettings()); this.modal.querySelector(".sf-diagnostics-btn").addEventListener("click", () => this._copyDiagnostics()); // Tabs this.modal.querySelector(".sf-tabs").addEventListener("click", e => { const tab = e.target.closest(".sf-tab"); if (!tab) return; const svc = tab.dataset.service; if (svc !== this.currentService && (svc === "_all" || this._isSourceEnabled(svc))) { this.currentService = svc; if (svc !== "_all") this.settings.set("lastService", svc); this._updateTabs(); this._loadScripts(); } }); // Query mode this.queryModeSelect = this.modal.querySelector(".sf-query-mode-select"); if (this.queryModeSelect) { this.queryModeSelect.value = this.queryMode; this.queryModeSelect.addEventListener("change", e => { this.queryMode = e.target.value; this.settings.set("queryMode", this.queryMode); this._loadScripts(); }); } // Sort this.sortSelect.addEventListener("change", e => { this.currentSort = e.target.value; this._displayScripts(); }); this.modal.querySelectorAll(".sf-filter-select").forEach(sel => { sel.addEventListener("change", () => { this.filters[sel.dataset.filter] = sel.value; this._displayScripts(); }); }); // Search filter this.searchInput.addEventListener("input", e => { this.searchQuery = e.target.value.toLowerCase().trim(); this._displayScripts(); }); // Settings toggles this.modal.querySelectorAll(".sf-toggle[data-key]").forEach(btn => { btn.addEventListener("click", () => { const key = btn.dataset.key; const val = !this.settings.get(key); this.settings.set(key, val); btn.classList.toggle("on", val); btn.setAttribute("aria-pressed", String(val)); if (key === "denseMode") this.host.classList.toggle("dense", val); if (key === "sensitiveHostProtection") this._refreshHostProtection(); }); }); this.modal.querySelectorAll(".sf-source-toggle").forEach(btn => { btn.addEventListener("click", () => { this._setSourceEnabled(btn.dataset.source, !this._isSourceEnabled(btn.dataset.source)); }); }); this.modal.querySelector(".sf-setting-textarea[data-key='sensitiveHostPatterns']")?.addEventListener("change", e => { this.settings.set("sensitiveHostPatterns", e.target.value); this._syncHostPrivacyControls(); this._refreshHostProtection(); }); this.modal.querySelector(".sf-host-override-btn")?.addEventListener("click", () => { this._setCurrentHostOverride(!this._currentHostOverridden()); }); // Settings selects this.modal.querySelectorAll(".sf-setting-select").forEach(sel => { sel.addEventListener("change", () => { const key = sel.dataset.key; let val = sel.value; if (key === "cacheDuration") val = parseInt(val); this.settings.set(key, val); if (key === "defaultSort") { this.currentSort = val; this.sortSelect.value = val; this._displayScripts(); } }); }); // Export/Import settings this.modal.querySelector(".sf-export-btn")?.addEventListener("click", () => { const data = JSON.stringify(this.settings.settings, null, 2); const blob = new Blob([data], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "userscript-finder-settings.json"; a.click(); URL.revokeObjectURL(url); this.toast.show(STRINGS.toastSettingsExported); }); const importFile = this.modal.querySelector(".sf-import-file"); this.modal.querySelector(".sf-import-btn")?.addEventListener("click", () => importFile?.click()); importFile?.addEventListener("change", e => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const imported = JSON.parse(reader.result); if (typeof imported !== "object" || imported === null) throw new Error("Invalid settings"); const allowedKeys = new Set(Object.keys(DEFAULT_SETTINGS)); Object.entries(imported).forEach(([key, value]) => { if (allowedKeys.has(key)) this.settings.set(key, value); }); this._ensureCurrentSource(); this._setupMenuCommands(); this._syncSettingsUi(); this._renderTabs(); this._updateTabs(); this.toast.show(STRINGS.toastSettingsImported); } catch { this.toast.show(STRINGS.toastImportFailed); } }; reader.readAsText(file); e.target.value = ""; }); // Outside click / Escape document.addEventListener("click", e => { if (this.isOpen && !this.host.contains(e.target)) this._close(); }); document.addEventListener("keydown", e => { if (!this.isOpen) return; if (e.key === "Escape") this._close(); else if (e.key === "Tab") this._trapFocus(e); }); } // ── Menu commands ─────────────────────────────────────────────── _setupMenuCommands() { const registerMenuCommand = gmFunction("GM_registerMenuCommand"); const unregisterMenuCommand = gmFunction("GM_unregisterMenuCommand"); if (!registerMenuCommand) return; if (window._sfMenuIds) window._sfMenuIds.forEach(id => { try { unregisterMenuCommand?.(id); } catch {} }); window._sfMenuIds = []; this.compatibility = ManagerCompatibility.report(); this._ensureCurrentSource(); const domain = HostService.extractRootDomain(this.currentDomain); const hostBlock = this._currentHostBlock(); if (!this.compatibility.ok) { window._sfMenuIds.push(registerMenuCommand("Script Finder compatibility report", () => { this._ensureUI(); this._openCompatibilityReport(); })); window._sfMenuIds.push(registerMenuCommand("Reset Script Finder Settings", () => this._resetSettings())); return; } if (hostBlock) { window._sfMenuIds.push(registerMenuCommand(`Script Finder blocked on ${hostBlock.host} (Settings)`, () => { this._ensureUI(); this._open(); })); window._sfMenuIds.push(registerMenuCommand("Reset Script Finder Settings", () => this._resetSettings())); return; } this._enabledSourceNames().forEach(source => { const meta = SOURCE_META[source]; window._sfMenuIds.push(registerMenuCommand(`Find ${meta.menuKind} for ${domain} (${meta.menuName})`, () => { this._ensureUI(); if (this._isSourceEnabled(source)) this.currentService = source; this._open(); })); }); window._sfMenuIds.push(registerMenuCommand("Reset Script Finder Settings", () => this._resetSettings())); } _resetSettings() { gmDeleteValue("sf_settings_v4"); location.reload(); } _focusableElements() { return Array.from(this.modal.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')) .filter(el => !el.disabled && el.getAttribute("aria-hidden") !== "true" && el.offsetParent !== null); } _trapFocus(event) { const focusable = this._focusableElements(); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = this.shadow.activeElement || document.activeElement; if (event.shiftKey && active === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } else if (!focusable.includes(active)) { event.preventDefault(); first.focus(); } } // ── Modal control ─────────────────────────────────────────────── _open() { this.isOpen = true; this.previousFocus = document.activeElement; this.compatibility = ManagerCompatibility.report(); this._ensureCurrentSource(); this._renderTabs(); this._updateTabs(); this._updateServiceColors(); this.sortSelect.value = this.currentSort; this.modal.classList.add("visible"); this.searchInput.value = ""; this.searchQuery = ""; requestAnimationFrame(() => this.searchInput.focus({ preventScroll: true })); if (!this.compatibility.ok) { this._showCompatibilityReport(); return; } const hostBlock = this._currentHostBlock(); if (hostBlock) { this._showHostBlocked(hostBlock); return; } if (this._needsDisclosure()) { this._showDisclosure(); return; } this._loadScripts(); } _close() { this.isOpen = false; this.settingsOpen = false; this.modal.querySelector(".sf-settings").classList.remove("visible"); this.modal.classList.remove("visible"); if (this.previousFocus && typeof this.previousFocus.focus === "function") { this.previousFocus.focus({ preventScroll: true }); } } _toggleSettings() { this.settingsOpen = !this.settingsOpen; this.modal.querySelector(".sf-settings").classList.toggle("visible", this.settingsOpen); } // ── Tab/color updates ─────────────────────────────────────────── _updateTabs() { this.modal.querySelectorAll(".sf-tab").forEach(t => { const active = t.dataset.service === this.currentService; t.classList.toggle("active", active); t.setAttribute("aria-selected", String(active)); }); this._updateServiceColors(); } _updateServiceColors() { const svc = this.currentService; const svcNames = SOURCE_ORDER; const header = this.modal.querySelector(".sf-modal-header"); svcNames.forEach(s => header.classList.toggle(s, s === svc)); svcNames.forEach(s => this.sortSelect.classList.toggle(s, s === svc)); svcNames.forEach(s => this.searchBox.classList.toggle(s, s === svc)); const footerLink = this.modal.querySelector(".sf-footer a"); if (footerLink) { svcNames.forEach(s => footerLink.classList.toggle(s, s === svc)); if (svc === "_all") { footerLink.textContent = "All sources"; footerLink.removeAttribute("href"); } else { footerLink.textContent = SOURCE_META[svc]?.label || svc; footerLink.href = SOURCE_META[svc]?.footerUrl || "#"; } } this._updateHealthUi(); } _setResultCount(count) { const countEl = this.modal.querySelector(".sf-subtitle-count"); const textEl = this.modal.querySelector(".sf-subtitle-text"); const unit = this.currentService === "_all" ? "result" : (SOURCE_META[this.currentService]?.unit || "script"); if (countEl) countEl.textContent = count || 0; if (textEl) textEl.textContent = count === 1 ? `${unit} found` : `${unit}s found`; } _sourceNoticeHtml() { const notices = []; const compat = this.compatibility || ManagerCompatibility.report(); if (compat.warnings.length) { notices.push(`
Manager degraded mode
${escapeHtml(compat.warnings[0])}
`); } if (this.sourceStatus) { const status = this.sourceStatus; const cls = status.type === "stale" || status.type === "partial" ? "warn" : "bad"; const svc = this.services[this.currentService]; const directUrl = svc ? svc.getDirectSearchUrl(this.currentDomain) : "#"; notices.push(`
${escapeHtml(status.title || "Source status")}
${escapeHtml(status.detail || "The source returned a degraded result.")} Search manually
`); } return notices.join(""); } _sourceErrorTitle(err, label) { if (err?.kind === "compat") return `${label} compatibility issue`; if (err?.kind === "rate-limit") return `${label} rate limit`; if (err?.kind === "timeout") return `${label} timed out`; if (err?.kind === "backoff") return `${label} is backing off`; if (err?.kind === "parse") return `${label} changed its response`; return `${label} unavailable`; } _showHostBlocked(hostBlock) { const host = hostBlock?.host || HostService.getCurrentHost() || "this host"; this.allScripts = []; this.sourceStatus = null; this._setResultCount(0); this.content.setAttribute("aria-busy", "false"); _safeHTML(this.content, `
Search disabled on sensitive host
No source requests will run on ${escapeHtml(host)}. Matched rule: ${escapeHtml(hostBlock?.pattern || "sensitive host")}.
`); this.content.querySelector(".sf-host-allow-btn")?.addEventListener("click", () => this._setCurrentHostOverride(true)); this.content.querySelector(".sf-host-settings-btn")?.addEventListener("click", () => { this.settingsOpen = true; this.modal.querySelector(".sf-settings").classList.add("visible"); }); this._syncHostPrivacyControls(); } _openCompatibilityReport() { this.isOpen = true; this.previousFocus = document.activeElement; this.modal.classList.add("visible"); requestAnimationFrame(() => this.modal.focus({ preventScroll: true })); this._showCompatibilityReport(); } _showCompatibilityReport() { const report = this.compatibility || ManagerCompatibility.report(); const issues = [ ...report.missingRequired.map(name => `${name} is required for source menus or network requests.`), ...report.warnings ]; this.allScripts = []; this.sourceStatus = null; this._setResultCount(0); this.content.setAttribute("aria-busy", "false"); this.modal.querySelector(".sf-modal-title").textContent = STRINGS.modalTitleCompat; _safeHTML(this.content, `
Userscript manager degraded mode
Detected ${escapeHtml(report.manager.handler)} ${escapeHtml(report.manager.version)}. Script Finder needs menu commands and GM_xmlhttpRequest for full discovery.
    ${issues.map(issue => `
  • ${escapeHtml(issue)}
  • `).join("")}
`); this.content.querySelector(".sf-compat-settings-btn")?.addEventListener("click", () => { this.settingsOpen = true; this.modal.querySelector(".sf-settings").classList.add("visible"); }); this._updateHealthUi(); } _recordSourceHealth(serviceName, health) { const normalized = { type: health?.type || "ok", title: health?.title || "Source checked", detail: health?.detail || "", checkedAt: health?.checkedAt || Date.now(), cachedAt: health?.cachedAt || null }; if (normalized.type === "rate-limit") normalized.type = "rate-limited"; this.sourceHealth[serviceName] = normalized; this._updateHealthUi(); } _healthLabel(health) { const labels = { ok: "OK", cached: "CACHE", stale: "STALE", partial: "PARTIAL", "rate-limited": "RATE", failed: "FAIL" }; return labels[health?.type] || "CHECK"; } _healthClass(health) { return ["ok", "cached", "stale", "partial", "rate-limited", "failed"].includes(health?.type) ? health.type : "failed"; } _healthTitle(serviceName, health) { if (!health) return "Source has not been checked yet."; const checked = new Date(health.checkedAt).toISOString(); const cacheAge = health.cachedAt ? SourceRuntime.ageLabel(health.cachedAt) : "none"; return `${SourceRuntime.sourceLabels[serviceName] || serviceName}: ${this._healthLabel(health)}. Last checked ${checked}. Cache age ${cacheAge}. ${health.detail || ""}`.trim(); } _updateHealthUi() { if (!this.modal) return; this.modal.querySelectorAll(".sf-tab").forEach(tab => { const health = this.sourceHealth[tab.dataset.service]; ["ok", "cached", "stale", "partial", "rate-limited", "failed"].forEach(cls => tab.classList.remove(`health-${cls}`)); tab.dataset.healthLabel = health ? this._healthLabel(health) : ""; if (health) tab.classList.add(`health-${this._healthClass(health)}`); tab.title = this._healthTitle(tab.dataset.service, health); }); const health = this.sourceHealth[this.currentService]; const pill = this.modal.querySelector(".sf-health-pill"); if (pill) { const cls = health ? this._healthClass(health) : ""; pill.className = `sf-health-pill ${cls}`; pill.textContent = health ? `${this._healthLabel(health)} ${SourceRuntime.ageLabel(health.checkedAt)}` : "Not checked"; pill.title = this._healthTitle(this.currentService, health); } } _diagnosticString() { const health = this.sourceHealth[this.currentService] || { type: "unchecked", checkedAt: Date.now() }; const rootHost = HostService.extractRootDomain(this.currentDomain || HostService.getCurrentHost()); const cacheAge = health.cachedAt ? SourceRuntime.ageLabel(health.cachedAt) : "none"; return [ "UserScript Finder source diagnostic", ...ManagerCompatibility.diagnosticLines(this.compatibility || ManagerCompatibility.report()), `source=${this.currentService}`, `host=${rootHost}`, `status=${health.type}`, `checkedAt=${new Date(health.checkedAt).toISOString()}`, `cacheAge=${cacheAge}`, `resultCount=${Array.isArray(this.allScripts) ? this.allScripts.length : 0}` ].join("\n"); } async _copyDiagnostics() { const diagnostic = this._diagnosticString(); try { if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable"); await navigator.clipboard.writeText(diagnostic); this.toast.show(STRINGS.toastDiagCopied); } catch(err) { console.info("[Script Finder diagnostics]", diagnostic); this._showDiagnosticsFallback(diagnostic); } } _showDiagnosticsFallback(text) { const existing = this.modal.querySelector(".sf-diagnostics-fallback"); if (existing) { existing.remove(); return; } const panel = document.createElement("div"); panel.className = "sf-diagnostics-fallback"; _safeHTML(panel, `
Copy diagnostics manually
`); const footer = this.modal.querySelector(".sf-footer"); footer.parentNode.insertBefore(panel, footer); const textarea = panel.querySelector(".sf-diagnostics-textarea"); textarea.focus(); textarea.select(); panel.querySelector(".sf-diagnostics-fallback-close").addEventListener("click", () => panel.remove()); panel.querySelector(".sf-diagnostics-retry-btn").addEventListener("click", async () => { try { if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable"); await navigator.clipboard.writeText(text); this.toast.show(STRINGS.toastDiagCopied); panel.remove(); } catch { this.toast.show(STRINGS.toastDiagConsole); } }); } // ── Disclosure ────────────────────────────────────────────────── _needsDisclosure() { const acked = this.settings.get("disclosureAckedSources") || []; return this._enabledSourceNames().some(s => !acked.includes(s)); } _showDisclosure() { this.allScripts = []; this.sourceStatus = null; this._setResultCount(0); this.content.setAttribute("aria-busy", "false"); this.modal.querySelector(".sf-modal-title").textContent = STRINGS.modalTitleDisclosure; const enabled = this._enabledSourceNames(); const rows = enabled.map(source => { const meta = SOURCE_META[source]; const hosts = (SOURCE_CONNECT[source] || []).map(h => escapeHtml(h)).join(", "); return `${hosts}`; }).join(""); _safeHTML(this.content, `
${STRINGS.disclosureTitle}
${STRINGS.disclosureText}
${rows}
SourceHosts contacted
`); this.content.querySelector(".sf-disclosure-continue").addEventListener("click", () => { const checked = [...this.content.querySelectorAll(".sf-disclosure-source input")]; const enabledSources = []; checked.forEach(cb => { const source = cb.dataset.source; if (cb.checked) { enabledSources.push(source); } else { const sources = this.settings.get("sources"); sources[source] = false; this.settings.set("sources", sources); } }); const acked = [...new Set([...(this.settings.get("disclosureAckedSources") || []), ...enabledSources])]; this.settings.set("disclosureAckedSources", acked); this._ensureCurrentSource(); this._renderTabs(); this._updateTabs(); this._loadScripts(); }); } async _loadAllSources() { const loadId = ++this._allLoadGen; this.content.setAttribute("aria-busy", "true"); _safeHTML(this.content, `
${STRINGS.loadingAllSources}
`); const host = HostService.getCurrentHost(); this.currentDomain = host; const queryHost = this._resolveQueryHost(host); const enabled = this._enabledSourceNames(); const results = []; const seen = new Set(); const promises = enabled.map(async source => { try { const scripts = await this.services[source].searchScriptsByHost(queryHost, this.settings); const health = scripts?._sfHealth || { type: "ok", title: `${SOURCE_META[source].label} loaded`, checkedAt: Date.now() }; this._recordSourceHealth(source, health); return Array.isArray(scripts) ? scripts : []; } catch (err) { this._recordSourceHealth(source, { type: err?.kind === "rate-limit" ? "rate-limited" : "failed", title: `${SOURCE_META[source].label} failed`, detail: err?.message || "Unknown error", checkedAt: Date.now() }); return []; } }); const allResults = await Promise.all(promises); if (this.currentService !== "_all" || loadId !== this._allLoadGen) { this.content.setAttribute("aria-busy", "false"); return; } for (const batch of allResults) { for (const script of batch) { const key = script.url || script.code_url || script._full_name || script.name; if (key && !seen.has(key)) { seen.add(key); results.push(script); } } } this.allScripts = results; this.sourceStatus = null; this._setResultCount(results.length); this._displayScripts(); this.content.setAttribute("aria-busy", "false"); } _resolveQueryHost(host) { switch (this.queryMode) { case "exact": return HostService.normalizeHost(host); case "root": return HostService.extractRootDomain(host); case "keyword": return HostService.extractRootDomain(host).split(".")[0]; default: return host; } } // ── Data ──────────────────────────────────────────────────────── async _loadScripts() { this.compatibility = ManagerCompatibility.report(); if (!this.compatibility.ok) { this._showCompatibilityReport(); return; } const hostBlock = this._currentHostBlock(); if (hostBlock) { this._showHostBlocked(hostBlock); return; } if (this.currentService === "_all") return this._loadAllSources(); if (this.isLoading) return; this._ensureCurrentSource(); if (!this._isSourceEnabled(this.currentService)) { this.allScripts = []; this.sourceStatus = null; this._setResultCount(0); _safeHTML(this.content, `
${STRINGS.emptySourceDisabled}
${STRINGS.emptySourceDisabledText}
`); return; } this.isLoading = true; const activeService = this.currentService; const svc = this.services[activeService]; const svcClass = this._serviceClass(activeService); const svcLabel = SOURCE_META[activeService]?.label || "Source"; this.content.setAttribute("aria-busy", "true"); _safeHTML(this.content, `
Searching ${svcLabel}...
`); try { const host = HostService.getCurrentHost(); this.currentDomain = host; const queryHost = this._resolveQueryHost(host); const scripts = await svc.searchScriptsByHost(queryHost, this.settings); const activeHostBlock = this._currentHostBlock(); if (activeHostBlock) { this._showHostBlocked(activeHostBlock); return; } if (activeService !== this.currentService || !this._isSourceEnabled(activeService)) return; this.allScripts = scripts; this.sourceStatus = this.allScripts?._sfStatus || null; this._recordSourceHealth(activeService, this.allScripts?._sfHealth || { type: "ok", title: `${svcLabel} loaded`, detail: "Fresh source results loaded.", checkedAt: Date.now() }); this._setResultCount(this.allScripts.length); this._displayScripts(); } catch(err) { if (activeService !== this.currentService || !this._isSourceEnabled(activeService)) return; this.sourceStatus = null; this.allScripts = []; this._recordSourceHealth(activeService, { type: err?.kind === "rate-limit" ? "rate-limited" : "failed", title: this._sourceErrorTitle(err, svcLabel), detail: err?.message || "Unknown error", checkedAt: Date.now() }); this._setResultCount(0); const directUrl = svc.getDirectSearchUrl(this.currentDomain); _safeHTML(this.content, `
${escapeHtml(this._sourceErrorTitle(err, svcLabel))}
${escapeHtml(err?.message || 'Unknown error')}
Search manually
`); this.content.querySelector(".sf-action-btn")?.addEventListener("click", () => this._loadScripts()); } finally { this.isLoading = false; this.content.setAttribute("aria-busy", "false"); if (this.isOpen && activeService !== this.currentService && this._isSourceEnabled(this.currentService)) this._loadScripts(); } } _sortScripts(scripts) { const copy = [...scripts]; switch (this.currentSort) { case "daily": return copy.sort((a,b) => (b.daily_installs || b.total_installs || b._stars || 0) - (a.daily_installs || a.total_installs || a._stars || 0)); case "total": return copy.sort((a,b) => (b.total_installs || b._stars || 0) - (a.total_installs || a._stars || 0)); case "good": return copy.sort((a,b) => (b.good_ratings || b._rating || 0) - (a.good_ratings || a._rating || 0)); case "fanscore": return copy.sort((a,b) => (b.fan_score || b._forks || 0) - (a.fan_score || a._forks || 0)); case "authorrep": return copy.sort((a,b) => reputationScore(b) - reputationScore(a)); case "updatedate": return copy.sort((a,b) => new Date(b.code_updated_at||0) - new Date(a.code_updated_at||0)); case "createdate": return copy.sort((a,b) => new Date(b.created_at||0) - new Date(a.created_at||0)); default: return copy.sort((a,b) => (b.daily_installs || b.total_installs || b._stars || 0) - (a.daily_installs || a.total_installs || a._stars || 0)); } } _displayScripts() { let scripts = this.allScripts || []; const svcClass = this._serviceClass(this.currentService); const svcLabel = this.currentService === "_all" ? "all sources" : (SOURCE_META[this.currentService]?.label || "Source"); const displayHost = HostService.extractRootDomain(this.currentDomain); const noticeHtml = this._sourceNoticeHtml(); // Update title const titleFn = this.currentService === "_all" ? STRINGS.modalTitleResults : this.currentService === "catalogs" ? STRINGS.modalTitleCatalogs : this.currentService === "githubgist" ? STRINGS.modalTitleGists : ["chromewebstore", "mozillaaddons"].includes(this.currentService) ? STRINGS.modalTitleExtensions : STRINGS.modalTitleScripts; this.modal.querySelector(".sf-modal-title").textContent = titleFn(displayHost); if (this.searchQuery) { const { fields, text } = this._parseFieldedQuery(this.searchQuery); scripts = scripts.filter(s => { for (const { key, value } of fields) { const target = this._fieldValue(s, key); if (!target.includes(value)) return false; } if (text) { return (s.name || "").toLowerCase().includes(text) || (s.description || "").toLowerCase().includes(text) || (s.users?.[0]?.name || "").toLowerCase().includes(text) || (s._full_name || "").toLowerCase().includes(text) || (s._catalog_source || "").toLowerCase().includes(text) || (s._category || "").toLowerCase().includes(text) || (s._topics || []).some(t => t.toLowerCase().includes(text)); } return true; }); } scripts = this._applyFilters(scripts); const dismissed = this._getDismissed(); if (dismissed.length) { scripts = scripts.filter(s => !dismissed.includes(this._scriptKey(s))); } const constrained = this.searchQuery || this._hasActiveFilters(); this.searchCount.textContent = constrained ? `${scripts.length}/${this.allScripts.length}` : ""; if (!scripts.length) { const svc = this.services[this.currentService]; const directUrl = svc ? svc.getDirectSearchUrl(this.currentDomain) : "#"; if (this.searchQuery) { _safeHTML(this.content, `${noticeHtml}
No matches
No scripts match "${escapeHtml(this.searchQuery)}"
`); } else if (this._hasActiveFilters()) { _safeHTML(this.content, `${noticeHtml}
No matches
No scripts match the active filters.
`); } else { _safeHTML(this.content, ` ${noticeHtml}
No scripts found
Nothing matched ${escapeHtml(displayHost)} on ${svcLabel}.
${svc ? `Search manually` : ""}
`); } this._setResultCount(this.allScripts.length); return; } const sorted = this._sortScripts(scripts); this._setResultCount(this.allScripts.length); if (this._chunkTimer) { cancelAnimationFrame(this._chunkTimer); this._chunkTimer = null; } _safeHTML(this.content, noticeHtml); this._renderChunked(sorted, svcClass); } _renderChunked(items, svcClass) { if (this._chunkTimer) { cancelAnimationFrame(this._chunkTimer); this._chunkTimer = null; } const isAll = this.currentService === "_all"; const CHUNK = 30; let offset = 0; const renderBatch = () => { const end = Math.min(offset + CHUNK, items.length); for (let i = offset; i < end; i++) { const itemClass = isAll ? this._serviceClass(items[i]._source || "greasyfork") : svcClass; this.content.appendChild(this._createScriptItem(items[i], itemClass, i)); } offset = end; if (offset < items.length) { this._chunkTimer = requestAnimationFrame(renderBatch); } else { this._chunkTimer = null; this._updateDismissedCount(); } }; renderBatch(); } _scriptKey(script) { return `${script._source || ""}:${script._full_name || script.url || script.name || ""}`; } _getDismissed() { const domain = HostService.extractRootDomain(this.currentDomain || HostService.getCurrentHost()); const all = gmGetValue("sf_dismissed", {}) || {}; return all[domain] || []; } _dismissScript(script) { const domain = HostService.extractRootDomain(this.currentDomain || HostService.getCurrentHost()); const all = gmGetValue("sf_dismissed", {}) || {}; const list = all[domain] || []; const key = this._scriptKey(script); if (!list.includes(key)) list.push(key); all[domain] = list; gmSetValue("sf_dismissed", all); } _updateDismissedCount() { const dismissed = this._getDismissed(); if (dismissed.length) { const existing = this.content.querySelector(".sf-dismissed-notice"); if (existing) { existing.querySelector(".sf-dismissed-count").textContent = dismissed.length; } else { const notice = document.createElement("div"); notice.className = "sf-dismissed-notice"; _safeHTML(notice, `${dismissed.length} hidden `); notice.querySelector(".sf-restore-dismissed").addEventListener("click", () => { const domain = HostService.extractRootDomain(this.currentDomain || HostService.getCurrentHost()); const all = gmGetValue("sf_dismissed", {}) || {}; delete all[domain]; gmSetValue("sf_dismissed", all); this._displayScripts(); }); this.content.appendChild(notice); } } } _getQueue() { return gmGetValue("sf_queue", []) || []; } _isQueued(script) { const key = this._scriptKey(script); return this._getQueue().some(q => q.key === key); } _toggleQueued(script) { const key = this._scriptKey(script); const queue = this._getQueue(); const idx = queue.findIndex(q => q.key === key); if (idx >= 0) { queue.splice(idx, 1); gmSetValue("sf_queue", queue); return false; } queue.push({ key, name: script.name || "Untitled", url: script.url || script.code_url || "", source: script._source || "", addedAt: new Date().toISOString() }); gmSetValue("sf_queue", queue); return true; } _parseFieldedQuery(raw) { const fields = []; const textParts = []; for (const token of raw.split(/\s+/)) { const match = token.match(/^(author|license|source|name|url):(.+)$/i); if (match) { fields.push({ key: match[1].toLowerCase(), value: match[2].toLowerCase() }); } else { textParts.push(token); } } return { fields, text: textParts.join(" ").trim() }; } _fieldValue(script, key) { switch (key) { case "author": return (script.users?.[0]?.name || script.users?.[0]?.url || "").toLowerCase(); case "license": return (script.license || "").toLowerCase(); case "source": return (script._source || "").toLowerCase(); case "name": return (script.name || "").toLowerCase(); case "url": return (script.url || script.code_url || "").toLowerCase(); default: return ""; } } _hasActiveFilters() { return this.filters.updatedMonths !== "any" || this.filters.minRating !== "any" || this.filters.languageFilter !== "any"; } _applyFilters(scripts) { const updatedMonths = this.filters.updatedMonths; const minRating = this.filters.minRating; const cutoff = updatedMonths === "any" ? null : Date.now() - (Number(updatedMonths) * 30 * 24 * 60 * 60 * 1000); const ratingFloor = minRating === "any" ? null : Number(minRating); return scripts.filter(script => { if (cutoff) { const updated = new Date(script.code_updated_at || 0).getTime(); if (!Number.isFinite(updated) || updated < cutoff) return false; } if (ratingFloor != null) { const rating = normalizedRating(script); if (rating == null || rating < ratingFloor) return false; } if (this.filters.languageFilter !== "any" && !matchesLanguageFilter(script, this.filters.languageFilter)) return false; return true; }); } async _toggleMatchPreview(script, pane, button) { if (!pane.classList.contains("hidden")) { pane.classList.add("hidden"); return; } pane.className = "sf-match-preview"; _safeHTML(pane, `
Checking match coverage...
`); button.disabled = true; try { if (!script._matchPreview) { const source = await this._fetchPreviewSource(script.code_url, script); script._matchPreview = this._parseMatchCoverage(source, HostService.getCurrentHost(), window.location.href); } const preview = script._matchPreview; pane.className = `sf-match-preview ${preview.status}`; _safeHTML(pane, `
${escapeHtml(preview.title)}
${escapeHtml(preview.detail)}
${preview.patterns.length ? `
Metadata: ${preview.patterns.map(p => `${escapeHtml(p)}`).join(", ")}
` : ""} `); } catch(err) { pane.className = "sf-match-preview warn"; _safeHTML(pane, `
Coverage unavailable
${escapeHtml(err?.message || "Could not load script metadata.")}
`); } finally { button.disabled = false; } } _fetchPreviewSource(url, script = null) { const validation = InstallSafety.validateInstallUrl(script || { _source: this.currentService }, url); if (!validation.ok) return Promise.reject(new Error(`Preview blocked: ${validation.reason}`)); return new Promise((resolve, reject) => { const request = gmFunction("GM_xmlhttpRequest"); if (!request) { reject(SourceRuntime.error("Preview", "GM_xmlhttpRequest is unavailable in this userscript manager", "compat", 0)); return; } request({ method: "GET", url: validation.url, headers: { Accept: "text/plain,*/*" }, timeout: SourceRuntime.timeoutMs, onload: r => { if (r.status >= 200 && r.status < 300) resolve(r.responseText || ""); else reject(new Error(`Preview HTTP ${r.status}`)); }, onerror: () => reject(new Error("Preview network request failed")), ontimeout: () => reject(new Error("Preview timed out")) }); }); } _openUrl(url) { const openInTab = gmFunction("GM_openInTab"); if (openInTab) { openInTab(url, { active: true }); return; } const opened = window.open(url, "_blank", "noopener,noreferrer"); if (!opened) window.location.href = url; } _parseMatchCoverage(source, host, currentUrl) { return MatchCoverage.evaluate(source, host, currentUrl); } async _openInstallTarget(script, button) { const validation = InstallSafety.validateInstallUrl(script, button.dataset.url || script.code_url); if (!validation.ok) { this.toast.show(`Install blocked: ${validation.reason}`); return; } const oldHtml = button.innerHTML; button.disabled = true; button.textContent = "Checking..."; try { const source = await this._fetchPreviewSource(validation.url, script); if (!InstallSafety.hasUserScriptMetadata(source)) { this.toast.show(STRINGS.toastInstallMetaMissing); return; } const dangerousGrants = InstallSafety.dangerousGrants(source); if (dangerousGrants.length) { this.toast.show(`Warning: requests ${dangerousGrants.join(", ")}`); } this._openUrl(validation.url); } catch(err) { this.toast.show(`Install check failed: ${err?.message || "could not load metadata"}`); } finally { button.disabled = false; _safeHTML(button, oldHtml); } } _createScriptItem(script, svcClass, index) { const item = document.createElement("div"); item.className = `sf-item ${svcClass}`; item.style.animationDelay = `${Math.min(index * 30, 300)}ms`; const isGH = script._source === "github"; const isCWS = script._source === "chromewebstore"; const isAMO = script._source === "mozillaaddons"; const isCatalog = script._source === "catalogs"; const isGist = script._source === "githubgist"; const daily = formatNumber(script.daily_installs); const total = formatNumber(script.total_installs); const good = formatNumber(script.good_ratings); const fanScore = script.fan_score != null ? Number(script.fan_score) : null; const fanText = Number.isFinite(fanScore) ? fanScore.toFixed(1) : null; const updated = relativeTime(script.code_updated_at); const created = relativeTime(script.created_at); const author = script.users?.[0]?.name || null; const baseUrls = { sleazyfork: "https://sleazyfork.org", greasyfork: "https://greasyfork.org", openuserjs: "https://openuserjs.org" }; const rawUrl = (isGH || isGist || isCatalog) ? script.url : (script.url?.startsWith("http") ? script.url : (baseUrls[svcClass] || baseUrls.greasyfork) + (script.url || "")); const scriptUrl = rawUrl?.startsWith("http") ? rawUrl : "#"; const installUrl = script.code_url || null; const installValidation = installUrl ? InstallSafety.validateInstallUrl(script, installUrl) : null; const safeInstallUrl = installValidation?.ok ? installValidation.url : null; const installWarning = installValidation && !installValidation.ok ? `Install blocked: ${installValidation.reason}` : ""; const fanClass = fanScore >= 8 ? "score-high" : fanScore >= 6 ? "score-mid" : fanScore >= 0 ? "score-low" : ""; const TWO_YEARS = 2 * 365 * 24 * 60 * 60 * 1000; const isStale = script.code_updated_at && (Date.now() - new Date(script.code_updated_at).getTime()) > TWO_YEARS; const badge = (icon, text, title, cls = "") => { if (!text) return ""; return `${getIcon(icon)} ${escapeHtml(text)}`; }; // GitHub: show stars + forks; Chrome Web Store: show users + rating; script registries show install stats. let metaHtml; if (isGH) { const stars = formatNumber(script._stars); const forks = formatNumber(script._forks); const lang = script._language; metaHtml = ` ${badge("star", stars, "Stars")} ${badge("gitFork", forks, "Forks")} ${lang ? badge("gitBranch", lang, "Language") : ""} ${badge("clockwise", updated, "Updated")} ${badge("calendarPlus", created, "Created")} ${isStale ? badge("warning", "Stale", "Last updated more than 2 years ago", "stale-flag") : ""} `; } else if (isGist) { const stars = formatNumber(script._stars); const forks = formatNumber(script._forks); const files = script._files ? `${script._files} ${script._files === 1 ? "file" : "files"}` : null; metaHtml = ` ${badge("gitBranch", files, "Files")} ${badge("star", stars, "Stars")} ${badge("gitFork", forks, "Forks")} ${badge("clockwise", updated, "Last active")} ${isStale ? badge("warning", "Stale", "Last updated more than 2 years ago", "stale-flag") : ""} `; } else if (isCatalog) { metaHtml = ` ${badge("search", script._catalog_source, "Catalog source")} ${badge("gitBranch", script._category, "Category")} ${badge("clockwise", updated, "Updated")} `; } else if (isCWS || isAMO) { const totalUsers = formatNumber(script.total_installs); const rating = script._rating != null ? Number(script._rating).toFixed(1) : null; const ratingCount = formatNumber(script._rating_count); const trustBadges = extensionTrustBadges(script); metaHtml = ` ${badge("chartBar", totalUsers, "Users")} ${badge("star", rating, "Average rating")} ${badge("user", ratingCount, "Rating count")} ${badge("clockwise", updated, "Updated")} ${trustBadges.map(trust => badge(trust.icon, trust.text, trust.title, trust.cls)).join("")} `; } else { metaHtml = ` ${badge("download", daily ? `${daily}/day` : null, "Daily installs")} ${badge("chartBar", total, "Total installs")} ${badge("star", good, "Ratings")} ${badge("flame", fanText, "Fan score", fanText ? fanClass : "")} ${badge("search", script._hostCoverageLabel, script._hostCoverageDetail || "Host coverage", script._hostCoverageClass || "")} ${badge("clockwise", updated, "Updated")} ${badge("calendarPlus", created, "Created")} ${isStale ? badge("warning", "Stale", "Last updated more than 2 years ago", "stale-flag") : ""} `; } // Non-userscript sources get a "View" button; script registries get "Install". let actionBtn; if (isGH || isCWS || isAMO || !safeInstallUrl) { const icon = (isGH || isGist) ? "githubLogo" : "search"; const title = isGH ? "View repository" : isGist ? "View gist" : isCatalog ? "Open catalog result" : isCWS || isAMO ? "View extension" : "View script page"; actionBtn = ``; } else if (safeInstallUrl) { actionBtn = ``; } else { actionBtn = ""; } const previewBtn = safeInstallUrl ? `` : ""; const queued = this._isQueued(script); const queueBtn = ``; const dismissBtn = ``; const actionsHtml = `
${actionBtn}${previewBtn}${queueBtn}${dismissBtn}
`; _safeHTML(item, `
${escapeHtml(script.name || "Untitled")}
${this.currentService === "_all" && script._source ? `${escapeHtml(SOURCE_META[script._source]?.tab || script._source)}` : ""} ${author ? `${getIcon('user')} ${escapeHtml(author)}` : ""} ${author && script.version ? `` : ""} ${script.version ? `${getIcon('gitBranch')} v${escapeHtml(script.version)}` : ""} ${(author || script.version) && script.license ? `` : ""} ${script.license ? `${getIcon('scales')} ${escapeHtml(script.license)}` : ""}
${actionsHtml}
${installWarning ? `
${escapeHtml(installWarning)}
` : ""}
${escapeHtml(script.description || "No description")}
${metaHtml}
`); // Action button handler const actionEl = item.querySelector(".sf-install-btn"); if (actionEl) { actionEl.addEventListener("click", (e) => { e.stopPropagation(); if (actionEl.dataset.action === "install") this._openInstallTarget(script, actionEl); else this._openUrl(actionEl.dataset.url); }); } const previewEl = item.querySelector(".sf-preview-btn"); const previewPane = item.querySelector(".sf-match-preview"); if (previewEl && previewPane) { previewEl.addEventListener("click", (e) => { e.stopPropagation(); this._toggleMatchPreview(script, previewPane, previewEl); }); } // Queue handler const queueEl = item.querySelector(".sf-queue-btn"); if (queueEl) { queueEl.addEventListener("click", (e) => { e.stopPropagation(); const isQueued = this._toggleQueued(script); queueEl.classList.toggle("queued", isQueued); queueEl.setAttribute("aria-pressed", String(isQueued)); queueEl.title = isQueued ? "Remove from queue" : "Queue to try later"; this.toast.show(isQueued ? STRINGS.toastQueued : STRINGS.toastUnqueued); }); } // Dismiss handler const dismissEl = item.querySelector(".sf-dismiss-btn"); if (dismissEl) { dismissEl.addEventListener("click", (e) => { e.stopPropagation(); this._dismissScript(script); item.style.opacity = "0"; item.style.transition = "opacity 0.2s ease"; setTimeout(() => { item.remove(); this._updateDismissedCount(); }, 200); }); } // Click-to-open script page item.addEventListener("click", (e) => { if (e.target.closest("a") || e.target.closest("button")) return; this._openUrl(scriptUrl); }); return item; } } // ── Init ──────────────────────────────────────────────────────────── function boot() { try { new ScriptFinder().init(); } catch(e) { console.error("[Script Finder v4]", e); } } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else setTimeout(boot, 50); })();