Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 1 addition & 20 deletions background/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
17 changes: 17 additions & 0 deletions modules/optional-provider-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 8 additions & 3 deletions modules/providers.js
Original file line number Diff line number Diff line change
@@ -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 = [
{
Expand Down Expand Up @@ -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)) {
Expand Down
10 changes: 7 additions & 3 deletions multi-panel/multi-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 41 additions & 4 deletions options/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -248,6 +251,7 @@ async function init() {
await renderProviderList();
setupEventListeners();
setupStorageChangeListener();
setupPermissionChangeListeners();
setupShortcutHelpers();
refreshAutoSizedSelects();
}
Expand Down Expand Up @@ -314,20 +318,31 @@ 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) => {
return (orderIndex.get(a.id) ?? 0) - (orderIndex.get(b.id) ?? 0);
});

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 `
<div class="provider-item ${isEnabled ? 'draggable' : ''}" data-provider-id="${provider.id}" draggable="${isEnabled}">
<div class="provider-item ${isEnabled ? 'draggable' : ''}"
data-provider-id="${provider.id}"
data-enabled-preference="${isEnabledPreference}"
data-has-access="${hasAccess}"
draggable="${isEnabled}">
<div class="provider-info">
${isEnabled ? '<span class="drag-handle material-symbols-outlined">drag_indicator</span>' : ''}
<div class="provider-icon">
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions tests/e2e/qwen-permission-sync.e2e.test.js
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
26 changes: 26 additions & 0 deletions tests/optional-provider-access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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/*'))
Expand Down
17 changes: 17 additions & 0 deletions tests/providers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading