From dbb4a49ba563df84e9f112696f7a374daa69552b Mon Sep 17 00:00:00 2001 From: "noz.am" Date: Wed, 16 Sep 2026 11:41:17 +0200 Subject: [PATCH] v1.0.3 update --- extension/chrome/background.js | 10 + extension/chrome/manifest.json | 3 +- extension/chrome/popup/popup.css | 10 + extension/chrome/popup/popup.html | 2 + extension/chrome/shared/cdn-background.js | 64 +++++ extension/chrome/shared/cdn-config.js | 51 ++++ extension/chrome/shared/cdn-settings.js | 242 ++++++++++++++++++ .../chrome/shared/integrated-settings.css | 49 +++- .../chrome/shared/integrated-settings.js | 98 ++++++- extension/chrome/shared/settings-frame.css | 5 + extension/chrome/shared/settings-frame.js | 2 + extension/firefox/background.html | 2 + extension/firefox/background.js | 9 + extension/firefox/manifest.json | 3 +- extension/firefox/popup/popup.css | 10 + extension/firefox/popup/popup.html | 2 + extension/firefox/shared/cdn-background.js | 64 +++++ extension/firefox/shared/cdn-config.js | 51 ++++ extension/firefox/shared/cdn-settings.js | 242 ++++++++++++++++++ .../firefox/shared/integrated-settings.css | 49 +++- .../firefox/shared/integrated-settings.js | 98 ++++++- extension/firefox/shared/settings-frame.css | 5 + extension/firefox/shared/settings-frame.js | 2 + extension/safari/background.js | 10 + extension/safari/manifest.json | 3 +- extension/safari/popup/popup.css | 10 + extension/safari/popup/popup.html | 2 + extension/safari/shared/cdn-background.js | 64 +++++ extension/safari/shared/cdn-config.js | 51 ++++ extension/safari/shared/cdn-settings.js | 242 ++++++++++++++++++ .../safari/shared/integrated-settings.css | 49 +++- .../safari/shared/integrated-settings.js | 98 ++++++- extension/safari/shared/settings-frame.css | 5 + extension/safari/shared/settings-frame.js | 2 + extension/shared/cdn-background.js | 64 +++++ extension/shared/cdn-config.js | 51 ++++ extension/shared/cdn-settings.js | 242 ++++++++++++++++++ extension/shared/integrated-settings.css | 49 +++- extension/shared/integrated-settings.js | 98 ++++++- extension/shared/settings-frame.css | 5 + extension/shared/settings-frame.js | 2 + package-lock.json | 4 +- package.json | 7 +- test/test_cdn.mjs | 122 +++++++++ test/test_cdn_settings.mjs | 179 +++++++++++++ test/test_extension_settings.mjs | 15 +- test/test_integrated_settings.mjs | 42 +++ 47 files changed, 2431 insertions(+), 58 deletions(-) create mode 100644 extension/chrome/shared/cdn-background.js create mode 100644 extension/chrome/shared/cdn-config.js create mode 100644 extension/chrome/shared/cdn-settings.js create mode 100644 extension/firefox/shared/cdn-background.js create mode 100644 extension/firefox/shared/cdn-config.js create mode 100644 extension/firefox/shared/cdn-settings.js create mode 100644 extension/safari/shared/cdn-background.js create mode 100644 extension/safari/shared/cdn-config.js create mode 100644 extension/safari/shared/cdn-settings.js create mode 100644 extension/shared/cdn-background.js create mode 100644 extension/shared/cdn-config.js create mode 100644 extension/shared/cdn-settings.js create mode 100644 test/test_cdn.mjs create mode 100644 test/test_cdn_settings.mjs diff --git a/extension/chrome/background.js b/extension/chrome/background.js index 28c5273..19a62af 100644 --- a/extension/chrome/background.js +++ b/extension/chrome/background.js @@ -1,5 +1,15 @@ // Bsky+ Background Service Worker importScripts('shared/llm.js'); +importScripts('shared/cdn-config.js', 'shared/cdn-background.js'); + +chrome.action.onClicked.addListener(tab => { + const url = 'https://bsky.app/bskyplus'; + if (tab?.id !== undefined && tab.url && new URL(tab.url).hostname === 'bsky.app') { + chrome.tabs.update(tab.id, { url }); + } else { + chrome.tabs.create({ url }); + } +}); const DEFAULT_SETTINGS = { skeetMode: false, diff --git a/extension/chrome/manifest.json b/extension/chrome/manifest.json index 7b8437f..615b94a 100644 --- a/extension/chrome/manifest.json +++ b/extension/chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Bsky+", - "version": "1.0.3", + "version": "1.0.3.1", "description": "Enhanced features for Bluesky (bsky.app) including local OCR, custom accent colors, and skeet mode.", "permissions": [ "declarativeNetRequestWithHostAccess", @@ -29,7 +29,6 @@ "service_worker": "background.js" }, "action": { - "default_popup": "popup/popup.html", "default_title": "Bsky+ Settings", "default_icon": { "16": "icons/icon16.png", diff --git a/extension/chrome/popup/popup.css b/extension/chrome/popup/popup.css index efacaa1..70049c4 100644 --- a/extension/chrome/popup/popup.css +++ b/extension/chrome/popup/popup.css @@ -342,6 +342,16 @@ body { color: #fff; } +.bskyplus-cdn-section .bskyplus-llm-actions { + flex-wrap: wrap; + gap: 8px; +} + +.bskyplus-cdn-section .secondary-btn { + flex: 1 1 auto; + min-width: 0; +} + .footer { text-align: center; margin-top: 14px; diff --git a/extension/chrome/popup/popup.html b/extension/chrome/popup/popup.html index 5bd42fa..046da08 100644 --- a/extension/chrome/popup/popup.html +++ b/extension/chrome/popup/popup.html @@ -126,5 +126,7 @@ + + diff --git a/extension/chrome/shared/cdn-background.js b/extension/chrome/shared/cdn-background.js new file mode 100644 index 0000000..0a4a02a --- /dev/null +++ b/extension/chrome/shared/cdn-background.js @@ -0,0 +1,64 @@ +(() => { + 'use strict'; + const api = typeof browser !== 'undefined' ? browser : chrome; + const config = globalThis.BskyPlusCdnConfig; + const useRules = api.runtime.getManifest().permissions.some(permission => + permission === 'declarativeNetRequest' || permission === 'declarativeNetRequestWithHostAccess'); + let activeHostname = ''; + let queue = Promise.resolve(); + + function apply() { + // Serialize startup, permission changes, and saves so an older update + // cannot overwrite the rules for a newer hostname. + const update = queue.catch(() => {}).then(async () => { + const stored = await api.storage.local.get('imageCdnHostname'); + let hostname = ''; + try { hostname = config.normalizeHostname(stored.imageCdnHostname || ''); } catch {} + const enabled = !!hostname && await api.permissions.contains({ origins: config.permissionOrigins(hostname) }); + if (useRules) { + await api.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [config.RULE_ID], + addRules: enabled ? [config.makeRule(hostname)] : [], + }); + } + activeHostname = enabled ? hostname : ''; + return { success: true, enabled, hostname }; + }); + queue = update; + return update; + } + + if (!useRules) { + // Firefox supports blocking request redirects on the existing minimum + // version. Wait for startup storage/permission checks before redirecting. + api.webRequest.onBeforeRequest.addListener(async details => { + await queue.catch(() => {}); + const redirectUrl = config.redirectUrl(details.url, activeHostname); + return redirectUrl ? { redirectUrl } : {}; + }, { + urls: [ + 'https://cdn.bsky.app/img/feed_thumbnail/plain/*', + 'https://cdn.bsky.app/img/feed_fullsize/plain/*', + 'https://cdn.bsky.app/img/avatar_thumbnail/plain/*', + ], + types: ['image', 'xmlhttprequest', 'main_frame'], + }, ['blocking']); + } + + const refresh = () => { apply().catch(() => { activeHostname = ''; }); }; + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.imageCdnHostname) refresh(); + }); + api.permissions.onAdded.addListener(refresh); + api.permissions.onRemoved.addListener(refresh); + api.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.action !== 'APPLY_IMAGE_CDN') return; + if (sender.id !== api.runtime.id || !sender.url?.startsWith(api.runtime.getURL(''))) { + sendResponse({ success: false, error: 'Open Bsky+ settings to change the CDN.' }); + return false; + } + apply().then(sendResponse, () => sendResponse({ success: false, error: 'Could not apply the image CDN. Try saving again.' })); + return true; + }); + refresh(); +})(); diff --git a/extension/chrome/shared/cdn-config.js b/extension/chrome/shared/cdn-config.js new file mode 100644 index 0000000..6e38532 --- /dev/null +++ b/extension/chrome/shared/cdn-config.js @@ -0,0 +1,51 @@ +(() => { + 'use strict'; + const SOURCE_HOST = 'cdn.bsky.app'; + const IMAGE_PATTERN = '^https://cdn\\.bsky\\.app/img/(feed_thumbnail|feed_fullsize|avatar_thumbnail)/plain/'; + const RULE_ID = 2001; + + function normalizeHostname(value) { + if (typeof value !== 'string') throw new Error('Enter a CDN hostname.'); + const input = value.trim(); + if (!input) return ''; + const authority = input.replace(/^https:\/\//i, '').replace(/\/$/, ''); + if (!authority || /[\s:/\\@?#%]/.test(authority)) { + throw new Error('Enter a hostname only, without a path, port, or login details.'); + } + let hostname; + try { + hostname = new URL(`https://${authority}`).hostname.toLowerCase().replace(/\.$/, ''); + } catch { + throw new Error('Enter a valid CDN hostname.'); + } + if (hostname.length > 253 || !hostname.split('.').every(label => + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) { + throw new Error('Enter a valid CDN hostname.'); + } + return hostname === SOURCE_HOST ? '' : hostname; + } + + function permissionOrigins(hostname) { + return hostname ? [`https://${SOURCE_HOST}/*`, `https://${hostname}/*`] : []; + } + + function makeRule(hostname) { + return { + id: RULE_ID, + priority: 1, + action: { type: 'redirect', redirect: { transform: { host: hostname } } }, + condition: { + regexFilter: IMAGE_PATTERN, + isUrlFilterCaseSensitive: true, + resourceTypes: ['image', 'xmlhttprequest', 'main_frame'], + }, + }; + } + + function redirectUrl(url, hostname) { + if (!hostname || !new RegExp(IMAGE_PATTERN).test(url)) return undefined; + return `https://${hostname}${url.slice(`https://${SOURCE_HOST}`.length)}`; + } + + globalThis.BskyPlusCdnConfig = { normalizeHostname, permissionOrigins, makeRule, redirectUrl, RULE_ID }; +})(); diff --git a/extension/chrome/shared/cdn-settings.js b/extension/chrome/shared/cdn-settings.js new file mode 100644 index 0000000..e63bfa6 --- /dev/null +++ b/extension/chrome/shared/cdn-settings.js @@ -0,0 +1,242 @@ +// Bsky+ image CDN settings module. +// This module is loaded by the settings frame after the popup settings script. +(function () { + 'use strict'; + + var STORAGE_KEY = 'imageCdnHostname'; + var config = typeof globalThis !== 'undefined' ? globalThis.BskyPlusCdnConfig : null; + + function getBrowserApi() { + if (typeof browser !== 'undefined' && browser && browser.runtime) return browser; + if (typeof chrome !== 'undefined' && chrome && chrome.runtime) return chrome; + return null; + } + + function storageGet(api, key) { + if (!api || !api.storage || !api.storage.local || typeof api.storage.local.get !== 'function') return Promise.resolve({}); + return Promise.resolve().then(function () { return api.storage.local.get(key); }).then(function (result) { + return result || {}; + }, function () { return {}; }); + } + + function storageSet(api, value) { + if (!api || !api.storage || !api.storage.local || typeof api.storage.local.set !== 'function') { + return Promise.reject(new Error('Settings storage is unavailable.')); + } + return new Promise(function (resolve, reject) { + var result; + try { + result = api.storage.local.set(value); + } catch (err) { + reject(err); + return; + } + if (result && typeof result.then === 'function') result.then(resolve, reject); + else reject(new Error('Storage did not confirm the write.')); + }); + } + + function makeField(labelText, input) { + var row = document.createElement('label'); + row.className = 'bskyplus-llm-field'; + var label = document.createElement('span'); + label.className = 'bskyplus-llm-label'; + label.textContent = labelText; + row.appendChild(label); + row.appendChild(input); + return row; + } + + function mount() { + if (document.querySelector('.bskyplus-cdn-section')) return null; + + var api = getBrowserApi(); + var section = document.createElement('div'); + section.className = 'section bskyplus-cdn-section'; + + var title = document.createElement('div'); + title.className = 'section-title'; + title.textContent = 'Image CDN'; + section.appendChild(title); + + var description = document.createElement('div'); + description.className = 'section-subtitle'; + description.textContent = 'Use a Bluesky-compatible CDN for post thumbnails, full-size images, and avatar thumbnails.'; + section.appendChild(description); + + var input = document.createElement('input'); + input.type = 'text'; + input.id = 'bskyplusCdnHostname'; + input.className = 'bskyplus-llm-input'; + input.placeholder = 'luna.danielmorrisey.com'; + input.autocomplete = 'off'; + input.spellcheck = false; + section.appendChild(makeField('CDN hostname', input)); + + var actions = document.createElement('div'); + actions.className = 'bskyplus-llm-actions'; + var saveButton = document.createElement('button'); + saveButton.type = 'button'; + saveButton.className = 'secondary-btn bskyplus-cdn-save'; + saveButton.textContent = 'Save'; + actions.appendChild(saveButton); + var resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = 'secondary-btn bskyplus-cdn-reset'; + resetButton.textContent = 'Use Bluesky CDN'; + actions.appendChild(resetButton); + var grantButton = document.createElement('button'); + grantButton.type = 'button'; + grantButton.className = 'secondary-btn bskyplus-cdn-grant'; + grantButton.textContent = 'Grant access'; + grantButton.hidden = true; + actions.appendChild(grantButton); + section.appendChild(actions); + + var status = document.createElement('p'); + status.className = 'bskyplus-llm-status bskyplus-cdn-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + section.appendChild(status); + + var notice = document.createElement('p'); + notice.className = 'bskyplus-llm-notice'; + notice.textContent = 'The chosen CDN receives image requests. Existing images may need to be reloaded.'; + section.appendChild(notice); + + var footer = document.querySelector('.footer'); + var llmSection = document.querySelector('.bskyplus-llm-section'); + var before = llmSection || footer; + if (before && before.parentElement) before.parentElement.insertBefore(section, before); + else document.body.appendChild(section); + + function setStatus(message, isError) { + status.textContent = message; + status.classList.toggle('bskyplus-llm-status-error', !!isError); + } + + function apply(hostname) { + if (!api || !api.runtime || typeof api.runtime.sendMessage !== 'function') { + return Promise.reject(new Error('Image CDN could not be applied.')); + } + return Promise.resolve().then(function () { + return api.runtime.sendMessage({ action: 'APPLY_IMAGE_CDN' }); + }).then(function (response) { + if (!response || response.success !== true || response.hostname !== hostname || (hostname && response.enabled !== true) || (!hostname && response.enabled !== false)) { + throw new Error(response && response.error ? response.error : 'Image CDN could not be applied.'); + } + return response; + }); + } + + var pendingHostname = ''; + var saving = false; + + function setBusy(value) { + saving = value; + input.disabled = value; + saveButton.disabled = value; + resetButton.disabled = value; + grantButton.disabled = value; + } + + function requestAccess(hostname) { + var origins = config.permissionOrigins(hostname); + if (!origins.length || !api || !api.permissions || typeof api.permissions.request !== 'function') return Promise.resolve(true); + try { + return Promise.resolve(api.permissions.request({ origins: origins })); + } catch (err) { + return Promise.reject(err); + } + } + + function saveValue(value) { + if (saving) return; + var hostname; + try { + hostname = config.normalizeHostname(value); + } catch (err) { + setStatus(err.message, true); + return; + } + setBusy(true); + grantButton.hidden = true; + pendingHostname = hostname; + // Persist first so a denied permission never loses the user's choice. + var saved = false; + storageSet(api, { imageCdnHostname: hostname }).then(function () { + saved = true; + input.value = hostname; + return requestAccess(hostname); + }).then(function (granted) { + if (!granted) { + grantButton.hidden = !hostname; + setStatus('CDN saved. Access was not granted. Choose Grant access to try again.', true); + return; + } + return apply(hostname).then(function () { + grantButton.hidden = true; + setStatus(hostname ? 'Image CDN is active.' : 'Image CDN disabled.', false); + }); + }).catch(function (err) { + grantButton.hidden = !(saved && hostname); + setStatus(err && err.message ? err.message : 'Could not apply image CDN.', true); + }).finally(function () { + setBusy(false); + }); + } + + function save() { + saveValue(input.value); + } + + function reset() { + input.value = ''; + saveValue(''); + } + + function retry() { + if (saving) return; + setBusy(true); + requestAccess(pendingHostname).then(function (granted) { + if (!granted) throw new Error('Access was not granted. Choose Grant access to try again.'); + return apply(pendingHostname); + }).then(function () { + grantButton.hidden = true; + setStatus(pendingHostname ? 'Image CDN is active.' : 'Image CDN disabled.', false); + }).catch(function (err) { + grantButton.hidden = !pendingHostname; + setStatus(err && err.message ? err.message : 'Could not apply image CDN.', true); + }).finally(function () { + setBusy(false); + }); + } + + saveButton.addEventListener('click', save); + resetButton.addEventListener('click', reset); + grantButton.addEventListener('click', retry); + setBusy(true); + storageGet(api, STORAGE_KEY).then(function (result) { + var value = result && typeof result[STORAGE_KEY] === 'string' ? result[STORAGE_KEY] : ''; + try { + input.value = config.normalizeHostname(value); + pendingHostname = input.value; + } catch (err) { + input.value = ''; + pendingHostname = ''; + } + }).catch(function () { + setStatus('Could not load image CDN settings.', true); + }).finally(function () { + setBusy(false); + }); + + return { input: input, saveButton: saveButton, grantButton: grantButton, status: status }; + } + + if (typeof module !== 'undefined' && module.exports) module.exports = config; + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); + else mount(); + } +})(); diff --git a/extension/chrome/shared/integrated-settings.css b/extension/chrome/shared/integrated-settings.css index 3fb2be6..b397a81 100644 --- a/extension/chrome/shared/integrated-settings.css +++ b/extension/chrome/shared/integrated-settings.css @@ -1,18 +1,59 @@ [data-bskyplus-settings-link] { display: flex; align-items: center; - justify-content: space-between; + flex-direction: row; + justify-content: flex-start; + text-decoration: none; +} + +[data-bskyplus-settings-row] { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; box-sizing: border-box; min-height: 48px; - padding: 12px 24px; - font: 600 16px/24px InterVariable, system-ui, sans-serif; - text-decoration: none; + padding: 8px 20px; } [data-bskyplus-settings-link]:hover { background: color-mix(in srgb, currentColor 6%, transparent); } +[data-bskyplus-settings-icon], +[data-bskyplus-settings-chevron] { + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 20; + flex-shrink: 0; + width: 24px; + height: 24px; +} + +[data-bskyplus-settings-icon] > svg { + display: block; + margin-left: 5px; + width: 90%; + height: 90%; +} + +[data-bskyplus-settings-chevron] { + width: 18px; + height: 18px; +} + +[data-bskyplus-settings-label] { + flex: 1 1 0%; + min-width: 0; + font: 400 15px/20px InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + letter-spacing: 0; + text-align: left; + font-variant: no-contextual unicode; +} + [data-bskyplus-settings-host] > :not([data-bskyplus-settings-page]) { display: none !important; } diff --git a/extension/chrome/shared/integrated-settings.js b/extension/chrome/shared/integrated-settings.js index 457a3d1..0863650 100644 --- a/extension/chrome/shared/integrated-settings.js +++ b/extension/chrome/shared/integrated-settings.js @@ -7,6 +7,8 @@ let previousTitle; let scheduled = false; let settingsFrame; + let nativeCenter; + let centerObserver; let bootstrapRequested = location.pathname === '/settings' && new URLSearchParams(location.search).get('bskyplus') === '1'; @@ -19,14 +21,64 @@ const link = document.createElement('a'); link.href = '/bskyplus'; link.dataset.bskyplusSettingsLink = ''; - const label = document.createElement('span'); + link.setAttribute('aria-label', 'Bsky+'); + link.setAttribute('aria-pressed', 'false'); + link.setAttribute('role', 'link'); + link.setAttribute('data-no-underline', '1'); + link.tabIndex = 0; + const row = document.createElement('div'); + row.dataset.bskyplusSettingsRow = ''; + const iconWrapper = document.createElement('div'); + iconWrapper.dataset.bskyplusSettingsIcon = ''; + const svgNamespace = 'http://www.w3.org/2000/svg'; + const icon = document.createElementNS(svgNamespace, 'svg'); + icon.setAttribute('viewBox', '0 0 160 140'); + icon.setAttribute('fill', 'currentColor'); + icon.setAttribute('width', '24'); + icon.setAttribute('height', '24'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + const glyph = document.createElementNS(svgNamespace, 'path'); + glyph.setAttribute('fill-rule', 'evenodd'); + glyph.setAttribute('d', 'M10 10h50c20 0 32 11 32 29 0 11-7 20-18 25 15 5 24 16 24 33 0 19-16 29-38 29H10V10zm22 16h26c11 0 17 5 17 14s-6 14-17 14H32V26zm0 44h29c12 0 19 7 19 18s-7 22-19 22H32V70z'); + icon.append(glyph); + for (const attributes of [ + { x: '110', y: '34', width: '36', height: '10', rx: '3' }, + { x: '123', y: '21', width: '10', height: '36', rx: '3' }, + ]) { + const rect = document.createElementNS(svgNamespace, 'rect'); + for (const [name, value] of Object.entries(attributes)) rect.setAttribute(name, value); + icon.append(rect); + } + const label = document.createElement('div'); + label.dir = 'auto'; + label.dataset.bskyplusSettingsLabel = ''; label.textContent = 'Bsky+'; const nativeLabel = reference.querySelector('[dir="auto"]') || reference; link.style.color = getComputedStyle(nativeLabel).color; - const chevron = document.createElement('span'); - chevron.textContent = '›'; + const chevron = document.createElement('div'); + chevron.dataset.bskyplusSettingsChevron = ''; chevron.setAttribute('aria-hidden', 'true'); - link.append(label, chevron); + const nativeChevron = reference.querySelector('svg:last-child'); + const nativeIcons = reference.querySelectorAll('svg'); + chevron.style.color = nativeIcons.length > 1 + ? getComputedStyle(nativeIcons[nativeIcons.length - 1]).color + : getComputedStyle(nativeChevron || nativeLabel).color; + const arrow = document.createElementNS(svgNamespace, 'svg'); + arrow.setAttribute('viewBox', '0 0 24 24'); + arrow.setAttribute('width', '18'); + arrow.setAttribute('height', '18'); + arrow.setAttribute('focusable', 'false'); + const arrowPath = document.createElementNS(svgNamespace, 'path'); + arrowPath.setAttribute('fill', 'currentColor'); + arrowPath.setAttribute('fill-rule', 'evenodd'); + arrowPath.setAttribute('clip-rule', 'evenodd'); + arrowPath.setAttribute('d', 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z'); + arrow.append(arrowPath); + chevron.append(arrow); + iconWrapper.append(icon); + row.append(iconWrapper, label, chevron); + link.append(row); link.addEventListener('click', event => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); @@ -35,10 +87,14 @@ page?.querySelector('h1')?.focus(); window.scrollTo(0, 0); }); - reference.after(link); + const firstRow = Array.from(reference.parentElement.children) + .find(element => element.matches('a[href^="/settings/"]')) || reference; + firstRow.before(link); } function unmountPage() { + centerObserver?.disconnect(); + centerObserver = nativeCenter = undefined; host?.removeAttribute('data-bskyplus-settings-host'); page?.remove(); settingsFrame = undefined; @@ -49,6 +105,34 @@ previousTitle = undefined; } + function findNativeCenter(reference) { + const content = reference?.closest('#content'); + let candidate; + for (let element = reference?.parentElement; element && element !== content; element = element.parentElement) { + if (element.style.transform || getComputedStyle(element).transform !== 'none') return element; + if (element.style.maxWidth || element.hasAttribute('data-bskyplus-feed-column')) candidate = element; + } + return candidate || content?.firstElementChild; + } + + function syncAlignment() { + if (!page || !nativeCenter) return; + const transform = nativeCenter.style.transform || getComputedStyle(nativeCenter).transform; + if (page.style.transform !== transform) page.style.transform = transform; + } + + function trackNativeCenter(element) { + if (nativeCenter !== element) { + centerObserver?.disconnect(); + nativeCenter = element; + if (nativeCenter) { + centerObserver = new MutationObserver(syncAlignment); + centerObserver.observe(nativeCenter, { attributes: true, attributeFilter: ['style', 'class'] }); + } + } + syncAlignment(); + } + function mountPage() { const reference = document.querySelector('a[href="/settings/about"]') || document.querySelector('a[href="/settings/account"]'); @@ -62,8 +146,6 @@ const nativeTitle = host.querySelector('[role="heading"], h1, [dir="auto"]'); page = document.createElement('section'); page.dataset.bskyplusSettingsPage = ''; - const nativeCenter = reference?.closest('#content')?.firstElementChild; - if (nativeCenter) page.style.transform = nativeCenter.style.transform; page.setAttribute('aria-label', 'Bsky+'); if (nativeTitle) page.style.color = getComputedStyle(nativeTitle).color; @@ -90,6 +172,7 @@ host.dataset.bskyplusSettingsHost = ''; host.append(page); } + trackNativeCenter(findNativeCenter(reference) || screen.firstElementChild); if (document.title !== 'Bsky+ — Bluesky') document.title = 'Bsky+ — Bluesky'; } @@ -150,5 +233,6 @@ }); window.addEventListener('popstate', scheduleUpdate); window.addEventListener('pageshow', scheduleUpdate); + window.addEventListener('resize', scheduleUpdate); scheduleUpdate(); })(); diff --git a/extension/chrome/shared/settings-frame.css b/extension/chrome/shared/settings-frame.css index f56b733..5cefbe1 100644 --- a/extension/chrome/shared/settings-frame.css +++ b/extension/chrome/shared/settings-frame.css @@ -82,6 +82,11 @@ body { margin-bottom: 14px; } +.bskyplus-cdn-section .bskyplus-llm-actions { + gap: 8px; + flex-wrap: wrap; +} + input:focus-visible, button:focus-visible { outline: 2px solid var(--accent-default); diff --git a/extension/chrome/shared/settings-frame.js b/extension/chrome/shared/settings-frame.js index 7e65f1f..95031f9 100644 --- a/extension/chrome/shared/settings-frame.js +++ b/extension/chrome/shared/settings-frame.js @@ -49,6 +49,8 @@ await loadScript('shared/feature-flags.js'); await loadScript('popup/popup.js'); await loadScript('shared/llm-settings.js'); + await loadScript('shared/cdn-config.js'); + await loadScript('shared/cdn-settings.js'); // Keep this long-lived page current when another extension view saves. api.storage.onChanged.addListener((changes, area) => { diff --git a/extension/firefox/background.html b/extension/firefox/background.html index 4b9934d..ac8286e 100644 --- a/extension/firefox/background.html +++ b/extension/firefox/background.html @@ -4,6 +4,8 @@ Bsky+ Background + + diff --git a/extension/firefox/background.js b/extension/firefox/background.js index e7e0639..3c106a9 100644 --- a/extension/firefox/background.js +++ b/extension/firefox/background.js @@ -7,6 +7,15 @@ const DEFAULT_SETTINGS = { const browserAPI = typeof browser !== 'undefined' ? browser : chrome; +browserAPI.action.onClicked.addListener(tab => { + const url = 'https://bsky.app/bskyplus'; + if (tab?.id !== undefined && tab.url && new URL(tab.url).hostname === 'bsky.app') { + browserAPI.tabs.update(tab.id, { url }); + } else { + browserAPI.tabs.create({ url }); + } +}); + // Initialize default settings on install browserAPI.runtime.onInstalled.addListener(async () => { const data = await browserAPI.storage.local.get(null); diff --git a/extension/firefox/manifest.json b/extension/firefox/manifest.json index 3b44bf4..21aac8a 100644 --- a/extension/firefox/manifest.json +++ b/extension/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Bsky+", - "version": "1.0.3", + "version": "1.0.3.1", "description": "Enhanced features for Bluesky (bsky.app) including local OCR, custom accent colors, and skeet mode.", "browser_specific_settings": { "gecko": { @@ -29,7 +29,6 @@ "page": "background.html" }, "action": { - "default_popup": "popup/popup.html", "default_title": "Bsky+ Settings", "default_icon": { "16": "icons/icon16.png", diff --git a/extension/firefox/popup/popup.css b/extension/firefox/popup/popup.css index efacaa1..70049c4 100644 --- a/extension/firefox/popup/popup.css +++ b/extension/firefox/popup/popup.css @@ -342,6 +342,16 @@ body { color: #fff; } +.bskyplus-cdn-section .bskyplus-llm-actions { + flex-wrap: wrap; + gap: 8px; +} + +.bskyplus-cdn-section .secondary-btn { + flex: 1 1 auto; + min-width: 0; +} + .footer { text-align: center; margin-top: 14px; diff --git a/extension/firefox/popup/popup.html b/extension/firefox/popup/popup.html index 4075d09..7b2b7d8 100644 --- a/extension/firefox/popup/popup.html +++ b/extension/firefox/popup/popup.html @@ -126,5 +126,7 @@ + + diff --git a/extension/firefox/shared/cdn-background.js b/extension/firefox/shared/cdn-background.js new file mode 100644 index 0000000..0a4a02a --- /dev/null +++ b/extension/firefox/shared/cdn-background.js @@ -0,0 +1,64 @@ +(() => { + 'use strict'; + const api = typeof browser !== 'undefined' ? browser : chrome; + const config = globalThis.BskyPlusCdnConfig; + const useRules = api.runtime.getManifest().permissions.some(permission => + permission === 'declarativeNetRequest' || permission === 'declarativeNetRequestWithHostAccess'); + let activeHostname = ''; + let queue = Promise.resolve(); + + function apply() { + // Serialize startup, permission changes, and saves so an older update + // cannot overwrite the rules for a newer hostname. + const update = queue.catch(() => {}).then(async () => { + const stored = await api.storage.local.get('imageCdnHostname'); + let hostname = ''; + try { hostname = config.normalizeHostname(stored.imageCdnHostname || ''); } catch {} + const enabled = !!hostname && await api.permissions.contains({ origins: config.permissionOrigins(hostname) }); + if (useRules) { + await api.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [config.RULE_ID], + addRules: enabled ? [config.makeRule(hostname)] : [], + }); + } + activeHostname = enabled ? hostname : ''; + return { success: true, enabled, hostname }; + }); + queue = update; + return update; + } + + if (!useRules) { + // Firefox supports blocking request redirects on the existing minimum + // version. Wait for startup storage/permission checks before redirecting. + api.webRequest.onBeforeRequest.addListener(async details => { + await queue.catch(() => {}); + const redirectUrl = config.redirectUrl(details.url, activeHostname); + return redirectUrl ? { redirectUrl } : {}; + }, { + urls: [ + 'https://cdn.bsky.app/img/feed_thumbnail/plain/*', + 'https://cdn.bsky.app/img/feed_fullsize/plain/*', + 'https://cdn.bsky.app/img/avatar_thumbnail/plain/*', + ], + types: ['image', 'xmlhttprequest', 'main_frame'], + }, ['blocking']); + } + + const refresh = () => { apply().catch(() => { activeHostname = ''; }); }; + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.imageCdnHostname) refresh(); + }); + api.permissions.onAdded.addListener(refresh); + api.permissions.onRemoved.addListener(refresh); + api.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.action !== 'APPLY_IMAGE_CDN') return; + if (sender.id !== api.runtime.id || !sender.url?.startsWith(api.runtime.getURL(''))) { + sendResponse({ success: false, error: 'Open Bsky+ settings to change the CDN.' }); + return false; + } + apply().then(sendResponse, () => sendResponse({ success: false, error: 'Could not apply the image CDN. Try saving again.' })); + return true; + }); + refresh(); +})(); diff --git a/extension/firefox/shared/cdn-config.js b/extension/firefox/shared/cdn-config.js new file mode 100644 index 0000000..6e38532 --- /dev/null +++ b/extension/firefox/shared/cdn-config.js @@ -0,0 +1,51 @@ +(() => { + 'use strict'; + const SOURCE_HOST = 'cdn.bsky.app'; + const IMAGE_PATTERN = '^https://cdn\\.bsky\\.app/img/(feed_thumbnail|feed_fullsize|avatar_thumbnail)/plain/'; + const RULE_ID = 2001; + + function normalizeHostname(value) { + if (typeof value !== 'string') throw new Error('Enter a CDN hostname.'); + const input = value.trim(); + if (!input) return ''; + const authority = input.replace(/^https:\/\//i, '').replace(/\/$/, ''); + if (!authority || /[\s:/\\@?#%]/.test(authority)) { + throw new Error('Enter a hostname only, without a path, port, or login details.'); + } + let hostname; + try { + hostname = new URL(`https://${authority}`).hostname.toLowerCase().replace(/\.$/, ''); + } catch { + throw new Error('Enter a valid CDN hostname.'); + } + if (hostname.length > 253 || !hostname.split('.').every(label => + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) { + throw new Error('Enter a valid CDN hostname.'); + } + return hostname === SOURCE_HOST ? '' : hostname; + } + + function permissionOrigins(hostname) { + return hostname ? [`https://${SOURCE_HOST}/*`, `https://${hostname}/*`] : []; + } + + function makeRule(hostname) { + return { + id: RULE_ID, + priority: 1, + action: { type: 'redirect', redirect: { transform: { host: hostname } } }, + condition: { + regexFilter: IMAGE_PATTERN, + isUrlFilterCaseSensitive: true, + resourceTypes: ['image', 'xmlhttprequest', 'main_frame'], + }, + }; + } + + function redirectUrl(url, hostname) { + if (!hostname || !new RegExp(IMAGE_PATTERN).test(url)) return undefined; + return `https://${hostname}${url.slice(`https://${SOURCE_HOST}`.length)}`; + } + + globalThis.BskyPlusCdnConfig = { normalizeHostname, permissionOrigins, makeRule, redirectUrl, RULE_ID }; +})(); diff --git a/extension/firefox/shared/cdn-settings.js b/extension/firefox/shared/cdn-settings.js new file mode 100644 index 0000000..e63bfa6 --- /dev/null +++ b/extension/firefox/shared/cdn-settings.js @@ -0,0 +1,242 @@ +// Bsky+ image CDN settings module. +// This module is loaded by the settings frame after the popup settings script. +(function () { + 'use strict'; + + var STORAGE_KEY = 'imageCdnHostname'; + var config = typeof globalThis !== 'undefined' ? globalThis.BskyPlusCdnConfig : null; + + function getBrowserApi() { + if (typeof browser !== 'undefined' && browser && browser.runtime) return browser; + if (typeof chrome !== 'undefined' && chrome && chrome.runtime) return chrome; + return null; + } + + function storageGet(api, key) { + if (!api || !api.storage || !api.storage.local || typeof api.storage.local.get !== 'function') return Promise.resolve({}); + return Promise.resolve().then(function () { return api.storage.local.get(key); }).then(function (result) { + return result || {}; + }, function () { return {}; }); + } + + function storageSet(api, value) { + if (!api || !api.storage || !api.storage.local || typeof api.storage.local.set !== 'function') { + return Promise.reject(new Error('Settings storage is unavailable.')); + } + return new Promise(function (resolve, reject) { + var result; + try { + result = api.storage.local.set(value); + } catch (err) { + reject(err); + return; + } + if (result && typeof result.then === 'function') result.then(resolve, reject); + else reject(new Error('Storage did not confirm the write.')); + }); + } + + function makeField(labelText, input) { + var row = document.createElement('label'); + row.className = 'bskyplus-llm-field'; + var label = document.createElement('span'); + label.className = 'bskyplus-llm-label'; + label.textContent = labelText; + row.appendChild(label); + row.appendChild(input); + return row; + } + + function mount() { + if (document.querySelector('.bskyplus-cdn-section')) return null; + + var api = getBrowserApi(); + var section = document.createElement('div'); + section.className = 'section bskyplus-cdn-section'; + + var title = document.createElement('div'); + title.className = 'section-title'; + title.textContent = 'Image CDN'; + section.appendChild(title); + + var description = document.createElement('div'); + description.className = 'section-subtitle'; + description.textContent = 'Use a Bluesky-compatible CDN for post thumbnails, full-size images, and avatar thumbnails.'; + section.appendChild(description); + + var input = document.createElement('input'); + input.type = 'text'; + input.id = 'bskyplusCdnHostname'; + input.className = 'bskyplus-llm-input'; + input.placeholder = 'luna.danielmorrisey.com'; + input.autocomplete = 'off'; + input.spellcheck = false; + section.appendChild(makeField('CDN hostname', input)); + + var actions = document.createElement('div'); + actions.className = 'bskyplus-llm-actions'; + var saveButton = document.createElement('button'); + saveButton.type = 'button'; + saveButton.className = 'secondary-btn bskyplus-cdn-save'; + saveButton.textContent = 'Save'; + actions.appendChild(saveButton); + var resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = 'secondary-btn bskyplus-cdn-reset'; + resetButton.textContent = 'Use Bluesky CDN'; + actions.appendChild(resetButton); + var grantButton = document.createElement('button'); + grantButton.type = 'button'; + grantButton.className = 'secondary-btn bskyplus-cdn-grant'; + grantButton.textContent = 'Grant access'; + grantButton.hidden = true; + actions.appendChild(grantButton); + section.appendChild(actions); + + var status = document.createElement('p'); + status.className = 'bskyplus-llm-status bskyplus-cdn-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + section.appendChild(status); + + var notice = document.createElement('p'); + notice.className = 'bskyplus-llm-notice'; + notice.textContent = 'The chosen CDN receives image requests. Existing images may need to be reloaded.'; + section.appendChild(notice); + + var footer = document.querySelector('.footer'); + var llmSection = document.querySelector('.bskyplus-llm-section'); + var before = llmSection || footer; + if (before && before.parentElement) before.parentElement.insertBefore(section, before); + else document.body.appendChild(section); + + function setStatus(message, isError) { + status.textContent = message; + status.classList.toggle('bskyplus-llm-status-error', !!isError); + } + + function apply(hostname) { + if (!api || !api.runtime || typeof api.runtime.sendMessage !== 'function') { + return Promise.reject(new Error('Image CDN could not be applied.')); + } + return Promise.resolve().then(function () { + return api.runtime.sendMessage({ action: 'APPLY_IMAGE_CDN' }); + }).then(function (response) { + if (!response || response.success !== true || response.hostname !== hostname || (hostname && response.enabled !== true) || (!hostname && response.enabled !== false)) { + throw new Error(response && response.error ? response.error : 'Image CDN could not be applied.'); + } + return response; + }); + } + + var pendingHostname = ''; + var saving = false; + + function setBusy(value) { + saving = value; + input.disabled = value; + saveButton.disabled = value; + resetButton.disabled = value; + grantButton.disabled = value; + } + + function requestAccess(hostname) { + var origins = config.permissionOrigins(hostname); + if (!origins.length || !api || !api.permissions || typeof api.permissions.request !== 'function') return Promise.resolve(true); + try { + return Promise.resolve(api.permissions.request({ origins: origins })); + } catch (err) { + return Promise.reject(err); + } + } + + function saveValue(value) { + if (saving) return; + var hostname; + try { + hostname = config.normalizeHostname(value); + } catch (err) { + setStatus(err.message, true); + return; + } + setBusy(true); + grantButton.hidden = true; + pendingHostname = hostname; + // Persist first so a denied permission never loses the user's choice. + var saved = false; + storageSet(api, { imageCdnHostname: hostname }).then(function () { + saved = true; + input.value = hostname; + return requestAccess(hostname); + }).then(function (granted) { + if (!granted) { + grantButton.hidden = !hostname; + setStatus('CDN saved. Access was not granted. Choose Grant access to try again.', true); + return; + } + return apply(hostname).then(function () { + grantButton.hidden = true; + setStatus(hostname ? 'Image CDN is active.' : 'Image CDN disabled.', false); + }); + }).catch(function (err) { + grantButton.hidden = !(saved && hostname); + setStatus(err && err.message ? err.message : 'Could not apply image CDN.', true); + }).finally(function () { + setBusy(false); + }); + } + + function save() { + saveValue(input.value); + } + + function reset() { + input.value = ''; + saveValue(''); + } + + function retry() { + if (saving) return; + setBusy(true); + requestAccess(pendingHostname).then(function (granted) { + if (!granted) throw new Error('Access was not granted. Choose Grant access to try again.'); + return apply(pendingHostname); + }).then(function () { + grantButton.hidden = true; + setStatus(pendingHostname ? 'Image CDN is active.' : 'Image CDN disabled.', false); + }).catch(function (err) { + grantButton.hidden = !pendingHostname; + setStatus(err && err.message ? err.message : 'Could not apply image CDN.', true); + }).finally(function () { + setBusy(false); + }); + } + + saveButton.addEventListener('click', save); + resetButton.addEventListener('click', reset); + grantButton.addEventListener('click', retry); + setBusy(true); + storageGet(api, STORAGE_KEY).then(function (result) { + var value = result && typeof result[STORAGE_KEY] === 'string' ? result[STORAGE_KEY] : ''; + try { + input.value = config.normalizeHostname(value); + pendingHostname = input.value; + } catch (err) { + input.value = ''; + pendingHostname = ''; + } + }).catch(function () { + setStatus('Could not load image CDN settings.', true); + }).finally(function () { + setBusy(false); + }); + + return { input: input, saveButton: saveButton, grantButton: grantButton, status: status }; + } + + if (typeof module !== 'undefined' && module.exports) module.exports = config; + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); + else mount(); + } +})(); diff --git a/extension/firefox/shared/integrated-settings.css b/extension/firefox/shared/integrated-settings.css index 3fb2be6..b397a81 100644 --- a/extension/firefox/shared/integrated-settings.css +++ b/extension/firefox/shared/integrated-settings.css @@ -1,18 +1,59 @@ [data-bskyplus-settings-link] { display: flex; align-items: center; - justify-content: space-between; + flex-direction: row; + justify-content: flex-start; + text-decoration: none; +} + +[data-bskyplus-settings-row] { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; box-sizing: border-box; min-height: 48px; - padding: 12px 24px; - font: 600 16px/24px InterVariable, system-ui, sans-serif; - text-decoration: none; + padding: 8px 20px; } [data-bskyplus-settings-link]:hover { background: color-mix(in srgb, currentColor 6%, transparent); } +[data-bskyplus-settings-icon], +[data-bskyplus-settings-chevron] { + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 20; + flex-shrink: 0; + width: 24px; + height: 24px; +} + +[data-bskyplus-settings-icon] > svg { + display: block; + margin-left: 5px; + width: 90%; + height: 90%; +} + +[data-bskyplus-settings-chevron] { + width: 18px; + height: 18px; +} + +[data-bskyplus-settings-label] { + flex: 1 1 0%; + min-width: 0; + font: 400 15px/20px InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + letter-spacing: 0; + text-align: left; + font-variant: no-contextual unicode; +} + [data-bskyplus-settings-host] > :not([data-bskyplus-settings-page]) { display: none !important; } diff --git a/extension/firefox/shared/integrated-settings.js b/extension/firefox/shared/integrated-settings.js index 457a3d1..0863650 100644 --- a/extension/firefox/shared/integrated-settings.js +++ b/extension/firefox/shared/integrated-settings.js @@ -7,6 +7,8 @@ let previousTitle; let scheduled = false; let settingsFrame; + let nativeCenter; + let centerObserver; let bootstrapRequested = location.pathname === '/settings' && new URLSearchParams(location.search).get('bskyplus') === '1'; @@ -19,14 +21,64 @@ const link = document.createElement('a'); link.href = '/bskyplus'; link.dataset.bskyplusSettingsLink = ''; - const label = document.createElement('span'); + link.setAttribute('aria-label', 'Bsky+'); + link.setAttribute('aria-pressed', 'false'); + link.setAttribute('role', 'link'); + link.setAttribute('data-no-underline', '1'); + link.tabIndex = 0; + const row = document.createElement('div'); + row.dataset.bskyplusSettingsRow = ''; + const iconWrapper = document.createElement('div'); + iconWrapper.dataset.bskyplusSettingsIcon = ''; + const svgNamespace = 'http://www.w3.org/2000/svg'; + const icon = document.createElementNS(svgNamespace, 'svg'); + icon.setAttribute('viewBox', '0 0 160 140'); + icon.setAttribute('fill', 'currentColor'); + icon.setAttribute('width', '24'); + icon.setAttribute('height', '24'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + const glyph = document.createElementNS(svgNamespace, 'path'); + glyph.setAttribute('fill-rule', 'evenodd'); + glyph.setAttribute('d', 'M10 10h50c20 0 32 11 32 29 0 11-7 20-18 25 15 5 24 16 24 33 0 19-16 29-38 29H10V10zm22 16h26c11 0 17 5 17 14s-6 14-17 14H32V26zm0 44h29c12 0 19 7 19 18s-7 22-19 22H32V70z'); + icon.append(glyph); + for (const attributes of [ + { x: '110', y: '34', width: '36', height: '10', rx: '3' }, + { x: '123', y: '21', width: '10', height: '36', rx: '3' }, + ]) { + const rect = document.createElementNS(svgNamespace, 'rect'); + for (const [name, value] of Object.entries(attributes)) rect.setAttribute(name, value); + icon.append(rect); + } + const label = document.createElement('div'); + label.dir = 'auto'; + label.dataset.bskyplusSettingsLabel = ''; label.textContent = 'Bsky+'; const nativeLabel = reference.querySelector('[dir="auto"]') || reference; link.style.color = getComputedStyle(nativeLabel).color; - const chevron = document.createElement('span'); - chevron.textContent = '›'; + const chevron = document.createElement('div'); + chevron.dataset.bskyplusSettingsChevron = ''; chevron.setAttribute('aria-hidden', 'true'); - link.append(label, chevron); + const nativeChevron = reference.querySelector('svg:last-child'); + const nativeIcons = reference.querySelectorAll('svg'); + chevron.style.color = nativeIcons.length > 1 + ? getComputedStyle(nativeIcons[nativeIcons.length - 1]).color + : getComputedStyle(nativeChevron || nativeLabel).color; + const arrow = document.createElementNS(svgNamespace, 'svg'); + arrow.setAttribute('viewBox', '0 0 24 24'); + arrow.setAttribute('width', '18'); + arrow.setAttribute('height', '18'); + arrow.setAttribute('focusable', 'false'); + const arrowPath = document.createElementNS(svgNamespace, 'path'); + arrowPath.setAttribute('fill', 'currentColor'); + arrowPath.setAttribute('fill-rule', 'evenodd'); + arrowPath.setAttribute('clip-rule', 'evenodd'); + arrowPath.setAttribute('d', 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z'); + arrow.append(arrowPath); + chevron.append(arrow); + iconWrapper.append(icon); + row.append(iconWrapper, label, chevron); + link.append(row); link.addEventListener('click', event => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); @@ -35,10 +87,14 @@ page?.querySelector('h1')?.focus(); window.scrollTo(0, 0); }); - reference.after(link); + const firstRow = Array.from(reference.parentElement.children) + .find(element => element.matches('a[href^="/settings/"]')) || reference; + firstRow.before(link); } function unmountPage() { + centerObserver?.disconnect(); + centerObserver = nativeCenter = undefined; host?.removeAttribute('data-bskyplus-settings-host'); page?.remove(); settingsFrame = undefined; @@ -49,6 +105,34 @@ previousTitle = undefined; } + function findNativeCenter(reference) { + const content = reference?.closest('#content'); + let candidate; + for (let element = reference?.parentElement; element && element !== content; element = element.parentElement) { + if (element.style.transform || getComputedStyle(element).transform !== 'none') return element; + if (element.style.maxWidth || element.hasAttribute('data-bskyplus-feed-column')) candidate = element; + } + return candidate || content?.firstElementChild; + } + + function syncAlignment() { + if (!page || !nativeCenter) return; + const transform = nativeCenter.style.transform || getComputedStyle(nativeCenter).transform; + if (page.style.transform !== transform) page.style.transform = transform; + } + + function trackNativeCenter(element) { + if (nativeCenter !== element) { + centerObserver?.disconnect(); + nativeCenter = element; + if (nativeCenter) { + centerObserver = new MutationObserver(syncAlignment); + centerObserver.observe(nativeCenter, { attributes: true, attributeFilter: ['style', 'class'] }); + } + } + syncAlignment(); + } + function mountPage() { const reference = document.querySelector('a[href="/settings/about"]') || document.querySelector('a[href="/settings/account"]'); @@ -62,8 +146,6 @@ const nativeTitle = host.querySelector('[role="heading"], h1, [dir="auto"]'); page = document.createElement('section'); page.dataset.bskyplusSettingsPage = ''; - const nativeCenter = reference?.closest('#content')?.firstElementChild; - if (nativeCenter) page.style.transform = nativeCenter.style.transform; page.setAttribute('aria-label', 'Bsky+'); if (nativeTitle) page.style.color = getComputedStyle(nativeTitle).color; @@ -90,6 +172,7 @@ host.dataset.bskyplusSettingsHost = ''; host.append(page); } + trackNativeCenter(findNativeCenter(reference) || screen.firstElementChild); if (document.title !== 'Bsky+ — Bluesky') document.title = 'Bsky+ — Bluesky'; } @@ -150,5 +233,6 @@ }); window.addEventListener('popstate', scheduleUpdate); window.addEventListener('pageshow', scheduleUpdate); + window.addEventListener('resize', scheduleUpdate); scheduleUpdate(); })(); diff --git a/extension/firefox/shared/settings-frame.css b/extension/firefox/shared/settings-frame.css index f56b733..5cefbe1 100644 --- a/extension/firefox/shared/settings-frame.css +++ b/extension/firefox/shared/settings-frame.css @@ -82,6 +82,11 @@ body { margin-bottom: 14px; } +.bskyplus-cdn-section .bskyplus-llm-actions { + gap: 8px; + flex-wrap: wrap; +} + input:focus-visible, button:focus-visible { outline: 2px solid var(--accent-default); diff --git a/extension/firefox/shared/settings-frame.js b/extension/firefox/shared/settings-frame.js index 7e65f1f..95031f9 100644 --- a/extension/firefox/shared/settings-frame.js +++ b/extension/firefox/shared/settings-frame.js @@ -49,6 +49,8 @@ await loadScript('shared/feature-flags.js'); await loadScript('popup/popup.js'); await loadScript('shared/llm-settings.js'); + await loadScript('shared/cdn-config.js'); + await loadScript('shared/cdn-settings.js'); // Keep this long-lived page current when another extension view saves. api.storage.onChanged.addListener((changes, area) => { diff --git a/extension/safari/background.js b/extension/safari/background.js index 6dafa64..ec53f92 100644 --- a/extension/safari/background.js +++ b/extension/safari/background.js @@ -1,4 +1,5 @@ // Bsky+ Background Script for Safari +importScripts('shared/cdn-config.js', 'shared/cdn-background.js'); const DEFAULT_SETTINGS = { skeetMode: false, @@ -7,6 +8,15 @@ const DEFAULT_SETTINGS = { const browserAPI = typeof browser !== 'undefined' ? browser : chrome; +browserAPI.action.onClicked.addListener(tab => { + const url = 'https://bsky.app/bskyplus'; + if (tab?.id !== undefined && tab.url && new URL(tab.url).hostname === 'bsky.app') { + browserAPI.tabs.update(tab.id, { url }); + } else { + browserAPI.tabs.create({ url }); + } +}); + // Initialize default settings on install browserAPI.runtime.onInstalled.addListener(async () => { const data = await browserAPI.storage.local.get(null); diff --git a/extension/safari/manifest.json b/extension/safari/manifest.json index 0391f31..c83a424 100644 --- a/extension/safari/manifest.json +++ b/extension/safari/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Bsky+", - "version": "1.0.3", + "version": "1.0.3.1", "description": "Enhanced features for Bluesky (bsky.app) including local OCR, custom accent colors, and skeet mode.", "permissions": [ "declarativeNetRequest", @@ -28,7 +28,6 @@ "service_worker": "background.js" }, "action": { - "default_popup": "popup/popup.html", "default_title": "Bsky+ Settings", "default_icon": { "16": "icons/icon16.png", diff --git a/extension/safari/popup/popup.css b/extension/safari/popup/popup.css index efacaa1..70049c4 100644 --- a/extension/safari/popup/popup.css +++ b/extension/safari/popup/popup.css @@ -342,6 +342,16 @@ body { color: #fff; } +.bskyplus-cdn-section .bskyplus-llm-actions { + flex-wrap: wrap; + gap: 8px; +} + +.bskyplus-cdn-section .secondary-btn { + flex: 1 1 auto; + min-width: 0; +} + .footer { text-align: center; margin-top: 14px; diff --git a/extension/safari/popup/popup.html b/extension/safari/popup/popup.html index 4075d09..7b2b7d8 100644 --- a/extension/safari/popup/popup.html +++ b/extension/safari/popup/popup.html @@ -126,5 +126,7 @@ + + diff --git a/extension/safari/shared/cdn-background.js b/extension/safari/shared/cdn-background.js new file mode 100644 index 0000000..0a4a02a --- /dev/null +++ b/extension/safari/shared/cdn-background.js @@ -0,0 +1,64 @@ +(() => { + 'use strict'; + const api = typeof browser !== 'undefined' ? browser : chrome; + const config = globalThis.BskyPlusCdnConfig; + const useRules = api.runtime.getManifest().permissions.some(permission => + permission === 'declarativeNetRequest' || permission === 'declarativeNetRequestWithHostAccess'); + let activeHostname = ''; + let queue = Promise.resolve(); + + function apply() { + // Serialize startup, permission changes, and saves so an older update + // cannot overwrite the rules for a newer hostname. + const update = queue.catch(() => {}).then(async () => { + const stored = await api.storage.local.get('imageCdnHostname'); + let hostname = ''; + try { hostname = config.normalizeHostname(stored.imageCdnHostname || ''); } catch {} + const enabled = !!hostname && await api.permissions.contains({ origins: config.permissionOrigins(hostname) }); + if (useRules) { + await api.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [config.RULE_ID], + addRules: enabled ? [config.makeRule(hostname)] : [], + }); + } + activeHostname = enabled ? hostname : ''; + return { success: true, enabled, hostname }; + }); + queue = update; + return update; + } + + if (!useRules) { + // Firefox supports blocking request redirects on the existing minimum + // version. Wait for startup storage/permission checks before redirecting. + api.webRequest.onBeforeRequest.addListener(async details => { + await queue.catch(() => {}); + const redirectUrl = config.redirectUrl(details.url, activeHostname); + return redirectUrl ? { redirectUrl } : {}; + }, { + urls: [ + 'https://cdn.bsky.app/img/feed_thumbnail/plain/*', + 'https://cdn.bsky.app/img/feed_fullsize/plain/*', + 'https://cdn.bsky.app/img/avatar_thumbnail/plain/*', + ], + types: ['image', 'xmlhttprequest', 'main_frame'], + }, ['blocking']); + } + + const refresh = () => { apply().catch(() => { activeHostname = ''; }); }; + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.imageCdnHostname) refresh(); + }); + api.permissions.onAdded.addListener(refresh); + api.permissions.onRemoved.addListener(refresh); + api.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.action !== 'APPLY_IMAGE_CDN') return; + if (sender.id !== api.runtime.id || !sender.url?.startsWith(api.runtime.getURL(''))) { + sendResponse({ success: false, error: 'Open Bsky+ settings to change the CDN.' }); + return false; + } + apply().then(sendResponse, () => sendResponse({ success: false, error: 'Could not apply the image CDN. Try saving again.' })); + return true; + }); + refresh(); +})(); diff --git a/extension/safari/shared/cdn-config.js b/extension/safari/shared/cdn-config.js new file mode 100644 index 0000000..6e38532 --- /dev/null +++ b/extension/safari/shared/cdn-config.js @@ -0,0 +1,51 @@ +(() => { + 'use strict'; + const SOURCE_HOST = 'cdn.bsky.app'; + const IMAGE_PATTERN = '^https://cdn\\.bsky\\.app/img/(feed_thumbnail|feed_fullsize|avatar_thumbnail)/plain/'; + const RULE_ID = 2001; + + function normalizeHostname(value) { + if (typeof value !== 'string') throw new Error('Enter a CDN hostname.'); + const input = value.trim(); + if (!input) return ''; + const authority = input.replace(/^https:\/\//i, '').replace(/\/$/, ''); + if (!authority || /[\s:/\\@?#%]/.test(authority)) { + throw new Error('Enter a hostname only, without a path, port, or login details.'); + } + let hostname; + try { + hostname = new URL(`https://${authority}`).hostname.toLowerCase().replace(/\.$/, ''); + } catch { + throw new Error('Enter a valid CDN hostname.'); + } + if (hostname.length > 253 || !hostname.split('.').every(label => + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) { + throw new Error('Enter a valid CDN hostname.'); + } + return hostname === SOURCE_HOST ? '' : hostname; + } + + function permissionOrigins(hostname) { + return hostname ? [`https://${SOURCE_HOST}/*`, `https://${hostname}/*`] : []; + } + + function makeRule(hostname) { + return { + id: RULE_ID, + priority: 1, + action: { type: 'redirect', redirect: { transform: { host: hostname } } }, + condition: { + regexFilter: IMAGE_PATTERN, + isUrlFilterCaseSensitive: true, + resourceTypes: ['image', 'xmlhttprequest', 'main_frame'], + }, + }; + } + + function redirectUrl(url, hostname) { + if (!hostname || !new RegExp(IMAGE_PATTERN).test(url)) return undefined; + return `https://${hostname}${url.slice(`https://${SOURCE_HOST}`.length)}`; + } + + globalThis.BskyPlusCdnConfig = { normalizeHostname, permissionOrigins, makeRule, redirectUrl, RULE_ID }; +})(); diff --git a/extension/safari/shared/cdn-settings.js b/extension/safari/shared/cdn-settings.js new file mode 100644 index 0000000..e63bfa6 --- /dev/null +++ b/extension/safari/shared/cdn-settings.js @@ -0,0 +1,242 @@ +// Bsky+ image CDN settings module. +// This module is loaded by the settings frame after the popup settings script. +(function () { + 'use strict'; + + var STORAGE_KEY = 'imageCdnHostname'; + var config = typeof globalThis !== 'undefined' ? globalThis.BskyPlusCdnConfig : null; + + function getBrowserApi() { + if (typeof browser !== 'undefined' && browser && browser.runtime) return browser; + if (typeof chrome !== 'undefined' && chrome && chrome.runtime) return chrome; + return null; + } + + function storageGet(api, key) { + if (!api || !api.storage || !api.storage.local || typeof api.storage.local.get !== 'function') return Promise.resolve({}); + return Promise.resolve().then(function () { return api.storage.local.get(key); }).then(function (result) { + return result || {}; + }, function () { return {}; }); + } + + function storageSet(api, value) { + if (!api || !api.storage || !api.storage.local || typeof api.storage.local.set !== 'function') { + return Promise.reject(new Error('Settings storage is unavailable.')); + } + return new Promise(function (resolve, reject) { + var result; + try { + result = api.storage.local.set(value); + } catch (err) { + reject(err); + return; + } + if (result && typeof result.then === 'function') result.then(resolve, reject); + else reject(new Error('Storage did not confirm the write.')); + }); + } + + function makeField(labelText, input) { + var row = document.createElement('label'); + row.className = 'bskyplus-llm-field'; + var label = document.createElement('span'); + label.className = 'bskyplus-llm-label'; + label.textContent = labelText; + row.appendChild(label); + row.appendChild(input); + return row; + } + + function mount() { + if (document.querySelector('.bskyplus-cdn-section')) return null; + + var api = getBrowserApi(); + var section = document.createElement('div'); + section.className = 'section bskyplus-cdn-section'; + + var title = document.createElement('div'); + title.className = 'section-title'; + title.textContent = 'Image CDN'; + section.appendChild(title); + + var description = document.createElement('div'); + description.className = 'section-subtitle'; + description.textContent = 'Use a Bluesky-compatible CDN for post thumbnails, full-size images, and avatar thumbnails.'; + section.appendChild(description); + + var input = document.createElement('input'); + input.type = 'text'; + input.id = 'bskyplusCdnHostname'; + input.className = 'bskyplus-llm-input'; + input.placeholder = 'luna.danielmorrisey.com'; + input.autocomplete = 'off'; + input.spellcheck = false; + section.appendChild(makeField('CDN hostname', input)); + + var actions = document.createElement('div'); + actions.className = 'bskyplus-llm-actions'; + var saveButton = document.createElement('button'); + saveButton.type = 'button'; + saveButton.className = 'secondary-btn bskyplus-cdn-save'; + saveButton.textContent = 'Save'; + actions.appendChild(saveButton); + var resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = 'secondary-btn bskyplus-cdn-reset'; + resetButton.textContent = 'Use Bluesky CDN'; + actions.appendChild(resetButton); + var grantButton = document.createElement('button'); + grantButton.type = 'button'; + grantButton.className = 'secondary-btn bskyplus-cdn-grant'; + grantButton.textContent = 'Grant access'; + grantButton.hidden = true; + actions.appendChild(grantButton); + section.appendChild(actions); + + var status = document.createElement('p'); + status.className = 'bskyplus-llm-status bskyplus-cdn-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + section.appendChild(status); + + var notice = document.createElement('p'); + notice.className = 'bskyplus-llm-notice'; + notice.textContent = 'The chosen CDN receives image requests. Existing images may need to be reloaded.'; + section.appendChild(notice); + + var footer = document.querySelector('.footer'); + var llmSection = document.querySelector('.bskyplus-llm-section'); + var before = llmSection || footer; + if (before && before.parentElement) before.parentElement.insertBefore(section, before); + else document.body.appendChild(section); + + function setStatus(message, isError) { + status.textContent = message; + status.classList.toggle('bskyplus-llm-status-error', !!isError); + } + + function apply(hostname) { + if (!api || !api.runtime || typeof api.runtime.sendMessage !== 'function') { + return Promise.reject(new Error('Image CDN could not be applied.')); + } + return Promise.resolve().then(function () { + return api.runtime.sendMessage({ action: 'APPLY_IMAGE_CDN' }); + }).then(function (response) { + if (!response || response.success !== true || response.hostname !== hostname || (hostname && response.enabled !== true) || (!hostname && response.enabled !== false)) { + throw new Error(response && response.error ? response.error : 'Image CDN could not be applied.'); + } + return response; + }); + } + + var pendingHostname = ''; + var saving = false; + + function setBusy(value) { + saving = value; + input.disabled = value; + saveButton.disabled = value; + resetButton.disabled = value; + grantButton.disabled = value; + } + + function requestAccess(hostname) { + var origins = config.permissionOrigins(hostname); + if (!origins.length || !api || !api.permissions || typeof api.permissions.request !== 'function') return Promise.resolve(true); + try { + return Promise.resolve(api.permissions.request({ origins: origins })); + } catch (err) { + return Promise.reject(err); + } + } + + function saveValue(value) { + if (saving) return; + var hostname; + try { + hostname = config.normalizeHostname(value); + } catch (err) { + setStatus(err.message, true); + return; + } + setBusy(true); + grantButton.hidden = true; + pendingHostname = hostname; + // Persist first so a denied permission never loses the user's choice. + var saved = false; + storageSet(api, { imageCdnHostname: hostname }).then(function () { + saved = true; + input.value = hostname; + return requestAccess(hostname); + }).then(function (granted) { + if (!granted) { + grantButton.hidden = !hostname; + setStatus('CDN saved. Access was not granted. Choose Grant access to try again.', true); + return; + } + return apply(hostname).then(function () { + grantButton.hidden = true; + setStatus(hostname ? 'Image CDN is active.' : 'Image CDN disabled.', false); + }); + }).catch(function (err) { + grantButton.hidden = !(saved && hostname); + setStatus(err && err.message ? err.message : 'Could not apply image CDN.', true); + }).finally(function () { + setBusy(false); + }); + } + + function save() { + saveValue(input.value); + } + + function reset() { + input.value = ''; + saveValue(''); + } + + function retry() { + if (saving) return; + setBusy(true); + requestAccess(pendingHostname).then(function (granted) { + if (!granted) throw new Error('Access was not granted. Choose Grant access to try again.'); + return apply(pendingHostname); + }).then(function () { + grantButton.hidden = true; + setStatus(pendingHostname ? 'Image CDN is active.' : 'Image CDN disabled.', false); + }).catch(function (err) { + grantButton.hidden = !pendingHostname; + setStatus(err && err.message ? err.message : 'Could not apply image CDN.', true); + }).finally(function () { + setBusy(false); + }); + } + + saveButton.addEventListener('click', save); + resetButton.addEventListener('click', reset); + grantButton.addEventListener('click', retry); + setBusy(true); + storageGet(api, STORAGE_KEY).then(function (result) { + var value = result && typeof result[STORAGE_KEY] === 'string' ? result[STORAGE_KEY] : ''; + try { + input.value = config.normalizeHostname(value); + pendingHostname = input.value; + } catch (err) { + input.value = ''; + pendingHostname = ''; + } + }).catch(function () { + setStatus('Could not load image CDN settings.', true); + }).finally(function () { + setBusy(false); + }); + + return { input: input, saveButton: saveButton, grantButton: grantButton, status: status }; + } + + if (typeof module !== 'undefined' && module.exports) module.exports = config; + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); + else mount(); + } +})(); diff --git a/extension/safari/shared/integrated-settings.css b/extension/safari/shared/integrated-settings.css index 3fb2be6..b397a81 100644 --- a/extension/safari/shared/integrated-settings.css +++ b/extension/safari/shared/integrated-settings.css @@ -1,18 +1,59 @@ [data-bskyplus-settings-link] { display: flex; align-items: center; - justify-content: space-between; + flex-direction: row; + justify-content: flex-start; + text-decoration: none; +} + +[data-bskyplus-settings-row] { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; box-sizing: border-box; min-height: 48px; - padding: 12px 24px; - font: 600 16px/24px InterVariable, system-ui, sans-serif; - text-decoration: none; + padding: 8px 20px; } [data-bskyplus-settings-link]:hover { background: color-mix(in srgb, currentColor 6%, transparent); } +[data-bskyplus-settings-icon], +[data-bskyplus-settings-chevron] { + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 20; + flex-shrink: 0; + width: 24px; + height: 24px; +} + +[data-bskyplus-settings-icon] > svg { + display: block; + margin-left: 5px; + width: 90%; + height: 90%; +} + +[data-bskyplus-settings-chevron] { + width: 18px; + height: 18px; +} + +[data-bskyplus-settings-label] { + flex: 1 1 0%; + min-width: 0; + font: 400 15px/20px InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + letter-spacing: 0; + text-align: left; + font-variant: no-contextual unicode; +} + [data-bskyplus-settings-host] > :not([data-bskyplus-settings-page]) { display: none !important; } diff --git a/extension/safari/shared/integrated-settings.js b/extension/safari/shared/integrated-settings.js index 457a3d1..0863650 100644 --- a/extension/safari/shared/integrated-settings.js +++ b/extension/safari/shared/integrated-settings.js @@ -7,6 +7,8 @@ let previousTitle; let scheduled = false; let settingsFrame; + let nativeCenter; + let centerObserver; let bootstrapRequested = location.pathname === '/settings' && new URLSearchParams(location.search).get('bskyplus') === '1'; @@ -19,14 +21,64 @@ const link = document.createElement('a'); link.href = '/bskyplus'; link.dataset.bskyplusSettingsLink = ''; - const label = document.createElement('span'); + link.setAttribute('aria-label', 'Bsky+'); + link.setAttribute('aria-pressed', 'false'); + link.setAttribute('role', 'link'); + link.setAttribute('data-no-underline', '1'); + link.tabIndex = 0; + const row = document.createElement('div'); + row.dataset.bskyplusSettingsRow = ''; + const iconWrapper = document.createElement('div'); + iconWrapper.dataset.bskyplusSettingsIcon = ''; + const svgNamespace = 'http://www.w3.org/2000/svg'; + const icon = document.createElementNS(svgNamespace, 'svg'); + icon.setAttribute('viewBox', '0 0 160 140'); + icon.setAttribute('fill', 'currentColor'); + icon.setAttribute('width', '24'); + icon.setAttribute('height', '24'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + const glyph = document.createElementNS(svgNamespace, 'path'); + glyph.setAttribute('fill-rule', 'evenodd'); + glyph.setAttribute('d', 'M10 10h50c20 0 32 11 32 29 0 11-7 20-18 25 15 5 24 16 24 33 0 19-16 29-38 29H10V10zm22 16h26c11 0 17 5 17 14s-6 14-17 14H32V26zm0 44h29c12 0 19 7 19 18s-7 22-19 22H32V70z'); + icon.append(glyph); + for (const attributes of [ + { x: '110', y: '34', width: '36', height: '10', rx: '3' }, + { x: '123', y: '21', width: '10', height: '36', rx: '3' }, + ]) { + const rect = document.createElementNS(svgNamespace, 'rect'); + for (const [name, value] of Object.entries(attributes)) rect.setAttribute(name, value); + icon.append(rect); + } + const label = document.createElement('div'); + label.dir = 'auto'; + label.dataset.bskyplusSettingsLabel = ''; label.textContent = 'Bsky+'; const nativeLabel = reference.querySelector('[dir="auto"]') || reference; link.style.color = getComputedStyle(nativeLabel).color; - const chevron = document.createElement('span'); - chevron.textContent = '›'; + const chevron = document.createElement('div'); + chevron.dataset.bskyplusSettingsChevron = ''; chevron.setAttribute('aria-hidden', 'true'); - link.append(label, chevron); + const nativeChevron = reference.querySelector('svg:last-child'); + const nativeIcons = reference.querySelectorAll('svg'); + chevron.style.color = nativeIcons.length > 1 + ? getComputedStyle(nativeIcons[nativeIcons.length - 1]).color + : getComputedStyle(nativeChevron || nativeLabel).color; + const arrow = document.createElementNS(svgNamespace, 'svg'); + arrow.setAttribute('viewBox', '0 0 24 24'); + arrow.setAttribute('width', '18'); + arrow.setAttribute('height', '18'); + arrow.setAttribute('focusable', 'false'); + const arrowPath = document.createElementNS(svgNamespace, 'path'); + arrowPath.setAttribute('fill', 'currentColor'); + arrowPath.setAttribute('fill-rule', 'evenodd'); + arrowPath.setAttribute('clip-rule', 'evenodd'); + arrowPath.setAttribute('d', 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z'); + arrow.append(arrowPath); + chevron.append(arrow); + iconWrapper.append(icon); + row.append(iconWrapper, label, chevron); + link.append(row); link.addEventListener('click', event => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); @@ -35,10 +87,14 @@ page?.querySelector('h1')?.focus(); window.scrollTo(0, 0); }); - reference.after(link); + const firstRow = Array.from(reference.parentElement.children) + .find(element => element.matches('a[href^="/settings/"]')) || reference; + firstRow.before(link); } function unmountPage() { + centerObserver?.disconnect(); + centerObserver = nativeCenter = undefined; host?.removeAttribute('data-bskyplus-settings-host'); page?.remove(); settingsFrame = undefined; @@ -49,6 +105,34 @@ previousTitle = undefined; } + function findNativeCenter(reference) { + const content = reference?.closest('#content'); + let candidate; + for (let element = reference?.parentElement; element && element !== content; element = element.parentElement) { + if (element.style.transform || getComputedStyle(element).transform !== 'none') return element; + if (element.style.maxWidth || element.hasAttribute('data-bskyplus-feed-column')) candidate = element; + } + return candidate || content?.firstElementChild; + } + + function syncAlignment() { + if (!page || !nativeCenter) return; + const transform = nativeCenter.style.transform || getComputedStyle(nativeCenter).transform; + if (page.style.transform !== transform) page.style.transform = transform; + } + + function trackNativeCenter(element) { + if (nativeCenter !== element) { + centerObserver?.disconnect(); + nativeCenter = element; + if (nativeCenter) { + centerObserver = new MutationObserver(syncAlignment); + centerObserver.observe(nativeCenter, { attributes: true, attributeFilter: ['style', 'class'] }); + } + } + syncAlignment(); + } + function mountPage() { const reference = document.querySelector('a[href="/settings/about"]') || document.querySelector('a[href="/settings/account"]'); @@ -62,8 +146,6 @@ const nativeTitle = host.querySelector('[role="heading"], h1, [dir="auto"]'); page = document.createElement('section'); page.dataset.bskyplusSettingsPage = ''; - const nativeCenter = reference?.closest('#content')?.firstElementChild; - if (nativeCenter) page.style.transform = nativeCenter.style.transform; page.setAttribute('aria-label', 'Bsky+'); if (nativeTitle) page.style.color = getComputedStyle(nativeTitle).color; @@ -90,6 +172,7 @@ host.dataset.bskyplusSettingsHost = ''; host.append(page); } + trackNativeCenter(findNativeCenter(reference) || screen.firstElementChild); if (document.title !== 'Bsky+ — Bluesky') document.title = 'Bsky+ — Bluesky'; } @@ -150,5 +233,6 @@ }); window.addEventListener('popstate', scheduleUpdate); window.addEventListener('pageshow', scheduleUpdate); + window.addEventListener('resize', scheduleUpdate); scheduleUpdate(); })(); diff --git a/extension/safari/shared/settings-frame.css b/extension/safari/shared/settings-frame.css index f56b733..5cefbe1 100644 --- a/extension/safari/shared/settings-frame.css +++ b/extension/safari/shared/settings-frame.css @@ -82,6 +82,11 @@ body { margin-bottom: 14px; } +.bskyplus-cdn-section .bskyplus-llm-actions { + gap: 8px; + flex-wrap: wrap; +} + input:focus-visible, button:focus-visible { outline: 2px solid var(--accent-default); diff --git a/extension/safari/shared/settings-frame.js b/extension/safari/shared/settings-frame.js index 7e65f1f..95031f9 100644 --- a/extension/safari/shared/settings-frame.js +++ b/extension/safari/shared/settings-frame.js @@ -49,6 +49,8 @@ await loadScript('shared/feature-flags.js'); await loadScript('popup/popup.js'); await loadScript('shared/llm-settings.js'); + await loadScript('shared/cdn-config.js'); + await loadScript('shared/cdn-settings.js'); // Keep this long-lived page current when another extension view saves. api.storage.onChanged.addListener((changes, area) => { diff --git a/extension/shared/cdn-background.js b/extension/shared/cdn-background.js new file mode 100644 index 0000000..0a4a02a --- /dev/null +++ b/extension/shared/cdn-background.js @@ -0,0 +1,64 @@ +(() => { + 'use strict'; + const api = typeof browser !== 'undefined' ? browser : chrome; + const config = globalThis.BskyPlusCdnConfig; + const useRules = api.runtime.getManifest().permissions.some(permission => + permission === 'declarativeNetRequest' || permission === 'declarativeNetRequestWithHostAccess'); + let activeHostname = ''; + let queue = Promise.resolve(); + + function apply() { + // Serialize startup, permission changes, and saves so an older update + // cannot overwrite the rules for a newer hostname. + const update = queue.catch(() => {}).then(async () => { + const stored = await api.storage.local.get('imageCdnHostname'); + let hostname = ''; + try { hostname = config.normalizeHostname(stored.imageCdnHostname || ''); } catch {} + const enabled = !!hostname && await api.permissions.contains({ origins: config.permissionOrigins(hostname) }); + if (useRules) { + await api.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [config.RULE_ID], + addRules: enabled ? [config.makeRule(hostname)] : [], + }); + } + activeHostname = enabled ? hostname : ''; + return { success: true, enabled, hostname }; + }); + queue = update; + return update; + } + + if (!useRules) { + // Firefox supports blocking request redirects on the existing minimum + // version. Wait for startup storage/permission checks before redirecting. + api.webRequest.onBeforeRequest.addListener(async details => { + await queue.catch(() => {}); + const redirectUrl = config.redirectUrl(details.url, activeHostname); + return redirectUrl ? { redirectUrl } : {}; + }, { + urls: [ + 'https://cdn.bsky.app/img/feed_thumbnail/plain/*', + 'https://cdn.bsky.app/img/feed_fullsize/plain/*', + 'https://cdn.bsky.app/img/avatar_thumbnail/plain/*', + ], + types: ['image', 'xmlhttprequest', 'main_frame'], + }, ['blocking']); + } + + const refresh = () => { apply().catch(() => { activeHostname = ''; }); }; + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.imageCdnHostname) refresh(); + }); + api.permissions.onAdded.addListener(refresh); + api.permissions.onRemoved.addListener(refresh); + api.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.action !== 'APPLY_IMAGE_CDN') return; + if (sender.id !== api.runtime.id || !sender.url?.startsWith(api.runtime.getURL(''))) { + sendResponse({ success: false, error: 'Open Bsky+ settings to change the CDN.' }); + return false; + } + apply().then(sendResponse, () => sendResponse({ success: false, error: 'Could not apply the image CDN. Try saving again.' })); + return true; + }); + refresh(); +})(); diff --git a/extension/shared/cdn-config.js b/extension/shared/cdn-config.js new file mode 100644 index 0000000..6e38532 --- /dev/null +++ b/extension/shared/cdn-config.js @@ -0,0 +1,51 @@ +(() => { + 'use strict'; + const SOURCE_HOST = 'cdn.bsky.app'; + const IMAGE_PATTERN = '^https://cdn\\.bsky\\.app/img/(feed_thumbnail|feed_fullsize|avatar_thumbnail)/plain/'; + const RULE_ID = 2001; + + function normalizeHostname(value) { + if (typeof value !== 'string') throw new Error('Enter a CDN hostname.'); + const input = value.trim(); + if (!input) return ''; + const authority = input.replace(/^https:\/\//i, '').replace(/\/$/, ''); + if (!authority || /[\s:/\\@?#%]/.test(authority)) { + throw new Error('Enter a hostname only, without a path, port, or login details.'); + } + let hostname; + try { + hostname = new URL(`https://${authority}`).hostname.toLowerCase().replace(/\.$/, ''); + } catch { + throw new Error('Enter a valid CDN hostname.'); + } + if (hostname.length > 253 || !hostname.split('.').every(label => + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) { + throw new Error('Enter a valid CDN hostname.'); + } + return hostname === SOURCE_HOST ? '' : hostname; + } + + function permissionOrigins(hostname) { + return hostname ? [`https://${SOURCE_HOST}/*`, `https://${hostname}/*`] : []; + } + + function makeRule(hostname) { + return { + id: RULE_ID, + priority: 1, + action: { type: 'redirect', redirect: { transform: { host: hostname } } }, + condition: { + regexFilter: IMAGE_PATTERN, + isUrlFilterCaseSensitive: true, + resourceTypes: ['image', 'xmlhttprequest', 'main_frame'], + }, + }; + } + + function redirectUrl(url, hostname) { + if (!hostname || !new RegExp(IMAGE_PATTERN).test(url)) return undefined; + return `https://${hostname}${url.slice(`https://${SOURCE_HOST}`.length)}`; + } + + globalThis.BskyPlusCdnConfig = { normalizeHostname, permissionOrigins, makeRule, redirectUrl, RULE_ID }; +})(); diff --git a/extension/shared/cdn-settings.js b/extension/shared/cdn-settings.js new file mode 100644 index 0000000..e63bfa6 --- /dev/null +++ b/extension/shared/cdn-settings.js @@ -0,0 +1,242 @@ +// Bsky+ image CDN settings module. +// This module is loaded by the settings frame after the popup settings script. +(function () { + 'use strict'; + + var STORAGE_KEY = 'imageCdnHostname'; + var config = typeof globalThis !== 'undefined' ? globalThis.BskyPlusCdnConfig : null; + + function getBrowserApi() { + if (typeof browser !== 'undefined' && browser && browser.runtime) return browser; + if (typeof chrome !== 'undefined' && chrome && chrome.runtime) return chrome; + return null; + } + + function storageGet(api, key) { + if (!api || !api.storage || !api.storage.local || typeof api.storage.local.get !== 'function') return Promise.resolve({}); + return Promise.resolve().then(function () { return api.storage.local.get(key); }).then(function (result) { + return result || {}; + }, function () { return {}; }); + } + + function storageSet(api, value) { + if (!api || !api.storage || !api.storage.local || typeof api.storage.local.set !== 'function') { + return Promise.reject(new Error('Settings storage is unavailable.')); + } + return new Promise(function (resolve, reject) { + var result; + try { + result = api.storage.local.set(value); + } catch (err) { + reject(err); + return; + } + if (result && typeof result.then === 'function') result.then(resolve, reject); + else reject(new Error('Storage did not confirm the write.')); + }); + } + + function makeField(labelText, input) { + var row = document.createElement('label'); + row.className = 'bskyplus-llm-field'; + var label = document.createElement('span'); + label.className = 'bskyplus-llm-label'; + label.textContent = labelText; + row.appendChild(label); + row.appendChild(input); + return row; + } + + function mount() { + if (document.querySelector('.bskyplus-cdn-section')) return null; + + var api = getBrowserApi(); + var section = document.createElement('div'); + section.className = 'section bskyplus-cdn-section'; + + var title = document.createElement('div'); + title.className = 'section-title'; + title.textContent = 'Image CDN'; + section.appendChild(title); + + var description = document.createElement('div'); + description.className = 'section-subtitle'; + description.textContent = 'Use a Bluesky-compatible CDN for post thumbnails, full-size images, and avatar thumbnails.'; + section.appendChild(description); + + var input = document.createElement('input'); + input.type = 'text'; + input.id = 'bskyplusCdnHostname'; + input.className = 'bskyplus-llm-input'; + input.placeholder = 'luna.danielmorrisey.com'; + input.autocomplete = 'off'; + input.spellcheck = false; + section.appendChild(makeField('CDN hostname', input)); + + var actions = document.createElement('div'); + actions.className = 'bskyplus-llm-actions'; + var saveButton = document.createElement('button'); + saveButton.type = 'button'; + saveButton.className = 'secondary-btn bskyplus-cdn-save'; + saveButton.textContent = 'Save'; + actions.appendChild(saveButton); + var resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = 'secondary-btn bskyplus-cdn-reset'; + resetButton.textContent = 'Use Bluesky CDN'; + actions.appendChild(resetButton); + var grantButton = document.createElement('button'); + grantButton.type = 'button'; + grantButton.className = 'secondary-btn bskyplus-cdn-grant'; + grantButton.textContent = 'Grant access'; + grantButton.hidden = true; + actions.appendChild(grantButton); + section.appendChild(actions); + + var status = document.createElement('p'); + status.className = 'bskyplus-llm-status bskyplus-cdn-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + section.appendChild(status); + + var notice = document.createElement('p'); + notice.className = 'bskyplus-llm-notice'; + notice.textContent = 'The chosen CDN receives image requests. Existing images may need to be reloaded.'; + section.appendChild(notice); + + var footer = document.querySelector('.footer'); + var llmSection = document.querySelector('.bskyplus-llm-section'); + var before = llmSection || footer; + if (before && before.parentElement) before.parentElement.insertBefore(section, before); + else document.body.appendChild(section); + + function setStatus(message, isError) { + status.textContent = message; + status.classList.toggle('bskyplus-llm-status-error', !!isError); + } + + function apply(hostname) { + if (!api || !api.runtime || typeof api.runtime.sendMessage !== 'function') { + return Promise.reject(new Error('Image CDN could not be applied.')); + } + return Promise.resolve().then(function () { + return api.runtime.sendMessage({ action: 'APPLY_IMAGE_CDN' }); + }).then(function (response) { + if (!response || response.success !== true || response.hostname !== hostname || (hostname && response.enabled !== true) || (!hostname && response.enabled !== false)) { + throw new Error(response && response.error ? response.error : 'Image CDN could not be applied.'); + } + return response; + }); + } + + var pendingHostname = ''; + var saving = false; + + function setBusy(value) { + saving = value; + input.disabled = value; + saveButton.disabled = value; + resetButton.disabled = value; + grantButton.disabled = value; + } + + function requestAccess(hostname) { + var origins = config.permissionOrigins(hostname); + if (!origins.length || !api || !api.permissions || typeof api.permissions.request !== 'function') return Promise.resolve(true); + try { + return Promise.resolve(api.permissions.request({ origins: origins })); + } catch (err) { + return Promise.reject(err); + } + } + + function saveValue(value) { + if (saving) return; + var hostname; + try { + hostname = config.normalizeHostname(value); + } catch (err) { + setStatus(err.message, true); + return; + } + setBusy(true); + grantButton.hidden = true; + pendingHostname = hostname; + // Persist first so a denied permission never loses the user's choice. + var saved = false; + storageSet(api, { imageCdnHostname: hostname }).then(function () { + saved = true; + input.value = hostname; + return requestAccess(hostname); + }).then(function (granted) { + if (!granted) { + grantButton.hidden = !hostname; + setStatus('CDN saved. Access was not granted. Choose Grant access to try again.', true); + return; + } + return apply(hostname).then(function () { + grantButton.hidden = true; + setStatus(hostname ? 'Image CDN is active.' : 'Image CDN disabled.', false); + }); + }).catch(function (err) { + grantButton.hidden = !(saved && hostname); + setStatus(err && err.message ? err.message : 'Could not apply image CDN.', true); + }).finally(function () { + setBusy(false); + }); + } + + function save() { + saveValue(input.value); + } + + function reset() { + input.value = ''; + saveValue(''); + } + + function retry() { + if (saving) return; + setBusy(true); + requestAccess(pendingHostname).then(function (granted) { + if (!granted) throw new Error('Access was not granted. Choose Grant access to try again.'); + return apply(pendingHostname); + }).then(function () { + grantButton.hidden = true; + setStatus(pendingHostname ? 'Image CDN is active.' : 'Image CDN disabled.', false); + }).catch(function (err) { + grantButton.hidden = !pendingHostname; + setStatus(err && err.message ? err.message : 'Could not apply image CDN.', true); + }).finally(function () { + setBusy(false); + }); + } + + saveButton.addEventListener('click', save); + resetButton.addEventListener('click', reset); + grantButton.addEventListener('click', retry); + setBusy(true); + storageGet(api, STORAGE_KEY).then(function (result) { + var value = result && typeof result[STORAGE_KEY] === 'string' ? result[STORAGE_KEY] : ''; + try { + input.value = config.normalizeHostname(value); + pendingHostname = input.value; + } catch (err) { + input.value = ''; + pendingHostname = ''; + } + }).catch(function () { + setStatus('Could not load image CDN settings.', true); + }).finally(function () { + setBusy(false); + }); + + return { input: input, saveButton: saveButton, grantButton: grantButton, status: status }; + } + + if (typeof module !== 'undefined' && module.exports) module.exports = config; + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); + else mount(); + } +})(); diff --git a/extension/shared/integrated-settings.css b/extension/shared/integrated-settings.css index 3fb2be6..b397a81 100644 --- a/extension/shared/integrated-settings.css +++ b/extension/shared/integrated-settings.css @@ -1,18 +1,59 @@ [data-bskyplus-settings-link] { display: flex; align-items: center; - justify-content: space-between; + flex-direction: row; + justify-content: flex-start; + text-decoration: none; +} + +[data-bskyplus-settings-row] { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; box-sizing: border-box; min-height: 48px; - padding: 12px 24px; - font: 600 16px/24px InterVariable, system-ui, sans-serif; - text-decoration: none; + padding: 8px 20px; } [data-bskyplus-settings-link]:hover { background: color-mix(in srgb, currentColor 6%, transparent); } +[data-bskyplus-settings-icon], +[data-bskyplus-settings-chevron] { + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 20; + flex-shrink: 0; + width: 24px; + height: 24px; +} + +[data-bskyplus-settings-icon] > svg { + display: block; + margin-left: 5px; + width: 90%; + height: 90%; +} + +[data-bskyplus-settings-chevron] { + width: 18px; + height: 18px; +} + +[data-bskyplus-settings-label] { + flex: 1 1 0%; + min-width: 0; + font: 400 15px/20px InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + letter-spacing: 0; + text-align: left; + font-variant: no-contextual unicode; +} + [data-bskyplus-settings-host] > :not([data-bskyplus-settings-page]) { display: none !important; } diff --git a/extension/shared/integrated-settings.js b/extension/shared/integrated-settings.js index 457a3d1..0863650 100644 --- a/extension/shared/integrated-settings.js +++ b/extension/shared/integrated-settings.js @@ -7,6 +7,8 @@ let previousTitle; let scheduled = false; let settingsFrame; + let nativeCenter; + let centerObserver; let bootstrapRequested = location.pathname === '/settings' && new URLSearchParams(location.search).get('bskyplus') === '1'; @@ -19,14 +21,64 @@ const link = document.createElement('a'); link.href = '/bskyplus'; link.dataset.bskyplusSettingsLink = ''; - const label = document.createElement('span'); + link.setAttribute('aria-label', 'Bsky+'); + link.setAttribute('aria-pressed', 'false'); + link.setAttribute('role', 'link'); + link.setAttribute('data-no-underline', '1'); + link.tabIndex = 0; + const row = document.createElement('div'); + row.dataset.bskyplusSettingsRow = ''; + const iconWrapper = document.createElement('div'); + iconWrapper.dataset.bskyplusSettingsIcon = ''; + const svgNamespace = 'http://www.w3.org/2000/svg'; + const icon = document.createElementNS(svgNamespace, 'svg'); + icon.setAttribute('viewBox', '0 0 160 140'); + icon.setAttribute('fill', 'currentColor'); + icon.setAttribute('width', '24'); + icon.setAttribute('height', '24'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + const glyph = document.createElementNS(svgNamespace, 'path'); + glyph.setAttribute('fill-rule', 'evenodd'); + glyph.setAttribute('d', 'M10 10h50c20 0 32 11 32 29 0 11-7 20-18 25 15 5 24 16 24 33 0 19-16 29-38 29H10V10zm22 16h26c11 0 17 5 17 14s-6 14-17 14H32V26zm0 44h29c12 0 19 7 19 18s-7 22-19 22H32V70z'); + icon.append(glyph); + for (const attributes of [ + { x: '110', y: '34', width: '36', height: '10', rx: '3' }, + { x: '123', y: '21', width: '10', height: '36', rx: '3' }, + ]) { + const rect = document.createElementNS(svgNamespace, 'rect'); + for (const [name, value] of Object.entries(attributes)) rect.setAttribute(name, value); + icon.append(rect); + } + const label = document.createElement('div'); + label.dir = 'auto'; + label.dataset.bskyplusSettingsLabel = ''; label.textContent = 'Bsky+'; const nativeLabel = reference.querySelector('[dir="auto"]') || reference; link.style.color = getComputedStyle(nativeLabel).color; - const chevron = document.createElement('span'); - chevron.textContent = '›'; + const chevron = document.createElement('div'); + chevron.dataset.bskyplusSettingsChevron = ''; chevron.setAttribute('aria-hidden', 'true'); - link.append(label, chevron); + const nativeChevron = reference.querySelector('svg:last-child'); + const nativeIcons = reference.querySelectorAll('svg'); + chevron.style.color = nativeIcons.length > 1 + ? getComputedStyle(nativeIcons[nativeIcons.length - 1]).color + : getComputedStyle(nativeChevron || nativeLabel).color; + const arrow = document.createElementNS(svgNamespace, 'svg'); + arrow.setAttribute('viewBox', '0 0 24 24'); + arrow.setAttribute('width', '18'); + arrow.setAttribute('height', '18'); + arrow.setAttribute('focusable', 'false'); + const arrowPath = document.createElementNS(svgNamespace, 'path'); + arrowPath.setAttribute('fill', 'currentColor'); + arrowPath.setAttribute('fill-rule', 'evenodd'); + arrowPath.setAttribute('clip-rule', 'evenodd'); + arrowPath.setAttribute('d', 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z'); + arrow.append(arrowPath); + chevron.append(arrow); + iconWrapper.append(icon); + row.append(iconWrapper, label, chevron); + link.append(row); link.addEventListener('click', event => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); @@ -35,10 +87,14 @@ page?.querySelector('h1')?.focus(); window.scrollTo(0, 0); }); - reference.after(link); + const firstRow = Array.from(reference.parentElement.children) + .find(element => element.matches('a[href^="/settings/"]')) || reference; + firstRow.before(link); } function unmountPage() { + centerObserver?.disconnect(); + centerObserver = nativeCenter = undefined; host?.removeAttribute('data-bskyplus-settings-host'); page?.remove(); settingsFrame = undefined; @@ -49,6 +105,34 @@ previousTitle = undefined; } + function findNativeCenter(reference) { + const content = reference?.closest('#content'); + let candidate; + for (let element = reference?.parentElement; element && element !== content; element = element.parentElement) { + if (element.style.transform || getComputedStyle(element).transform !== 'none') return element; + if (element.style.maxWidth || element.hasAttribute('data-bskyplus-feed-column')) candidate = element; + } + return candidate || content?.firstElementChild; + } + + function syncAlignment() { + if (!page || !nativeCenter) return; + const transform = nativeCenter.style.transform || getComputedStyle(nativeCenter).transform; + if (page.style.transform !== transform) page.style.transform = transform; + } + + function trackNativeCenter(element) { + if (nativeCenter !== element) { + centerObserver?.disconnect(); + nativeCenter = element; + if (nativeCenter) { + centerObserver = new MutationObserver(syncAlignment); + centerObserver.observe(nativeCenter, { attributes: true, attributeFilter: ['style', 'class'] }); + } + } + syncAlignment(); + } + function mountPage() { const reference = document.querySelector('a[href="/settings/about"]') || document.querySelector('a[href="/settings/account"]'); @@ -62,8 +146,6 @@ const nativeTitle = host.querySelector('[role="heading"], h1, [dir="auto"]'); page = document.createElement('section'); page.dataset.bskyplusSettingsPage = ''; - const nativeCenter = reference?.closest('#content')?.firstElementChild; - if (nativeCenter) page.style.transform = nativeCenter.style.transform; page.setAttribute('aria-label', 'Bsky+'); if (nativeTitle) page.style.color = getComputedStyle(nativeTitle).color; @@ -90,6 +172,7 @@ host.dataset.bskyplusSettingsHost = ''; host.append(page); } + trackNativeCenter(findNativeCenter(reference) || screen.firstElementChild); if (document.title !== 'Bsky+ — Bluesky') document.title = 'Bsky+ — Bluesky'; } @@ -150,5 +233,6 @@ }); window.addEventListener('popstate', scheduleUpdate); window.addEventListener('pageshow', scheduleUpdate); + window.addEventListener('resize', scheduleUpdate); scheduleUpdate(); })(); diff --git a/extension/shared/settings-frame.css b/extension/shared/settings-frame.css index f56b733..5cefbe1 100644 --- a/extension/shared/settings-frame.css +++ b/extension/shared/settings-frame.css @@ -82,6 +82,11 @@ body { margin-bottom: 14px; } +.bskyplus-cdn-section .bskyplus-llm-actions { + gap: 8px; + flex-wrap: wrap; +} + input:focus-visible, button:focus-visible { outline: 2px solid var(--accent-default); diff --git a/extension/shared/settings-frame.js b/extension/shared/settings-frame.js index 7e65f1f..95031f9 100644 --- a/extension/shared/settings-frame.js +++ b/extension/shared/settings-frame.js @@ -49,6 +49,8 @@ await loadScript('shared/feature-flags.js'); await loadScript('popup/popup.js'); await loadScript('shared/llm-settings.js'); + await loadScript('shared/cdn-config.js'); + await loadScript('shared/cdn-settings.js'); // Keep this long-lived page current when another extension view saves. api.storage.onChanged.addListener((changes, area) => { diff --git a/package-lock.json b/package-lock.json index 508cdf0..ee1f609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bskyplus", - "version": "1.0.3", + "version": "1.0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bskyplus", - "version": "1.0.3", + "version": "1.0.3.1", "license": "ISC", "dependencies": { "puppeteer-core": "^25.10.0", diff --git a/package.json b/package.json index ec44071..28a3f90 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "bskyplus", - "version": "1.0.3", + "version": "1.0.3.1", "description": "", "main": "index.js", "scripts": { - "test": "node --test test/test_new_features.mjs && node --test test/test_llm.mjs && node test/test_composer.mjs && node --test test/test_integrated_settings.mjs && node test/test_firefox_validation.mjs", + "test": "node --test test/test_new_features.mjs && node --test test/test_llm.mjs && node test/test_composer.mjs && node --test test/test_integrated_settings.mjs test/test_cdn.mjs test/test_cdn_settings.mjs && node test/test_firefox_validation.mjs", "test:features": "node --test test/test_new_features.mjs", "test:firefox": "node test/test_firefox_accent.mjs", "test:llm": "node --test test/test_llm.mjs", "test:composer": "node test/test_composer.mjs", "test:settings": "node --test test/test_integrated_settings.mjs", "test:settings:extension": "node test/test_extension_settings.mjs", + "test:cdn": "node --test test/test_cdn.mjs test/test_cdn_settings.mjs", "test:extension": "node test/test_extension_composer.mjs", "sync:shared": "node scripts/sync_shared.mjs", "package:chrome": "node scripts/package_browser.mjs chrome", @@ -18,7 +19,7 @@ "package": "npm run package:chrome && npm run build:firefox && npm run source:firefox && npm run package:safari", "build:firefox": "node scripts/build_firefox_ocr.mjs && node test/test_firefox_validation.mjs && node scripts/package_firefox.mjs", "source:firefox": "node scripts/package_firefox.mjs --source", - "lint:firefox": "addons-linter dist/firefox/bsky_-1.0.3.zip --boring" + "lint:firefox": "addons-linter dist/firefox/bsky_-1.0.3.1.zip --boring" }, "keywords": [], "author": "", diff --git a/test/test_cdn.mjs b/test/test_cdn.mjs new file mode 100644 index 0000000..c9f887d --- /dev/null +++ b/test/test_cdn.mjs @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +const configSource = await readFile('extension/shared/cdn-config.js', 'utf8'); +const backgroundSource = await readFile('extension/shared/cdn-background.js', 'utf8'); +const configContext = vm.createContext({ URL }); +vm.runInContext(configSource, configContext); +const config = configContext.BskyPlusCdnConfig; +const hostname = 'luna.danielmorrisey.com'; +const suffix = 'did:plc:lmkzmvv6sdxntwtyxpg7fqqq/bafkreihdpj7vljawsjsy3zqix7mu4hvhpjt7iauxecwyjvmp65nrmd5alq@jpeg?size=1000&x=a%2Fb'; + +test('CDN hostname validation supports a host or HTTPS origin', () => { + assert.equal(config.normalizeHostname(` ${hostname.toUpperCase()} `), hostname); + assert.equal(config.normalizeHostname(`https://${hostname}/`), hostname); + assert.equal(config.normalizeHostname(''), ''); + assert.equal(config.normalizeHostname('https://cdn.bsky.app/'), ''); + for (const input of ['http://example.com', 'example.com/path', 'example.com:443', 'a@b.com', 'a.com?x=1', 'a.com#x', 'a..com', '-a.com', 'a_.com', 'a.com\\other', 'a b.com']) { + assert.throws(() => config.normalizeHostname(input), undefined, input); + } +}); + +test('only the hostname changes for thumbnails and full-size images', () => { + for (const kind of ['feed_thumbnail', 'feed_fullsize', 'avatar_thumbnail']) { + const url = `https://cdn.bsky.app/img/${kind}/plain/${suffix}`; + assert.equal(config.redirectUrl(url, hostname), `https://${hostname}/img/${kind}/plain/${suffix}`); + assert.equal(config.redirectUrl(url, ''), undefined); + } + for (const url of [ + `https://cdn.bsky.app/img/avatar/plain/${suffix}`, + `https://cdn.bsky.app/img/feed_thumbnail_other/plain/${suffix}`, + `https://cdn.bsky.app/img/feed_fullsize/other/${suffix}`, + `https://other.example/img/feed_fullsize/plain/${suffix}`, + `http://cdn.bsky.app/img/feed_fullsize/plain/${suffix}`, + ]) assert.equal(config.redirectUrl(url, hostname), undefined); +}); + +function event() { + const listeners = []; + return { addListener: callback => listeners.push(callback), emit: (...args) => listeners.forEach(callback => callback(...args)), listeners }; +} + +function background(useRules = true) { + let stored = { imageCdnHostname: hostname }; + let granted = true; + const rules = new Map([[99, { id: 99 }]]); + const api = { + runtime: { + id: 'test-extension', + getURL: path => `moz-extension://test-extension/${path}`, + getManifest: () => ({ permissions: useRules ? ['declarativeNetRequestWithHostAccess'] : ['webRequest', 'webRequestBlocking'] }), + onMessage: event(), + }, + storage: { + local: { get: async () => ({ ...stored }) }, + onChanged: event(), + }, + permissions: { contains: async () => granted, onAdded: event(), onRemoved: event() }, + declarativeNetRequest: { updateDynamicRules: async ({ removeRuleIds, addRules }) => { + removeRuleIds.forEach(id => rules.delete(id)); + addRules.forEach(rule => rules.set(rule.id, rule)); + } }, + webRequest: { onBeforeRequest: event() }, + }; + const context = vm.createContext({ URL, browser: api }); + vm.runInContext(configSource, context); + vm.runInContext(backgroundSource, context); + function apply(sender = { id: api.runtime.id, url: api.runtime.getURL('shared/settings-frame.html') }) { + return new Promise(resolve => api.runtime.onMessage.emit({ action: 'APPLY_IMAGE_CDN' }, sender, resolve)); + } + return { + api, rules, apply, + setHost(value) { stored = { imageCdnHostname: value }; api.storage.onChanged.emit({ imageCdnHostname: { newValue: value } }, 'local'); }, + setPermission(value) { granted = value; api.permissions[value ? 'onAdded' : 'onRemoved'].emit({}); }, + }; +} + +test('Chrome rules persist configuration and react to change, reset, and permissions', async () => { + const instance = background(); + assert.equal((await instance.apply()).enabled, true); + const rule = instance.rules.get(config.RULE_ID); + assert.equal(rule.action.redirect.transform.host, hostname); + for (const kind of ['feed_thumbnail', 'feed_fullsize', 'avatar_thumbnail']) { + assert.match(`https://cdn.bsky.app/img/${kind}/plain/${suffix}`, new RegExp(rule.condition.regexFilter)); + } + instance.setHost('images.example.com'); + await instance.apply(); + assert.equal(instance.rules.get(config.RULE_ID).action.redirect.transform.host, 'images.example.com'); + instance.setPermission(false); + assert.equal((await instance.apply()).enabled, false); + assert.equal(instance.rules.has(config.RULE_ID), false); + instance.setPermission(true); + assert.equal((await instance.apply()).enabled, true); + instance.setHost(''); + assert.equal((await instance.apply()).enabled, false); + assert.equal(instance.rules.has(config.RULE_ID), false); + assert.equal(instance.rules.has(99), true, 'Unrelated rules remain untouched'); +}); + +test('invalid saved values disable redirects and webpage messages cannot apply settings', async () => { + const instance = background(); + await instance.apply(); + instance.setHost('bad/path'); + await instance.apply(); + assert.equal(instance.rules.has(config.RULE_ID), false); + assert.equal((await instance.apply({ id: 'test-extension', url: 'https://bsky.app/bskyplus' })).success, false); +}); + +test('Firefox redirects preserve URL bytes and stop on reset or permission revocation', async () => { + const instance = background(false); + const listener = instance.api.webRequest.onBeforeRequest.listeners[0]; + const url = `https://cdn.bsky.app/img/feed_fullsize/plain/${suffix}`; + assert.equal((await listener({ url })).redirectUrl, config.redirectUrl(url, hostname)); + instance.setPermission(false); + assert.equal((await listener({ url })).redirectUrl, undefined); + instance.setPermission(true); + instance.setHost('images.example.com'); + assert.equal((await listener({ url })).redirectUrl, config.redirectUrl(url, 'images.example.com')); + instance.setHost(''); + assert.equal((await listener({ url })).redirectUrl, undefined); +}); diff --git a/test/test_cdn_settings.mjs b/test/test_cdn_settings.mjs new file mode 100644 index 0000000..805b646 --- /dev/null +++ b/test/test_cdn_settings.mjs @@ -0,0 +1,179 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import puppeteer from 'puppeteer-core'; + +const chromePath = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const configScript = await readFile('extension/shared/cdn-config.js', 'utf8'); +const settingsScript = await readFile('extension/shared/cdn-settings.js', 'utf8'); +const HOSTNAME = 'luna.danielmorrisey.com'; + +async function openSettings(browser, { stored = '', permissions = [], applyHostname = HOSTNAME, storageSet = 'resolve' } = {}) { + const page = await browser.newPage(); + await page.setContent('
'); + await page.evaluate(({ initial, permissionResults, appliedHostname, setResult }) => { + const calls = { get: 0, set: [], request: [], apply: [] }; + window.__cdnTestCalls = calls; + const outcomes = permissionResults.slice(); + window.chrome = { + runtime: { + sendMessage: async message => { + calls.apply.push(structuredClone(message)); + return { success: true, enabled: !!appliedHostname, hostname: appliedHostname }; + }, + }, + storage: { + local: { + get: async () => { + calls.get += 1; + return { imageCdnHostname: initial }; + }, + set: async value => { + calls.set.push(structuredClone(value)); + if (setResult === 'reject') throw new Error('storage unavailable'); + }, + }, + }, + permissions: { + request: async request => { + calls.request.push({ + request: structuredClone(request), + persisted: calls.set.at(-1) ? structuredClone(calls.set.at(-1)) : null, + }); + const outcome = outcomes.shift() || 'grant'; + if (outcome === 'reject') throw new Error('permission request failed'); + return outcome !== 'deny'; + }, + }, + }; + }, { initial: stored, permissionResults: permissions, appliedHostname: applyHostname, setResult: storageSet }); + await page.addScriptTag({ content: configScript }); + await page.addScriptTag({ content: settingsScript }); + await page.waitForSelector('.bskyplus-cdn-section'); + await page.waitForFunction(() => !document.querySelector('.bskyplus-cdn-save').disabled); + return page; +} + +async function clickSave(page, value) { + await page.$eval('#bskyplusCdnHostname', (input, next) => { input.value = next; }, value); + await page.click('.bskyplus-cdn-save'); +} + +async function getCalls(page) { + return page.evaluate(() => structuredClone(window.__cdnTestCalls)); +} + +test('CDN config validates and normalizes hostnames', async () => { + const browser = await puppeteer.launch({ executablePath: chromePath, headless: true }); + try { + const values = await browser.newPage().then(page => page.evaluate(({ config, hostname }) => { + const script = document.createElement('script'); + script.textContent = config; + document.head.append(script); + const api = globalThis.BskyPlusCdnConfig; + const invalid = value => { try { api.normalizeHostname(value); return false; } catch { return true; } }; + return { + blank: api.normalizeHostname(''), + source: api.normalizeHostname('cdn.bsky.app'), + normalized: api.normalizeHostname(' Luna.DanielMorrisey.com. '), + origins: api.permissionOrigins(hostname), + invalid: [invalid('https://example.test'), invalid('bad host')], + }; + }, { config: configScript, hostname: HOSTNAME })); + assert.deepEqual(values, { + blank: '', source: '', normalized: HOSTNAME, + origins: ['https://cdn.bsky.app/*', `https://${HOSTNAME}/*`], invalid: [false, true], + }); + } finally { + await browser.close(); + } +}); + +test('Save stores the normalized hostname before requesting access', async () => { + const browser = await puppeteer.launch({ executablePath: chromePath, headless: true }); + try { + const page = await openSettings(browser); + await clickSave(page, ` ${HOSTNAME.toUpperCase()}. `); + await page.waitForFunction(() => document.querySelector('.bskyplus-cdn-status').textContent.includes('active')); + const recorded = await getCalls(page); + assert.deepEqual(recorded.set, [{ imageCdnHostname: HOSTNAME }]); + assert.deepEqual(recorded.request[0], { + request: { origins: ['https://cdn.bsky.app/*', `https://${HOSTNAME}/*`] }, + persisted: { imageCdnHostname: HOSTNAME }, + }); + assert.deepEqual(recorded.apply, [{ action: 'APPLY_IMAGE_CDN' }]); + assert.equal(await page.$eval('#bskyplusCdnHostname', input => input.value), HOSTNAME); + } finally { + await browser.close(); + } +}); + +test('Denied or rejected permission can be retried after the value is saved', async () => { + for (const first of ['deny', 'reject']) { + const browser = await puppeteer.launch({ executablePath: chromePath, headless: true }); + try { + const page = await openSettings(browser, { permissions: [first, 'grant'] }); + await clickSave(page, HOSTNAME); + await page.waitForFunction(() => !document.querySelector('.bskyplus-cdn-grant').hidden); + assert.deepEqual((await getCalls(page)).set, [{ imageCdnHostname: HOSTNAME }]); + await page.click('.bskyplus-cdn-grant'); + await page.waitForFunction(() => document.querySelector('.bskyplus-cdn-status').textContent.includes('active')); + assert.equal((await getCalls(page)).request.length, 2); + } finally { + await browser.close(); + } + } +}); + +test('Storage rejection requests no permission and exposes no retry', async () => { + const browser = await puppeteer.launch({ executablePath: chromePath, headless: true }); + try { + const page = await openSettings(browser, { storageSet: 'reject' }); + await clickSave(page, HOSTNAME); + await page.waitForFunction(() => document.querySelector('.bskyplus-cdn-status').textContent.includes('storage unavailable')); + const recorded = await getCalls(page); + assert.equal(recorded.request.length, 0); + assert.equal(await page.$eval('.bskyplus-cdn-grant', button => button.hidden), true); + } finally { + await browser.close(); + } +}); + +test('Use Bluesky CDN clears the value without requesting permissions', async () => { + const browser = await puppeteer.launch({ executablePath: chromePath, headless: true }); + try { + const page = await openSettings(browser, { stored: HOSTNAME, applyHostname: '' }); + await page.click('.bskyplus-cdn-reset'); + await page.waitForFunction(() => document.querySelector('.bskyplus-cdn-status').textContent.includes('disabled')); + const recorded = await getCalls(page); + assert.deepEqual(recorded.set, [{ imageCdnHostname: '' }]); + assert.equal(recorded.request.length, 0); + assert.deepEqual(recorded.apply, [{ action: 'APPLY_IMAGE_CDN' }]); + assert.equal(await page.$eval('#bskyplusCdnHostname', input => input.value), ''); + } finally { + await browser.close(); + } +}); + +test('Mismatched backend hostname is reported as an error', async () => { + const browser = await puppeteer.launch({ executablePath: chromePath, headless: true }); + try { + const page = await openSettings(browser, { applyHostname: 'other.example.test' }); + await clickSave(page, HOSTNAME); + await page.waitForFunction(() => document.querySelector('.bskyplus-cdn-status').classList.contains('bskyplus-llm-status-error')); + assert.equal(await page.$eval('.bskyplus-cdn-grant', button => button.hidden), false); + } finally { + await browser.close(); + } +}); + +test('Repeated mount does not duplicate the CDN section', async () => { + const browser = await puppeteer.launch({ executablePath: chromePath, headless: true }); + try { + const page = await openSettings(browser); + await page.addScriptTag({ content: settingsScript }); + assert.equal(await page.$$eval('.bskyplus-cdn-section', sections => sections.length), 1); + } finally { + await browser.close(); + } +}); diff --git a/test/test_extension_settings.mjs b/test/test_extension_settings.mjs index 3332ef1..38ac83e 100644 --- a/test/test_extension_settings.mjs +++ b/test/test_extension_settings.mjs @@ -27,7 +27,7 @@ function readExtensionStorage(frame) { } try { - await browser.waitForTarget(target => target.type() === 'service_worker'); + const workerTarget = await browser.waitForTarget(target => target.type() === 'service_worker'); const page = await browser.newPage(); await page.setViewport({ width: 1600, height: 1000 }); await page.setRequestInterception(true); @@ -70,6 +70,7 @@ try { await page.bringToFront(); const frame = await waitForSettingsFrame(page); + await frame.waitForSelector('.bskyplus-cdn-save'); await frame.waitForSelector('#feedWidthSlider'); await frame.waitForFunction(() => document.querySelector('#feedWidthSlider').value === '600'); @@ -117,7 +118,7 @@ try { await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await frame.click('.bskyplus-llm-save'); - await frame.waitForFunction(() => document.querySelector('.bskyplus-llm-status').textContent === 'LLM endpoint saved.'); + await frame.waitForFunction(() => document.querySelector('.bskyplus-llm-section .bskyplus-llm-status').textContent === 'LLM endpoint saved.'); const stored = await readExtensionStorage(frame); assert.equal(stored.skeetMode, true); @@ -161,7 +162,15 @@ try { model: 'vision-test', apiKey: 'test-key-do-not-send', }); - console.log('PASS: unpacked extension route redirect, manifest injection, refresh, and settings navigation'); + const popup = await browser.newPage(); + await popup.goto(new URL('popup/popup.html', workerTarget.url()).href); + await popup.waitForSelector('#bskyplusCdnHostname'); + await popup.waitForFunction(() => !document.querySelector('.bskyplus-cdn-reset').disabled); + await popup.click('.bskyplus-cdn-reset'); + await popup.waitForFunction(() => document.querySelector('.bskyplus-cdn-status').textContent === 'Image CDN disabled.'); + assert.equal(await popup.$eval('#bskyplusCdnHostname', input => input.value), ''); + assert.ok(await popup.$eval('body', body => body.scrollWidth <= 320), 'Popup CDN controls must fit the popup width'); + console.log('PASS: unpacked extension settings navigation, persistence, and popup CDN controls'); } finally { await browser.close(); } diff --git a/test/test_integrated_settings.mjs b/test/test_integrated_settings.mjs index fd46d40..2aeabf6 100644 --- a/test/test_integrated_settings.mjs +++ b/test/test_integrated_settings.mjs @@ -12,6 +12,24 @@ const routeRules = JSON.parse(await readFile('extension/shared/integrated-settin const firefoxRoute = await readFile('extension/firefox/integrated-settings-route.js', 'utf8'); function documentFor(pathname, search = '') { + if (pathname === '/settings' && new URLSearchParams(search).get('fixture') === 'compact') { + return ` +
Settings
+
+ About +
+
+ + + `; + } if (pathname === '/settings' && new URLSearchParams(search).get('bskyplus') === '1') { return `
@@ -103,6 +121,7 @@ test('settings link uses the about entry and client-side navigation mounts Bsky+ await fixture.page.goBack(); await fixture.page.waitForFunction(() => location.pathname === '/settings'); + await fixture.page.waitForSelector('section[data-bskyplus-settings-page]', { hidden: true }); assert.equal(await fixture.page.$('section[data-bskyplus-settings-page]'), null); await fixture.page.goForward(); await fixture.page.waitForFunction(() => location.pathname === '/bskyplus'); @@ -170,6 +189,29 @@ test('Chrome route rule redirects only the exact Bsky+ main-frame path', () => { assert.equal(rule.action.redirect.url, 'https://bsky.app/settings?bskyplus=1'); }); +test('integrated settings follow the native center through compact sidebar breakpoints', async () => { + const fixture = await openFixture(); + try { + await fixture.page.setViewport({ width: 1200, height: 900 }); + await fixture.page.goto(`${fixture.origin}/settings?fixture=compact`); + await fixture.page.waitForSelector('[data-bskyplus-settings-link]'); + const nativeLeft = await fixture.page.$eval('#nativeCenter', el => el.getBoundingClientRect().left); + await fixture.page.click('[data-bskyplus-settings-link]'); + const settingsLeft = await fixture.page.$eval('[data-bskyplus-settings-page]', el => el.getBoundingClientRect().left); + assert.equal(settingsLeft, nativeLeft, 'Nested ScrollView wrapper must not lose the native center offset'); + for (const width of [1400, 1299, 600, 1200]) { + await fixture.page.setViewport({ width, height: 900 }); + await fixture.page.waitForFunction(() => { + const page = document.querySelector('[data-bskyplus-settings-page]'); + const offset = innerWidth < 1300 && innerWidth >= 800 ? -105 : 0; + return Math.abs(page.getBoundingClientRect().left - ((document.documentElement.clientWidth - Math.min(600, document.documentElement.clientWidth)) / 2 + offset)) < 1; + }); + } + } finally { + await closeFixture(fixture); + } +}); + test('Firefox redirects only the custom route', () => { let listener; vm.runInNewContext(firefoxRoute, { -- knotmirror 0.0.1