diff --git a/background/service-worker.js b/background/service-worker.js index a92114f..a991cf2 100644 --- a/background/service-worker.js +++ b/background/service-worker.js @@ -2,8 +2,7 @@ import { notifyMessage } from '../modules/messaging.js'; import { t, initializeLanguage } from '../modules/i18n.js'; import { migrateEnabledProvidersOnUpdate } from '../modules/provider-defaults.js'; import { - filterProvidersWithGrantedAccess, - syncOptionalProviderAccess + reconcileOptionalProviderAccess } from '../modules/optional-provider-access.js'; // Install event - setup context menus @@ -114,24 +113,6 @@ async function migrateProviderSettingsForUpdate(details) { } } -async function reconcileOptionalProviderAccess() { - try { - const settings = await chrome.storage.sync.get({ enabledProviders: [] }); - const enabledProviders = Array.isArray(settings.enabledProviders) - ? settings.enabledProviders - : []; - const providersWithAccess = await filterProvidersWithGrantedAccess(enabledProviders); - - if (providersWithAccess.length !== enabledProviders.length) { - await chrome.storage.sync.set({ enabledProviders: providersWithAccess }); - } - - await syncOptionalProviderAccess(providersWithAccess); - } catch (error) { - console.error('[Background] Failed to synchronize optional provider access:', error); - } -} - let optionalProviderReconcileQueue = Promise.resolve(); function scheduleOptionalProviderReconcile() { diff --git a/modules/optional-provider-access.js b/modules/optional-provider-access.js index d847c37..3c58e0a 100644 --- a/modules/optional-provider-access.js +++ b/modules/optional-provider-access.js @@ -107,6 +107,23 @@ export async function syncOptionalProviderAccess(enabledProviderIds) { await syncFrameRules(configs.map(([, config]) => config), desiredConfigs); } +/** + * Reconcile device-local scripts and frame rules with the synced provider preference. + * Missing device permissions must never overwrite the user's synced preference. + */ +export async function reconcileOptionalProviderAccess() { + try { + const settings = await chrome.storage.sync.get({ enabledProviders: [] }); + const enabledProviderIds = Array.isArray(settings.enabledProviders) + ? settings.enabledProviders + : []; + + await syncOptionalProviderAccess(enabledProviderIds); + } catch (error) { + console.error('[Background] Failed to synchronize optional provider access:', error); + } +} + async function syncContentScripts(allConfigs, desiredConfigs) { if (!chrome.scripting?.getRegisteredContentScripts) { return; diff --git a/modules/providers.js b/modules/providers.js index ed5e4fd..8a1b624 100644 --- a/modules/providers.js +++ b/modules/providers.js @@ -1,5 +1,8 @@ import { DEFAULT_PROVIDER_IDS } from './provider-defaults.js'; -import { OPTIONAL_PROVIDER_CONFIGS } from './optional-provider-access.js'; +import { + OPTIONAL_PROVIDER_CONFIGS, + filterProvidersWithGrantedAccess +} from './optional-provider-access.js'; export const PROVIDERS = [ { @@ -137,8 +140,10 @@ export async function getEnabledProviders() { console.warn('Failed to load provider settings, using defaults'); } - // Filter enabled providers - let enabledProviders = PROVIDERS.filter(p => settings.enabledProviders.includes(p.id)); + // Synced preferences may include optional providers that are not authorized on + // this device. Keep the preference intact, but do not load inaccessible panels. + const availableProviderIds = await filterProvidersWithGrantedAccess(settings.enabledProviders); + let enabledProviders = PROVIDERS.filter(p => availableProviderIds.includes(p.id)); // Sort by custom order if available if (settings.providerOrder && Array.isArray(settings.providerOrder)) { diff --git a/multi-panel/multi-panel.js b/multi-panel/multi-panel.js index 08b6855..af96406 100644 --- a/multi-panel/multi-panel.js +++ b/multi-panel/multi-panel.js @@ -1139,16 +1139,20 @@ async function initializePanels() { enabledProviders: DEFAULT_PROVIDERS, multiPanelProviders: DEFAULT_PROVIDERS }); + const availableProviderIds = new Set( + (await getEnabledProviders()).map(({ id }) => id) + ); // Use providerOrder if available (from settings page), fallback to multiPanelProviders let providerIds; if (settings.providerOrder && Array.isArray(settings.providerOrder) && settings.providerOrder.length > 0) { // Use providerOrder directly since it now reflects enabled providers in correct order - // Filter to ensure all providers in providerOrder are actually enabled - providerIds = settings.providerOrder.filter(id => settings.enabledProviders.includes(id)); + // Filter to providers that are enabled and authorized on this device. + providerIds = settings.providerOrder.filter(id => availableProviderIds.has(id)); } else { // Fallback: use enabledProviders in their stored order, or multiPanelProviders - providerIds = settings.enabledProviders || settings.multiPanelProviders; + const configuredProviderIds = settings.enabledProviders || settings.multiPanelProviders; + providerIds = configuredProviderIds.filter(id => availableProviderIds.has(id)); } const panelCount = LAYOUT_PANEL_COUNTS[currentLayout] || 4; diff --git a/options/options.js b/options/options.js index 5a1eb62..c1fdc5d 100644 --- a/options/options.js +++ b/options/options.js @@ -2,7 +2,10 @@ import { PROVIDERS, getProviderIcon } from '../modules/providers.js'; import { DEFAULT_PROVIDER_IDS } from '../modules/provider-defaults.js'; import { appendProviderToOrder, normalizeProviderOrder } from '../modules/provider-order.js'; -import { requestOptionalProviderPermission } from '../modules/optional-provider-access.js'; +import { + hasOptionalProviderPermission, + requestOptionalProviderPermission +} from '../modules/optional-provider-access.js'; import { getSettings, getSetting, saveSettings, saveSetting, resetSettings, exportSettings, importSettings } from '../modules/settings.js'; import { DEFAULT_GOOGLE_PROVIDER_MODE, @@ -248,6 +251,7 @@ async function init() { await renderProviderList(); setupEventListeners(); setupStorageChangeListener(); + setupPermissionChangeListeners(); setupShortcutHelpers(); refreshAutoSizedSelects(); } @@ -314,6 +318,11 @@ async function renderProviderList() { const googleProviderMode = getGoogleProviderModeOrDefault(settings); const displayOrder = getProviderDisplayOrder(settings); const listContainer = document.getElementById('provider-list'); + const providerAccessEntries = await Promise.all(PROVIDERS.map(async (provider) => [ + provider.id, + await hasOptionalProviderPermission(provider.id) + ])); + const providerAccess = new Map(providerAccessEntries); const orderIndex = new Map(displayOrder.map((id, index) => [id, index])); const sortedProviders = [...PROVIDERS].sort((a, b) => { @@ -321,13 +330,19 @@ async function renderProviderList() { }); listContainer.innerHTML = sortedProviders.map(provider => { - const isEnabled = enabledProviders.includes(provider.id); + const isEnabledPreference = enabledProviders.includes(provider.id); + const hasAccess = providerAccess.get(provider.id) !== false; + const isEnabled = isEnabledPreference && hasAccess; const googleModeControl = provider.id === 'google' ? renderGoogleModeSelectMarkup(googleProviderMode, isEnabled) : ''; return ` -
+
${isEnabled ? 'drag_indicator' : ''}
@@ -355,7 +370,10 @@ async function renderProviderList() { if (!providerId) return; const provider = PROVIDERS.find(({ id }) => id === providerId); - if (!toggle.classList.contains('active') && provider?.optionalOrigins) { + const hasAccess = providerItem?.dataset.hasAccess !== 'false'; + const isEnabledPreference = providerItem?.dataset.enabledPreference === 'true'; + + if (!hasAccess && provider?.optionalOrigins) { try { const permissionGranted = await requestOptionalProviderPermission(providerId); if (!permissionGranted) { @@ -367,6 +385,14 @@ async function renderProviderList() { showStatus('error', t('msgProviderPermissionRequestFailed', provider.name)); return; } + + // A synced preference can already be enabled while this device still + // needs permission. Granting access should activate it, not toggle it off. + if (isEnabledPreference) { + await renderProviderList(); + showStatus('success', t('msgProviderSettingsUpdated')); + return; + } } await toggleProvider(providerId); @@ -412,6 +438,17 @@ function setupStorageChangeListener() { }); } +function setupPermissionChangeListeners() { + const refreshProviders = () => { + renderProviderList().catch((error) => { + console.error('Error syncing optional provider access:', error); + }); + }; + + chrome.permissions?.onAdded?.addListener(refreshProviders); + chrome.permissions?.onRemoved?.addListener(refreshProviders); +} + // Setup drag-and-drop reordering function setupProviderDragAndDrop(container) { let draggedItem = null; diff --git a/tests/e2e/qwen-permission-sync.e2e.test.js b/tests/e2e/qwen-permission-sync.e2e.test.js new file mode 100644 index 0000000..928f980 --- /dev/null +++ b/tests/e2e/qwen-permission-sync.e2e.test.js @@ -0,0 +1,91 @@ +import { test, expect, chromium } from '@playwright/test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const EXTENSION_PATH = path.resolve(__dirname, '../..'); + +test.describe('Qwen optional permission sync E2E', () => { + test.setTimeout(60000); + + let context; + let page; + let userDataDir; + + test.beforeAll(async () => { + userDataDir = await mkdtemp(path.join(os.tmpdir(), 'panelize-qwen-sync-')); + context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + '--no-sandbox', + '--disable-setuid-sandbox', + ], + }); + + let [serviceWorker] = context.serviceWorkers(); + if (!serviceWorker) { + serviceWorker = await context.waitForEvent('serviceworker'); + } + + const extensionId = new URL(serviceWorker.url()).host; + page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/options/options.html`); + await page.waitForSelector('[data-provider-id="qwen-cn"]'); + }); + + test.afterAll(async () => { + await context?.close(); + await rm(userDataDir, { recursive: true, force: true }); + }); + + test('preserves the synced preference when this device has no Qwen permission', async ({}, testInfo) => { + await page.evaluate(async () => { + await chrome.storage.sync.set({ + enabledProviders: ['chatgpt', 'qwen-cn'], + providerOrder: ['chatgpt', 'qwen-cn'], + }); + }); + + await page.waitForTimeout(1500); + await page.reload(); + await page.waitForSelector('[data-provider-id="qwen-cn"]'); + await page.screenshot({ + path: testInfo.outputPath('qwen-permission-sync.png'), + fullPage: true, + }); + + const state = await page.evaluate(async () => { + const settings = await chrome.storage.sync.get({ enabledProviders: [] }); + const item = document.querySelector('[data-provider-id="qwen-cn"]'); + const registeredScripts = await chrome.scripting.getRegisteredContentScripts(); + const dynamicRules = await chrome.declarativeNetRequest.getDynamicRules(); + + return { + enabledProviders: settings.enabledProviders, + permissionGranted: await chrome.permissions.contains({ + origins: ['https://www.qianwen.com/*'], + }), + enabledPreference: item?.dataset.enabledPreference, + hasAccess: item?.dataset.hasAccess, + toggleActive: item?.querySelector('.toggle-switch')?.classList.contains('active'), + qwenScriptRegistered: registeredScripts.some(({ id }) => id === 'qwen-cn-scripts'), + qwenRuleRegistered: dynamicRules.some(({ id }) => id === 1001), + }; + }); + + expect(state).toEqual({ + enabledProviders: ['chatgpt', 'qwen-cn'], + permissionGranted: false, + enabledPreference: 'true', + hasAccess: 'false', + toggleActive: false, + qwenScriptRegistered: false, + qwenRuleRegistered: false, + }); + }); +}); diff --git a/tests/optional-provider-access.test.js b/tests/optional-provider-access.test.js index 8e223cf..4e13ae0 100644 --- a/tests/optional-provider-access.test.js +++ b/tests/optional-provider-access.test.js @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { OPTIONAL_PROVIDER_CONFIGS, filterProvidersWithGrantedAccess, + reconcileOptionalProviderAccess, requestOptionalProviderPermission, syncOptionalProviderAccess, } from '../modules/optional-provider-access.js'; @@ -22,6 +23,8 @@ describe('optional provider access', () => { getDynamicRules: vi.fn(() => Promise.resolve([])), updateDynamicRules: vi.fn(() => Promise.resolve()), }; + chrome.storage.sync.get.mockImplementation((defaults) => Promise.resolve(defaults)); + chrome.storage.sync.set.mockResolvedValue(); }); it('uses separate narrow origins and script registrations for both Qwen sites', () => { @@ -90,6 +93,29 @@ describe('optional provider access', () => { ])).resolves.toEqual(['chatgpt', 'qwen-global']); }); + it('never overwrites synced provider preferences when this device lacks permission', async () => { + chrome.storage.sync.get.mockResolvedValue({ + enabledProviders: ['chatgpt', 'qwen-cn'], + }); + chrome.scripting.getRegisteredContentScripts.mockResolvedValue([ + { id: 'qwen-cn-scripts' }, + ]); + chrome.declarativeNetRequest.getDynamicRules.mockResolvedValue([ + { id: 1001 }, + ]); + + await reconcileOptionalProviderAccess(); + + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(chrome.scripting.unregisterContentScripts).toHaveBeenCalledWith({ + ids: ['qwen-cn-scripts'], + }); + expect(chrome.declarativeNetRequest.updateDynamicRules).toHaveBeenCalledWith({ + removeRuleIds: [1001], + addRules: [], + }); + }); + it('registers scripts and frame rules only for enabled providers with access', async () => { chrome.permissions.contains.mockImplementation(({ origins }) => Promise.resolve(origins.includes('https://chat.qwen.ai/*')) diff --git a/tests/providers.test.js b/tests/providers.test.js index 017cfb3..c79992a 100644 --- a/tests/providers.test.js +++ b/tests/providers.test.js @@ -102,6 +102,23 @@ describe('providers module', () => { expect(providers[1].id).toBe('claude'); }); + it('keeps synced optional preferences but excludes providers unavailable on this device', async () => { + chrome.storage.sync.get.mockResolvedValue({ + enabledProviders: ['chatgpt', 'qwen-cn', 'qwen-global'], + providerOrder: ['chatgpt', 'qwen-cn', 'qwen-global'], + }); + chrome.permissions = { + contains: vi.fn(({ origins }) => Promise.resolve( + origins.includes('https://chat.qwen.ai/*') + )), + }; + + const providers = await getEnabledProviders(); + + expect(providers.map(({ id }) => id)).toEqual(['chatgpt', 'qwen-global']); + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + }); + it('should use default settings when not provided', async () => { chrome.storage.sync.get.mockImplementation((defaults) => Promise.resolve(defaults)