Skip to content
Open
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
9 changes: 5 additions & 4 deletions src/background/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ async function executeApi(
console.debug('[background] Skipping a superseded direct ChatGPT Web session request.')
return
}
await generateAnswersWithChatgptWebApi(port, session.question, session, accessToken)
await generateAnswersWithChatgptWebApi(port, session.question, session, accessToken, config)
}
} else if (isUsingClaudeWebModel(session)) {
console.debug('[background] Using Claude Web Model')
Expand Down Expand Up @@ -656,7 +656,7 @@ Browser.runtime.onMessage.addListener(async (message, sender) => {
break
case 'OPEN_CHAT_WINDOW': {
console.log('[background] Processing OPEN_CHAT_WINDOW message')
const config = await getUserConfig()
const config = await getUserConfig({ fresh: true })
const url = Browser.runtime.getURL('IndependentPanel.html')
const tabs = await Browser.tabs.query({ url: url, windowType: 'popup' })
if (!config.alwaysCreateNewConversationWindow && tabs.length > 0) {
Expand All @@ -675,7 +675,7 @@ Browser.runtime.onMessage.addListener(async (message, sender) => {
}
case 'REFRESH_MENU':
console.log('[background] Processing REFRESH_MENU message')
refreshMenu()
await refreshMenu()
break
case 'PIN_TAB': {
console.log('[background] Processing PIN_TAB message:', message.data)
Expand Down Expand Up @@ -1050,7 +1050,8 @@ try {

try {
refreshMenu()
console.log('[background] Menu refreshed successfully.')
.then(() => console.log('[background] Menu refreshed successfully.'))
.catch((error) => console.error('[background] Error refreshing menu:', error))
} catch (error) {
console.error('[background] Error refreshing menu:', error)
}
9 changes: 4 additions & 5 deletions src/background/menus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,11 @@ const onClickMenu = (info, tab) => {
export function refreshMenu() {
if (Browser.contextMenus.onClicked.hasListener(onClickMenu))
Browser.contextMenus.onClicked.removeListener(onClickMenu)
Browser.contextMenus.removeAll().then(async () => {
if ((await getUserConfig()).hideContextMenu) return
return Browser.contextMenus.removeAll().then(async () => {
const config = await getUserConfig({ fresh: true })
if (config.hideContextMenu) return

await getPreferredLanguageKey().then((lang) => {
changeLanguage(lang)
})
await changeLanguage(await getPreferredLanguageKey(config))
Browser.contextMenus.create({
id: menuId,
title: 'ChatGPTBox',
Expand Down
12 changes: 8 additions & 4 deletions src/components/ConversationCard/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ function ConversationCard(props) {
}
foregroundPortsRef.current.add(fakePort)
try {
const bingToken = (await getUserConfig()).bingAccessToken
const bingToken = (await getUserConfig({ fresh: true })).bingAccessToken
if (
disposedRef.current ||
disconnected ||
Expand Down Expand Up @@ -445,9 +445,13 @@ function ConversationCard(props) {
}
const closeChatsEscListener = async (e) => {
if (e.key !== 'Escape') return
const { allowEscToCloseAll } = await getUserConfig()
if (disposedRef.current || !allowEscToCloseAll) return
closeChatsMessageListener({ type: 'CLOSE_CHATS' })
try {
const { allowEscToCloseAll } = await getUserConfig({ fresh: true })
if (disposedRef.current || !allowEscToCloseAll) return
closeChatsMessageListener({ type: 'CLOSE_CHATS' })
} catch (error) {
console.warn('[ConversationCard] Failed to handle Escape:', error)
}
}

if (props.closeable) {
Expand Down
16 changes: 10 additions & 6 deletions src/components/WebJumpBackNotification/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,16 @@ const WebJumpBackNotification = (props) => {
<button
style={buttonStyle}
onClick={async () => {
Browser.runtime.sendMessage({
type: 'ACTIVATE_URL',
data: {
tabId: (await getUserConfig()).notificationJumpBackTabId,
},
})
try {
await Browser.runtime.sendMessage({
type: 'ACTIVATE_URL',
data: {
tabId: (await getUserConfig({ fresh: true })).notificationJumpBackTabId,
},
})
} catch (error) {
console.warn('[WebJumpBackNotification] Failed to return to source tab:', error)
}
}}
>
{t('Go Back')}
Expand Down
174 changes: 144 additions & 30 deletions src/config/index.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defaults } from 'lodash-es'
import { cloneDeep, defaults } from 'lodash-es'
import Browser from 'webextension-polyfill'
import { isMobile } from '../utils/is-mobile.mjs'
import {
Expand All @@ -19,6 +19,7 @@ import {
canonicalizeModelKeyArray,
} from './model-key-migrations.mjs'
import { getNavigatorLanguage, resolvePreferredLanguageKey } from './language-data.mjs'
import { hasConfigStorageChange } from './storage-keys.mjs'

export { getNavigatorLanguage }

Expand Down Expand Up @@ -1065,8 +1066,8 @@ export function isUsingCustomModel(configOrSession) {
return isInApiModeGroup(customApiModelKeys, configOrSession)
}

export async function getPreferredLanguageKey() {
const config = await getUserConfig()
export async function getPreferredLanguageKey(config = null) {
config ??= await getUserConfig()
if (config.preferredLanguage === 'auto') return config.userLanguage
return config.preferredLanguage
}
Expand Down Expand Up @@ -2145,11 +2146,121 @@ function migrateUserConfig(options) {
return { migrated, dirty, storageKeysToRemove }
}

let userConfigCache = null
let userConfigCachePromise = null
let userConfigCacheGeneration = 0
let userConfigCacheSequence = 0
let nextUserConfigReadSequence = 0

function invalidateUserConfigCache() {
userConfigCacheGeneration += 1
userConfigCache = null
userConfigCachePromise = null
}

// Writers that do not go through setUserConfig (import/export cleanup, for
// example) are covered by this listener, because the event reaches every
// extension context. A read that races a write from another context can still
// return the pre-write config until the event arrives; the UI merges that event
// into its own state, and setUserConfig closes the window for in-process writes.
//
// The area-wide event is used rather than storage.local.onChanged because it is
// the one every target exposes, and the area is filtered here instead. The
// listener runs while this module is evaluated, so a missing event would break
// config loading entirely.
Browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local' && hasConfigStorageChange(changes)) invalidateUserConfigCache()
Comment thread
PeterDaveHello marked this conversation as resolved.
})

// userLanguage and the default it feeds are derived from the browser locale rather
// than stored, so a locale change has to invalidate the cache like a write does.
// Without this a context that stays alive across the change, which a service worker
// or an open panel can be for hours, keeps resolving preferredLanguage against the
// language the user no longer has.
if (typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('languagechange', () => invalidateUserConfigCache())
}

/**
* get user config from local storage
* Get user config from local storage.
*
* The cache is per context, so it is invalidated by the storage event rather than
* by the write that caused it. A read therefore returns the previous config for as
* long as another context's completed write has not reached this one yet, which is
* invisible for most callers but not for the ones that start a session the moment
* the user acts: a selection made right after changing a setting in the popup used
* to open with the previous model, because before this cache every read went to
* storage. `fresh` is how such a caller opts out.
*
* @param {{fresh?: boolean}} [options]
* `fresh` reads storage directly instead of the cache, for a read that has to
* observe a write another context has already completed. It still populates the
* cache, so the reads after it are cheap again.
* @returns {Promise<UserConfig>}
*
* Every caller gets its own copy, as before, so one caller mutating the result
* cannot corrupt the cache or another caller's config. cloneDeep rather than
* structuredClone, which only exists from Safari 15.4 while the project declares
* no minimum browser version and ships a Safari build. The config is plain JSON
* data from storage plus the literal defaults, so a value structuredClone could not
* handle cannot reach the cache.
*/
export async function getUserConfig() {
export async function getUserConfig({ fresh = false } = {}) {
// A read updates the cache only if no newer result has completed since it
// started. The generation covers invalidation: a read that began before a change
// cannot publish at all. The sequence covers two reads that began after the same
// change and finish out of order, which is how an older read would otherwise
// replace the config a fresh read had already published.
const readAndCache = async (generation, sequence) => {
const { config, persisted } = await readAndMigrateUserConfig()
if (generation === userConfigCacheGeneration && sequence > userConfigCacheSequence) {
userConfigCacheSequence = sequence
if (persisted) {
userConfigCache = config
} else {
// A failed migration must also retire older cache and pending reads, so
// ordinary callers retry storage instead of falling back to stale settings.
invalidateUserConfigCache()
}
}
return config
}

if (fresh) {
// Deliberately not shared with any in-flight read, so this cannot join one
// that started before the write it has to observe.
const sequence = ++nextUserConfigReadSequence
return cloneDeep(await readAndCache(userConfigCacheGeneration, sequence))
}

if (userConfigCache) return cloneDeep(userConfigCache)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
PeterDaveHello marked this conversation as resolved.
Comment thread
PeterDaveHello marked this conversation as resolved.
if (!userConfigCachePromise) {
const generation = userConfigCacheGeneration
const sequence = ++nextUserConfigReadSequence
userConfigCachePromise = readAndCache(generation, sequence).finally(() => {
if (generation === userConfigCacheGeneration) userConfigCachePromise = null
})
}
return cloneDeep(await userConfigCachePromise)
}

// Every write a read performs is reported, so the caller knows whether the
// result it returns is backed by storage.
const writeAndReport = async (write) => {
try {
await write()
return true
} catch {
return false
}
}

async function readAndMigrateUserConfig() {
// A write that fails leaves storage unchanged, so no storage event would ever
// invalidate a cached result built from it. The caller only caches the config
// when every write below landed, which keeps a failed migration retryable.
let persisted = true

// Also fetch old keys for migration
const options = await Browser.storage.local.get([
...Object.keys(defaultConfig),
Expand All @@ -2163,27 +2274,30 @@ export async function getUserConfig() {
if (options.claudeApiKey !== undefined) {
if (options.anthropicApiKey === undefined) {
options.anthropicApiKey = options.claudeApiKey
try {
await Browser.storage.local.set({ anthropicApiKey: options.claudeApiKey })
await Browser.storage.local.remove('claudeApiKey')
} catch {
// Retry the legacy-key cleanup on the next config read.
}
// A failure here is retried on the next config read.
persisted =
(await writeAndReport(async () => {
await Browser.storage.local.set({ anthropicApiKey: options.claudeApiKey })
await Browser.storage.local.remove('claudeApiKey')
})) && persisted
} else {
await Browser.storage.local.remove('claudeApiKey').catch(() => {})
persisted =
(await writeAndReport(() => Browser.storage.local.remove('claudeApiKey'))) && persisted
}
}
if (options.customClaudeApiUrl !== undefined) {
if (options.customAnthropicApiUrl === undefined) {
options.customAnthropicApiUrl = options.customClaudeApiUrl
try {
await Browser.storage.local.set({ customAnthropicApiUrl: options.customClaudeApiUrl })
await Browser.storage.local.remove('customClaudeApiUrl')
} catch {
// Retry the legacy-key cleanup on the next config read.
}
// A failure here is retried on the next config read.
persisted =
(await writeAndReport(async () => {
await Browser.storage.local.set({ customAnthropicApiUrl: options.customClaudeApiUrl })
await Browser.storage.local.remove('customClaudeApiUrl')
})) && persisted
} else {
await Browser.storage.local.remove('customClaudeApiUrl').catch(() => {})
persisted =
(await writeAndReport(() => Browser.storage.local.remove('customClaudeApiUrl'))) &&
persisted
}
}

Expand Down Expand Up @@ -2262,20 +2376,17 @@ export async function getUserConfig() {
}
let payloadPersisted = true
if (Object.keys(payload).length > 0) {
payloadPersisted = await Browser.storage.local
.set(payload)
.then(() => true)
.catch(() => false)
payloadPersisted = await writeAndReport(() => Browser.storage.local.set(payload))
}
if (payloadPersisted && storageKeysToRemove.length > 0) {
try {
await Browser.storage.local.remove(storageKeysToRemove)
} catch {
// Invalid live-default sentinels remain non-authoritative and are retried on the next read.
}
// Invalid live-default sentinels remain non-authoritative and are retried on the next read.
payloadPersisted = await writeAndReport(() =>
Browser.storage.local.remove(storageKeysToRemove),
)
}
persisted = payloadPersisted && persisted
}
return defaults(migrated, defaultConfig)
return { config: defaults(migrated, defaultConfig), persisted }
}

/**
Expand All @@ -2284,6 +2395,9 @@ export async function getUserConfig() {
*/
export async function setUserConfig(value) {
await Browser.storage.local.set(value)
// The storage event also invalidates the cache, but it can arrive after this
// promise resolves, so invalidate here to keep the next read consistent.
invalidateUserConfigCache()
}

export async function setAccessToken(accessToken) {
Expand All @@ -2293,7 +2407,7 @@ export async function setAccessToken(accessToken) {
const TOKEN_DURATION = 30 * 24 * 3600 * 1000

export async function clearOldAccessToken() {
const duration = Date.now() - (await getUserConfig()).tokenSavedOn
const duration = Date.now() - (await getUserConfig({ fresh: true })).tokenSavedOn
if (duration > TOKEN_DURATION) {
await setAccessToken('')
}
Expand Down
6 changes: 3 additions & 3 deletions src/config/language.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { languageList } from './language-data.mjs'

export { languageList }

function formatLanguageForPrompt(languageKey) {
export function formatLanguageForPrompt(languageKey) {
const { name, native } = languageList[languageKey]
return native && native !== name
? `${name} (${native}; ${languageKey})`
Expand All @@ -20,8 +20,8 @@ export async function getUserLanguageNative() {
return languageList[config.userLanguage].native
}

export async function getPreferredLanguage() {
return formatLanguageForPrompt(await getPreferredLanguageKey())
export async function getPreferredLanguage(config = null) {
return formatLanguageForPrompt(await getPreferredLanguageKey(config))
}

export async function getPreferredLanguageNative() {
Expand Down
19 changes: 19 additions & 0 deletions src/config/storage-keys.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Storage keys that live next to the user config but are not part of it.
*
* Conversation history is rewritten after every finished message, so it must
* not be treated as a config change by anything that reacts to storage events.
*/
export const NON_CONFIG_STORAGE_KEYS = ['sessions']

/**
* Whether a storage event carries a change to the user config.
*
* Only the key matters, not the reported values. Both browsers report the keys
* of the call that changed storage, so a conversation history write only ever
* mentions `sessions`; Firefox additionally reports keys whose value did not
* actually change, which costs a cache miss and can never serve a stale config.
*/
export function hasConfigStorageChange(changes) {
return Object.keys(changes).some((key) => !NON_CONFIG_STORAGE_KEYS.includes(key))
Comment thread
PeterDaveHello marked this conversation as resolved.
}
Loading
Loading