diff --git a/CLAUDE.md b/CLAUDE.md index 9376b11c..386e5e4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,12 @@ This file holds the project facts that are easy to get wrong. - `plugins//plugin.js` runs in **a fresh QuickJS runtime per probe**: module-level variables do NOT survive between refreshes (vitest reuses one instance, so tests hide this). Persist anything cross-probe under `ctx.app.pluginDataDir` (see claude `live-plan.json`). +- A new install starts with every provider off and probes them all once (`use-provider-detection.ts`): a card + turns on unless its error is one `isNotSetUpError` knows. So a plugin with nothing set up must throw such an + error without a request (`plugins/not-set-up.test.js` checks every shipped plugin). Copilot needs a Copilot plan + in its answer (its login is the GitHub CLI's, which any GitHub user has). The store key `providerDetectionPending` + is set before the first settings save and cleared once every provider is decided, so a start cut short detects + again. A plugin whose login is rejected must not throw a "not set up" message (use "Session expired …"). - Every line label a plugin can emit on the overview card must be declared `scope: "overview"` in `plugin.json`, or that account type gets a blank card (the card filters by manifest labels). - Response bodies are logged (first 500 chars) after `redact_body` in diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index afcfb86d..f9335f63 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -280,4 +280,4 @@ The Cloud Code model set is a superset of the LS model set. The LS returns only 4. Try unexpired SQLite/cached access tokens: the plan and project from `loadCodeAssist`, then `retrieveUserQuotaSummary` (then `fetchAvailableModels` for builds without it). 5. Refresh SQLite refresh tokens only after auth failure or when no access token exists. 6. Read `agy` keychain token from service `gemini`, account `antigravity`, then call `loadCodeAssist` and `retrieveUserQuota`. -7. If all strategies fail: error "Start Antigravity or run `agy` and try again." +7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; when every login found (SQLite and agy) was refused: Cloud Code or Google's token refresh refused (400/401/403, and no refresh worked), or an expired one with nothing to refresh it, "Session expired. Run `agy` to log in again."; otherwise (5xx, timeouts, no models, a refresh that failed or worked but Cloud Code then didn't answer), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection. diff --git a/docs/providers/devin.md b/docs/providers/devin.md index c4d78f63..4cc4bf83 100644 --- a/docs/providers/devin.md +++ b/docs/providers/devin.md @@ -120,7 +120,7 @@ Devin currently returns no `weeklyQuotaRemainingPercent` field in the observed p 2. Read Devin app SQLite auth if CLI credentials are missing or different. 3. POST `GetUserStatus` with `ideName: "devin"`. 4. Build weekly quota and extra balance lines. Show daily quota only if Devin does not mark it hidden. -5. If auth fails, show: `Run devin auth login or sign in to Devin and try again.` +5. With no login found, show: `Run devin auth login or sign in to Devin and try again.` When every login found is rejected (401/403), show: ``Session expired. Sign in via Devin app or run `devin auth login`.`` (a new install's detection reads only the first as "nothing set up"). ## WSL diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 8a41215b..1ba7ad80 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -147,6 +147,8 @@ // --- Google OAuth token refresh --- + // The new access token; false when Google refused the refresh token (the login needs a new sign-in); + // null when it couldn't be checked (network, 5xx, 429, a malformed answer). function refreshAccessToken(ctx, refreshTokenValue, cacheFile) { if (!refreshTokenValue) { ctx.host.log.warn("refresh skipped: no refresh token") @@ -167,7 +169,8 @@ }) if (resp.status < 200 || resp.status >= 300) { ctx.host.log.warn("Google OAuth refresh returned status: " + resp.status) - return null + // 400 is invalid_grant: the refresh token was revoked or expired. + return resp.status === 400 || ctx.util.isAuthStatus(resp.status) ? false : null } var body = ctx.util.tryParseJson(resp.bodyText) if (!body || !body.access_token) { @@ -1064,6 +1067,8 @@ } var cached = loadCachedToken(ctx, agy.refreshToken ? [agy.refreshToken] : [], AGY_TOKEN_CACHE) if (cached && tokens.indexOf(cached) === -1) tokens.push(cached) + // An expired login with nothing to refresh it reads as refused: probe then says "Session expired". + if (tokens.length === 0 && !agy.refreshToken) return { _authFailed: true } var sawAuthFailure = false for (var i = 0; i < tokens.length; i++) { @@ -1073,6 +1078,7 @@ } if ((sawAuthFailure || tokens.length === 0) && agy.refreshToken) { var refreshed = refreshAccessToken(ctx, agy.refreshToken, AGY_TOKEN_CACHE) + if (refreshed === false) return { _authFailed: true } if (refreshed) return probeAgyCloudCode(ctx, refreshed) } return sawAuthFailure ? { _authFailed: true } : null @@ -1121,10 +1127,16 @@ // probeCloudCodeToken returns null for transient failures (5xx/timeouts); without this // guard a Cloud Code incident would trigger a Google OAuth refresh every probe cycle // instead of ~once per token lifetime — risking refresh-token throttling or rotation. + var refreshWorked = false + var refreshRefused = false + var refreshUnchecked = false if (!ccResult && (sawAuthFailure || tokens.length === 0)) { for (var k = 0; k < refreshTokens.length; k++) { var refreshed = refreshAccessToken(ctx, refreshTokens[k], DB_TOKEN_CACHE) + if (refreshed === false) refreshRefused = true + else if (!refreshed) refreshUnchecked = true if (!refreshed) continue + refreshWorked = true var refreshedResult = probeCloudCodeToken(ctx, refreshed) if (refreshedResult && !refreshedResult._authFailed) { ccResult = refreshedResult @@ -1134,18 +1146,33 @@ } } + // Refused: Cloud Code turned a refreshed token down, or a token got a 401 or Google refused the refresh + // and no refresh worked, or an expired login has nothing to refresh it. A refused refresh next to one + // that couldn't be checked (a stale second database) isn't a refusal. + var sqliteRefused = (ccResult && ccResult._authFailed) || + (!refreshWorked && (sawAuthFailure || (refreshRefused && !refreshUnchecked) || (tokens.length === 0 && refreshTokens.length === 0))) + + var agyRefused = false if (!ccResult || ccResult._authFailed) { var agy = loadAgyKeychainToken(ctx) if (agy) { var agyResult = probeAgyKeychain(ctx, agy, nowSec) if (agyResult && !agyResult._authFailed) return agyResult - if (agyResult && agyResult._authFailed) ccResult = agyResult + agyRefused = Boolean(agyResult) } } if (ccResult && !ccResult._authFailed) return ccResult - throw LOGIN_MESSAGE + // LOGIN_MESSAGE says no login was found; a saved one, even expired, is the user's, and a new install + // shows its card (use-provider-detection.ts). + if (dbTokenCandidates.length === 0 && !agy) throw LOGIN_MESSAGE + // "Session expired" asks the user to sign in again (useLoginRecovery), so every login found must be + // refused; one that couldn't be checked (network, 5xx, even after a refresh) makes it "unavailable". + if ((dbTokenCandidates.length === 0 || sqliteRefused) && (!agy || agyRefused)) { + throw "Session expired. Run `agy` to log in again." + } + throw "Antigravity quota data unavailable. Try again later." } globalThis.__openusage_plugin = { id: "antigravity", probe: probe } diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 025b18fe..56fe5b36 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -899,7 +899,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") }) it("Cloud Code tries multiple base URLs", async () => { @@ -1147,7 +1147,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") }) it("tries DB token first, then cached on auth failure", async () => { @@ -1659,7 +1659,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") }) it("handles refresh response missing access_token", async () => { @@ -1682,7 +1682,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") expect(oauthCalls).toBe(1) }) @@ -1885,7 +1885,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") expect(refreshCalls).toBe(0) }) @@ -1911,10 +1911,159 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") expect(refreshCalls).toBe(0) }) + it("says unavailable, not expired, when Cloud Code fails after a refresh that worked", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.old", refreshToken: "1//refresh", expirySeconds: futureExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("oauth2.googleapis.com")) { + return { status: 200, bodyText: JSON.stringify({ access_token: "ya29.new", expires_in: 3600 }) } + } + if (url.includes("fetchAvailableModels") && opts.headers.Authorization === "Bearer ya29.old") { + return { status: 401, bodyText: "" } + } + return { status: 503, bodyText: "" } + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says unavailable, not expired, when the agy login's Cloud Code calls fail", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, null) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockImplementation((service, account) => { + if (service === "gemini" && account === "antigravity") { + return "go-keyring-base64:" + ctx.base64.encode(JSON.stringify({ + tokens: { access_token: "agy-keychain-token" }, + })) + } + throw new Error("unexpected keychain lookup") + }) + ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says the session expired for an expired agy login with nothing to refresh it", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, null) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ + token: { access_token: "ya29.expired", expiry: new Date(Date.now() - 3600000).toISOString() }, + })) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + + it("says the session expired when Google refuses an expired agy login's refresh token", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, null) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ + token: { + access_token: "ya29.expired", + refresh_token: "1//revoked", + expiry: new Date(Date.now() - 3600000).toISOString(), + }, + })) + ctx.host.http.request.mockReturnValue({ status: 400, bodyText: '{"error":"invalid_grant"}' }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") + }) + + it("says the session expired when Google refuses an expired SQLite token's refresh token", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//revoked", expirySeconds: pastExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.http.request.mockReturnValue({ status: 400, bodyText: '{"error":"invalid_grant"}' }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") + }) + + it("says unavailable when the SQLite login is refused but the agy login can't be checked", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//revoked", expirySeconds: pastExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ token: { access_token: "agy-keychain-token" } })) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("oauth2.googleapis.com")) return { status: 400, bodyText: '{"error":"invalid_grant"}' } + return { status: 503, bodyText: "" } + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says unavailable when the agy login is refused but the SQLite login can't be checked", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.valid", refreshToken: "1//refresh", expirySeconds: futureExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ + token: { access_token: "ya29.agy-expired", expiry: new Date(Date.now() - 3600000).toISOString() }, + })) + ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says unavailable when one database's refresh is refused and the other's can't reach Google", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteByPath(ctx, { + [STATE_DB_V2]: makeOAuthSentinelB64(ctx, { accessToken: "ya29.v2", refreshToken: "1//v2-current", expirySeconds: pastExpiry }), + [STATE_DB_V1]: makeOAuthSentinelB64(ctx, { accessToken: "ya29.v1", refreshToken: "1//v1-revoked", expirySeconds: pastExpiry }), + }) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.bodyText).includes(encodeURIComponent("1//v1-revoked"))) { + return { status: 400, bodyText: '{"error":"invalid_grant"}' } + } + return { status: 503, bodyText: "" } + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says unavailable when the refresh of an expired SQLite token can't reach Google", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//refresh", expirySeconds: pastExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says the session expired for an expired SQLite token with nothing to refresh it", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", expirySeconds: pastExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + // --- Regression tests: RetrieveUserQuotaSummary (merged Gemini pool + weekly limits) --- const SESSION_MS = 5 * 60 * 60 * 1000 diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index f6db3e83..96226184 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -720,7 +720,7 @@ accessToken = refreshed } else if (!accessToken) { ctx.host.log.error("refresh failed and no access token available") - throw "Not logged in. " + LOGIN_HINT + throw "Session expired. " + LOGIN_HINT } } diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index fbc795de..2a8ec974 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -1543,7 +1543,8 @@ describe("cursor plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Not logged in") + // A refresh token is a login the user has: not "Not logged in", which means nothing is set up. + expect(() => plugin.probe(ctx)).toThrow("Session expired. Sign in via Cursor app or run `agent login`.") }) it("throws token expired when usage remains unauthorized after refresh retry", async () => { diff --git a/plugins/devin/plugin.js b/plugins/devin/plugin.js index 708b5058..2386a6ef 100644 --- a/plugins/devin/plugin.js +++ b/plugins/devin/plugin.js @@ -325,7 +325,8 @@ if (appAttempt.authFailure) sawAuthFailure = true } - if (sawAuthFailure) throw LOGIN_HINT + // Not LOGIN_HINT: that says nothing is set up, and a new install would leave the card off. + if (sawAuthFailure) throw "Session expired. Sign in via Devin app or run `devin auth login`." if (sawApiKey) throw QUOTA_HINT throw LOGIN_HINT } diff --git a/plugins/devin/plugin.test.js b/plugins/devin/plugin.test.js index 732220a6..3a2a5613 100644 --- a/plugins/devin/plugin.test.js +++ b/plugins/devin/plugin.test.js @@ -337,6 +337,15 @@ describe("devin plugin", () => { ]) }) + it("says the session expired when every login is rejected, not that nothing is set up", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "{}" }) + const plugin = await loadPlugin() + + expect(() => plugin.probe(ctx)).toThrow("Session expired. Sign in via Devin app or run `devin auth login`.") + }) + it("throws the login hint when no auth source is available", async () => { const ctx = makeCtx() const plugin = await loadPlugin() diff --git a/plugins/kimi/plugin.js b/plugins/kimi/plugin.js index 45072e00..591631fa 100644 --- a/plugins/kimi/plugin.js +++ b/plugins/kimi/plugin.js @@ -253,7 +253,10 @@ if (refreshed) { accessToken = refreshed } else if (!accessToken) { - throw "Not logged in. Run `kimi login` to authenticate." + // A refresh token that no longer works is a login the user has (a new install shows its card). + throw creds.refresh_token + ? "Session expired. Run `kimi login` to authenticate." + : "Not logged in. Run `kimi login` to authenticate." } } diff --git a/plugins/kimi/plugin.test.js b/plugins/kimi/plugin.test.js index 0a638092..c28e254f 100644 --- a/plugins/kimi/plugin.test.js +++ b/plugins/kimi/plugin.test.js @@ -306,7 +306,7 @@ describe("kimi plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Usage request failed. Check your connection.") }) - it("throws not logged in when refresh response is missing access token", async () => { + it("says the session expired when the refresh token gives no access token", async () => { const ctx = makeCtx() ctx.host.fs.writeText( CRED_PATH, @@ -324,7 +324,8 @@ describe("kimi plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Not logged in") + // A refresh token is a login the user has: "Not logged in" would mean nothing is set up. + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `kimi login` to authenticate.") }) it("returns badge when no valid quota is available", async () => { diff --git a/plugins/not-set-up.test.js b/plugins/not-set-up.test.js new file mode 100644 index 00000000..af0ed598 --- /dev/null +++ b/plugins/not-set-up.test.js @@ -0,0 +1,29 @@ +import { readdirSync } from "node:fs" +import { expect, it } from "vitest" +import { isNotSetUpError } from "../src/lib/plugin-errors" +import { makeCtx } from "./test-helpers.js" + +// A new install turns on the providers whose first probe finds something (use-provider-detection.ts). +// So with nothing set up, every shipped plugin (copy-bundled.cjs skips mock) must say so in words +// isNotSetUpError knows, without a request: anything else would turn its card on for everyone. +const ids = readdirSync("plugins", { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== "mock") + .map((entry) => entry.name) + +it("every plugin says 'not set up' when nothing of it is on the PC", async () => { + expect(ids.length).toBeGreaterThan(10) + for (const id of ids) { + await import(/* @vite-ignore */ `./${id}/plugin.js`) + const plugin = globalThis.__openusage_plugin + const ctx = makeCtx() + ctx.app.platform = "windows" + let message = "(no error)" + try { + await plugin.probe(ctx) + } catch (error) { + message = String(error?.message ?? error) + } + expect(isNotSetUpError(message), `${id}: ${message}`).toBe(true) + expect(ctx.host.http.request, id).not.toHaveBeenCalled() + } +}) diff --git a/src-tauri/src/local_http_api/cache.rs b/src-tauri/src/local_http_api/cache.rs index 1c9d772a..315e3235 100644 --- a/src-tauri/src/local_http_api/cache.rs +++ b/src-tauri/src/local_http_api/cache.rs @@ -7,7 +7,6 @@ use std::time::Duration; const CACHE_FILE_NAME: &str = "usage-api-cache.json"; const SETTINGS_FILE_NAME: &str = "settings.json"; -const DEFAULT_ENABLED_PLUGINS: &[&str] = &["claude", "codex"]; #[cfg(not(test))] const CACHE_WRITE_DEBOUNCE: Duration = Duration::from_millis(500); @@ -316,15 +315,8 @@ fn read_plugin_settings(app_data_dir: &Path) -> (Vec, HashSet, b pub(super) fn enabled_snapshots_ordered(state: &CacheState) -> Vec { let (settings_order, disabled, has_settings) = read_plugin_settings(&state.app_data_dir); - let default_enabled: HashSet<&str> = DEFAULT_ENABLED_PLUGINS.iter().copied().collect(); - - let is_enabled = |id: &str| -> bool { - if has_settings { - !disabled.contains(id) - } else { - default_enabled.contains(id) - } - }; + // Until the app first saves its plugin settings nothing is on: a new install turns on what it detects. + let is_enabled = |id: &str| has_settings && !disabled.contains(id); // Build ordered plugin ids: settings order first, then remaining known ids. let mut ordered: Vec = Vec::new(); diff --git a/src/App.test.tsx b/src/App.test.tsx index 35921704..cb210293 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -231,6 +231,8 @@ vi.mock("@/lib/settings", async () => { ...actual, loadPluginSettings: state.loadPluginSettingsMock, savePluginSettings: state.savePluginSettingsMock, + loadProviderDetectionPending: async () => false, + saveProviderDetectionPending: async () => {}, loadAutoUpdateInterval: state.loadAutoUpdateIntervalMock, saveAutoUpdateInterval: state.saveAutoUpdateIntervalMock, loadThemeMode: state.loadThemeModeMock, @@ -474,6 +476,31 @@ describe("App", () => { await screen.findByText("Now") }) + it("on a new install shows only the providers this PC has", async () => { + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: [], disabled: [] }) + render() + await waitFor(() => expect(state.startBatchMock).toHaveBeenCalledWith(["a", "b"])) + expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["a", "b"] }) + + state.probeHandlers?.onResult({ + providerId: "a", + displayName: "Alpha", + iconUrl: "icon-a", + lines: [{ type: "text", label: "Now", value: "Later" }], + }) + state.probeHandlers?.onResult({ + providerId: "b", + displayName: "Beta", + iconUrl: "icon-b", + lines: [{ type: "badge", label: "Error", text: "Not logged in. Run `beta` to authenticate." }], + }) + + await waitFor(() => expect(state.savePluginSettingsMock).toHaveBeenLastCalledWith({ order: ["a", "b"], disabled: ["b"] })) + expect(state.savePluginSettingsMock).toHaveBeenCalledTimes(2) + await screen.findByText("Now") + expect(screen.queryByText("Beta")).toBeNull() + }) + it("updates tray icon on probe results when plugin has a primary progress", async () => { state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { @@ -949,8 +976,7 @@ describe("App", () => { }) it("toggles plugins in settings", async () => { - // Use already-normalised settings so no init save fires (b is disabled - // because "b" is not in DEFAULT_ENABLED_PLUGINS = ["claude","codex"]) + // Use already-normalised settings so no init save fires state.loadPluginSettingsMock.mockResolvedValue({ order: ["a", "b"], disabled: ["b"] }) render() const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) diff --git a/src/App.tsx b/src/App.tsx index 8a1c0b11..c1a2a843 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { AppShell } from "@/components/app/app-shell" import { useAppPluginViews } from "@/hooks/app/use-app-plugin-views" import { useProbe } from "@/hooks/app/use-probe" import { useSettingsBootstrap } from "@/hooks/app/use-settings-bootstrap" +import { useProviderDetection } from "@/hooks/app/use-provider-detection" import { useSettingsDisplayActions } from "@/hooks/app/use-settings-display-actions" import { useSettingsPluginActions } from "@/hooks/app/use-settings-plugin-actions" import { useSettingsPluginList } from "@/hooks/app/use-settings-plugin-list" @@ -217,6 +218,14 @@ function App() { } }, [scheduleTrayIconUpdate]) + const { detectProviders } = useProviderDetection({ + pluginSettings, + pluginStates, + setPluginSettings, + setLoadingForPlugins, + startBatch, + }) + const { applyStartOnLogin } = useSettingsBootstrap({ setPluginSettings, setPluginsMeta, @@ -244,6 +253,7 @@ function App() { setLoadingForPlugins, setErrorForPlugins, startBatch, + detectProviders, }) useSettingsTheme(themeMode) diff --git a/src/hooks/app/use-provider-detection.test.ts b/src/hooks/app/use-provider-detection.test.ts new file mode 100644 index 00000000..92bad3ca --- /dev/null +++ b/src/hooks/app/use-provider-detection.test.ts @@ -0,0 +1,144 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { PluginState } from "@/hooks/app/types" +import type { PluginOutput } from "@/lib/plugin-types" +import type { PluginSettings } from "@/lib/settings" +import { useAppPluginStore } from "@/stores/app-plugin-store" + +const { savePluginSettingsMock, saveDetectionPendingMock } = vi.hoisted(() => ({ + savePluginSettingsMock: vi.fn(), + saveDetectionPendingMock: vi.fn(), +})) + +vi.mock("@/lib/settings", () => ({ + savePluginSettings: savePluginSettingsMock, + saveProviderDetectionPending: saveDetectionPendingMock, +})) + +import { useProviderDetection } from "@/hooks/app/use-provider-detection" + +const IDS = ["claude", "codex", "cursor", "zai"] + +const done = (patch: Partial): PluginState => ({ + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + lastUpdatedAt: null, + ...patch, +}) +const usage = (id: string, plan?: string): PluginOutput => ({ providerId: id, displayName: id, plan, lines: [], iconUrl: "" }) +const notSetUp = "Not logged in. Run `x` to authenticate." + +// As in App: the setter is the store's, and the hook reads the store. +const settings = () => useAppPluginStore.getState().pluginSettings +const setSettings = (value: PluginSettings | null) => useAppPluginStore.getState().setPluginSettings(value) + +function setup(ids = IDS, startBatch = vi.fn().mockResolvedValue(undefined)) { + setSettings({ order: ids, disabled: [...ids] }) + const setPluginSettings = vi.fn(setSettings) + const hook = renderHook( + ({ pluginStates }: { pluginStates: Record }) => + useProviderDetection({ + pluginSettings: useAppPluginStore((state) => state.pluginSettings), + pluginStates, + setPluginSettings, + setLoadingForPlugins: vi.fn(), + startBatch, + }), + { initialProps: { pluginStates: {} as Record } } + ) + return { hook, setPluginSettings, startBatch, detect: () => act(() => hook.result.current.detectProviders(ids)) } +} + +describe("useProviderDetection", () => { + beforeEach(() => { + useAppPluginStore.getState().resetState() + savePluginSettingsMock.mockReset() + savePluginSettingsMock.mockResolvedValue(undefined) + saveDetectionPendingMock.mockReset() + saveDetectionPendingMock.mockResolvedValue(undefined) + }) + + it("turns on what a probe found, and saves once every provider is decided", async () => { + const { hook, startBatch, detect } = setup() + await detect() + expect(startBatch).toHaveBeenCalledWith(IDS) + + const states = { + claude: done({ data: usage("claude") }), + codex: done({ error: notSetUp }), + // An expired login is still the user's: the card shows with its fix. + cursor: done({ error: "Token expired. Sign in via Cursor app or run `agent login`." }), + zai: done({ loading: true }), + } + hook.rerender({ pluginStates: states }) + expect(settings()).toEqual({ order: IDS, disabled: ["codex", "zai"] }) + // Not saved while zai is still probing: a start cut short detects again. + expect(savePluginSettingsMock).not.toHaveBeenCalled() + + hook.rerender({ pluginStates: { ...states, zai: done({ error: "No ZAI_API_KEY found. Set up environment variable first." }) } }) + expect(savePluginSettingsMock).toHaveBeenCalledTimes(1) + expect(savePluginSettingsMock).toHaveBeenCalledWith({ order: IDS, disabled: ["codex", "zai"] }) + // Cleared only after the settings are on disk. + await waitFor(() => expect(saveDetectionPendingMock).toHaveBeenCalledWith(false)) + expect(saveDetectionPendingMock.mock.invocationCallOrder[0]).toBeGreaterThan(savePluginSettingsMock.mock.invocationCallOrder[0]) + }) + + it("saves the all-off settings when nothing is found", async () => { + const { hook, setPluginSettings, detect } = setup(["codex"]) + await detect() + hook.rerender({ pluginStates: { codex: done({ error: notSetUp }) } }) + expect(setPluginSettings).not.toHaveBeenCalled() + expect(savePluginSettingsMock).toHaveBeenCalledWith({ order: ["codex"], disabled: ["codex"] }) + }) + + it("takes Copilot only with a Copilot plan: its login is the GitHub CLI's", async () => { + const { hook, detect } = setup(["copilot"]) + await detect() + hook.rerender({ pluginStates: { copilot: done({ error: "Token invalid. Run `gh auth login` to re-authenticate." }) } }) + expect(settings()?.disabled).toEqual(["copilot"]) + + const found = setup(["copilot"]) + await found.detect() + found.hook.rerender({ pluginStates: { copilot: done({ data: usage("copilot", "Individual Pro") }) } }) + expect(settings()?.disabled).toEqual([]) + }) + + it("decides each provider once, so turning one off later sticks", async () => { + const { hook, detect } = setup(["claude"]) + await detect() + const states = { claude: done({ data: usage("claude") }) } + hook.rerender({ pluginStates: states }) + act(() => setSettings({ order: ["claude"], disabled: ["claude"] })) + hook.rerender({ pluginStates: { ...states } }) + expect(settings()?.disabled).toEqual(["claude"]) + }) + + it("leaves a provider the user turned on and off while it probed to the user", async () => { + const { hook, detect } = setup(["claude"]) + await detect() + act(() => setSettings({ order: ["claude"], disabled: [] })) + act(() => setSettings({ order: ["claude"], disabled: ["claude"] })) + hook.rerender({ pluginStates: { claude: done({ data: usage("claude") }) } }) + expect(settings()?.disabled).toEqual(["claude"]) + }) + + it("keeps a card added while it was detecting", async () => { + const { hook, detect } = setup(["claude"]) + await detect() + // A WSL card arrives (handlePluginsChanged) before the result. + act(() => setSettings({ order: ["claude", "claude-wsl-1a2b3c4d"], disabled: ["claude"] })) + hook.rerender({ pluginStates: { claude: done({ data: usage("claude") }) } }) + expect(settings()).toEqual({ order: ["claude", "claude-wsl-1a2b3c4d"], disabled: [] }) + }) + + it("turns nothing on and saves nothing when the probes can't start", async () => { + const { hook, setPluginSettings, detect } = setup(["claude"], vi.fn().mockRejectedValue(new Error("no host"))) + await detect() + hook.rerender({ pluginStates: { claude: done({ error: "Failed to start probe" }) } }) + expect(setPluginSettings).not.toHaveBeenCalled() + expect(savePluginSettingsMock).not.toHaveBeenCalled() + expect(saveDetectionPendingMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/app/use-provider-detection.ts b/src/hooks/app/use-provider-detection.ts new file mode 100644 index 00000000..78ae3093 --- /dev/null +++ b/src/hooks/app/use-provider-detection.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef } from "react" +import type { PluginState } from "@/hooks/app/types" +import { isNotSetUpError } from "@/lib/plugin-errors" +import { savePluginSettings, saveProviderDetectionPending, type PluginSettings } from "@/lib/settings" +import { useAppPluginStore } from "@/stores/app-plugin-store" + +type UseProviderDetectionArgs = { + pluginSettings: PluginSettings | null + pluginStates: Record + setPluginSettings: (value: PluginSettings | null) => void + setLoadingForPlugins: (ids: string[]) => void + startBatch: (pluginIds?: string[]) => Promise +} + +/** Usage, or any error but "nothing set up": an expired login or a 429 still means the user has it. */ +function isFound(id: string, state: PluginState) { + // Copilot signs in with the GitHub CLI's login, which any GitHub user has: only a Copilot plan proves Copilot. + if (id === "copilot") return Boolean(state.data?.plan) + return Boolean(state.data) || !isNotSetUpError(state.error ?? "") +} + +/** + * A new install starts with every provider off and probes them all once: each plugin's own login + * lookup is the detector, and what it finds is turned on. Once every provider is decided the + * settings are saved and the bootstrap's `providerDetectionPending` flag cleared; a start cut short + * (quit, crash, probes that can't start) leaves it set and detects again. + */ +export function useProviderDetection({ + pluginSettings, + pluginStates, + setPluginSettings, + setLoadingForPlugins, + startBatch, +}: UseProviderDetectionArgs) { + const pending = useRef(new Set()) + + const detectProviders = useCallback( + async (ids: string[]) => { + for (const id of ids) pending.current.add(id) + setLoadingForPlugins(ids) + try { + await startBatch(ids) + } catch (error) { + console.error("Failed to start provider detection:", error) + pending.current.clear() + } + }, + [setLoadingForPlugins, startBatch] + ) + + // Runs on new results and on settings changes. Reads the store, not the render's copy: a WSL card + // added meanwhile (`handlePluginsChanged`) must not be written over. + useEffect(() => { + const current = useAppPluginStore.getState().pluginSettings + if (!current || pending.current.size === 0) return + const found = new Set() + for (const id of [...pending.current]) { + const state = pluginStates[id] + // Turned on by the user meanwhile: theirs to decide. + if (!current.disabled.includes(id)) pending.current.delete(id) + else if (state && !state.loading) { + pending.current.delete(id) + if (isFound(id, state)) found.add(id) + } + } + const next = found.size > 0 ? { ...current, disabled: current.disabled.filter((id) => !found.has(id)) } : current + if (next !== current) setPluginSettings(next) + if (pending.current.size === 0) { + void savePluginSettings(next) + .then(() => saveProviderDetectionPending(false)) + .catch((error) => console.error("Failed to save detected providers:", error)) + } + }, [pluginSettings, pluginStates, setPluginSettings]) + + return { detectProviders } +} diff --git a/src/hooks/app/use-settings-bootstrap.test.ts b/src/hooks/app/use-settings-bootstrap.test.ts index 6843b143..ecf79bb1 100644 --- a/src/hooks/app/use-settings-bootstrap.test.ts +++ b/src/hooks/app/use-settings-bootstrap.test.ts @@ -66,6 +66,8 @@ vi.mock("@tauri-apps/plugin-autostart", () => ({ isEnabled: isAutostartEnabledMock, })) +const detectionMocks = vi.hoisted(() => ({ load: vi.fn(), save: vi.fn() })) + vi.mock("@/lib/settings", () => ({ arePluginSettingsEqual: arePluginSettingsEqualMock, DEFAULT_AUTO_UPDATE_INTERVAL: 15, @@ -102,6 +104,8 @@ vi.mock("@/lib/settings", () => ({ migrateWindsurfToDevin: migrateWindsurfToDevinMock, normalizePluginSettings: normalizePluginSettingsMock, savePluginSettings: savePluginSettingsMock, + loadProviderDetectionPending: detectionMocks.load, + saveProviderDetectionPending: detectionMocks.save, })) import { useSettingsBootstrap } from "@/hooks/app/use-settings-bootstrap" @@ -131,6 +135,7 @@ function createArgs() { setLoadingForPlugins: vi.fn(), setErrorForPlugins: vi.fn(), startBatch: vi.fn().mockResolvedValue(undefined), + detectProviders: vi.fn().mockResolvedValue(undefined), } } @@ -310,5 +315,33 @@ describe("useSettingsBootstrap", () => { expect(args.setPluginSettings).toHaveBeenCalledWith(migratedSettings) expect(args.startBatch).toHaveBeenCalledWith(["devin"]) }) + expect(args.detectProviders).not.toHaveBeenCalled() + }) + + it("on a new install probes every provider to find the ones this PC has", async () => { + const args = createArgs() + const normalized = { order: ["claude", "codex"], disabled: ["claude", "codex"] } + loadPluginSettingsMock.mockResolvedValueOnce({ order: [], disabled: [] }) + normalizePluginSettingsMock.mockReturnValueOnce(normalized) + arePluginSettingsEqualMock.mockReturnValueOnce(false) + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => expect(args.detectProviders).toHaveBeenCalledWith(["claude", "codex"])) + expect(args.setPluginSettings).toHaveBeenCalledWith(normalized) + expect(args.startBatch).not.toHaveBeenCalled() + // The flag first: a settings save alone would make the next start look like an update. + expect(detectionMocks.save).toHaveBeenCalledWith(true) + expect(detectionMocks.save.mock.invocationCallOrder[0]).toBeLessThan(savePluginSettingsMock.mock.invocationCallOrder[0]) + }) + + it("detects again when an earlier detection was cut short", async () => { + const args = createArgs() + detectionMocks.load.mockResolvedValueOnce(true) + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => expect(args.detectProviders).toHaveBeenCalledWith(["codex"])) + expect(args.startBatch).not.toHaveBeenCalled() }) }) diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index e47dab44..4c7667b6 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -39,6 +39,8 @@ import { migrateLegacyTraySettings, migrateWindsurfToDevin, loadPluginSettings, + loadProviderDetectionPending, + saveProviderDetectionPending, loadResetTimerDisplayMode, loadStartOnLogin, loadThemeMode, @@ -89,6 +91,7 @@ type UseSettingsBootstrapArgs = { setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void startBatch: (pluginIds?: string[]) => Promise + detectProviders: (ids: string[]) => Promise } export function useSettingsBootstrap({ @@ -118,6 +121,7 @@ export function useSettingsBootstrap({ setLoadingForPlugins, setErrorForPlugins, startBatch, + detectProviders, }: UseSettingsBootstrapArgs) { const applyStartOnLogin = useCallback(async (value: boolean) => { if (!isTauri()) return @@ -205,6 +209,10 @@ export function useSettingsBootstrap({ const storedSettings = await loadPluginSettings() const migratedSettings = migrateWindsurfToDevin(storedSettings) const normalized = normalizePluginSettings(migratedSettings, availablePlugins) + // A new install, or one whose detection was cut short (use-provider-detection.ts). The flag is saved + // before the settings, so no later save (a WSL card, a toggle) can hide an unfinished run. + const detect = storedSettings.order.length === 0 || (await loadProviderDetectionPending()) + if (detect) await saveProviderDetectionPending(true) if (!arePluginSettingsEqual(storedSettings, normalized)) { await savePluginSettings(normalized) } @@ -301,6 +309,10 @@ export function useSettingsBootstrap({ setTrayMetric(storedTrayMetric) setTrayPercentColor(storedTrayPercentColor) + if (detect) { + await detectProviders(normalized.order) + return + } const enabledIds = getEnabledPluginIds(normalized) setLoadingForPlugins(enabledIds) try { @@ -324,6 +336,7 @@ export function useSettingsBootstrap({ } }, [ applyStartOnLogin, + detectProviders, setAutoUpdateInterval, setDisplayMode, setErrorForPlugins, diff --git a/src/hooks/app/use-settings-plugin-actions.test.ts b/src/hooks/app/use-settings-plugin-actions.test.ts index 13ce39dc..f44bd377 100644 --- a/src/hooks/app/use-settings-plugin-actions.test.ts +++ b/src/hooks/app/use-settings-plugin-actions.test.ts @@ -14,6 +14,7 @@ const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })) vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })) import { useSettingsPluginActions } from "@/hooks/app/use-settings-plugin-actions" +import { useAppPluginStore } from "@/stores/app-plugin-store" describe("useSettingsPluginActions", () => { beforeEach(() => { @@ -335,6 +336,32 @@ describe("useSettingsPluginActions", () => { expect(startBatch).toHaveBeenCalledWith(["claude-wsl-0a1b2c3d"]) }) + it("keeps a provider turned on while the plugin list was being read", async () => { + let listed: (metas: unknown) => void = () => {} + invokeMock.mockReturnValue(new Promise((resolve) => { listed = resolve })) + const setPluginSettings = vi.fn() + const { result } = renderHook(() => + useSettingsPluginActions({ + pluginSettings: { order: ["claude", "codex"], disabled: ["claude", "codex"] }, + setPluginSettings, + setPluginsMeta: vi.fn(), + setLoadingForPlugins: vi.fn(), + setErrorForPlugins: vi.fn(), + startBatch: vi.fn(() => Promise.resolve([])), + scheduleTrayIconUpdate: vi.fn(), + }) + ) + + const change = result.current.handlePluginsChanged({ added: [{ id: "claude-wsl-0a1b2c3d", plugin: "claude" }], removed: [] }) + // A new install's detection turns Claude on meanwhile. + useAppPluginStore.getState().setPluginSettings({ order: ["claude", "codex"], disabled: ["codex"] }) + listed(["claude", "claude-wsl-0a1b2c3d", "codex"].map((id) => ({ id, name: id }))) + await act(() => change) + + expect(setPluginSettings).toHaveBeenCalledWith({ order: ["claude", "claude-wsl-0a1b2c3d", "codex"], disabled: ["codex"] }) + useAppPluginStore.getState().resetState() + }) + it("keeps a change that arrives before the settings load, and applies it once they do", async () => { invokeMock.mockResolvedValue(["claude", "claude-wsl-0a1b2c3d", "codex"].map((id) => ({ id, name: id }))) const setPluginSettings = vi.fn() diff --git a/src/hooks/app/use-settings-plugin-actions.ts b/src/hooks/app/use-settings-plugin-actions.ts index 5460b314..aab30013 100644 --- a/src/hooks/app/use-settings-plugin-actions.ts +++ b/src/hooks/app/use-settings-plugin-actions.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core" import { insertAccount, type AccountsChange, type PluginsChange } from "@/lib/accounts" import type { PluginMeta } from "@/lib/plugin-types" import { normalizePluginSettings, savePluginSettings, type PluginSettings } from "@/lib/settings" +import { useAppPluginStore } from "@/stores/app-plugin-store" const TRAY_SETTINGS_DEBOUNCE_MS = 2000 @@ -110,9 +111,11 @@ export function useSettingsPluginActions({ try { const metas = await invoke("list_plugins") setPluginsMeta(metas) + // Read after the await: a provider a new install's detection turned on meanwhile must stay on. + const current = useAppPluginStore.getState().pluginSettings ?? pluginSettings const removed = new Set(change.removed) - let order = pluginSettings.order.filter((id) => !removed.has(id)) - let disabled = pluginSettings.disabled.filter((id) => !removed.has(id)) + let order = current.order.filter((id) => !removed.has(id)) + let disabled = current.disabled.filter((id) => !removed.has(id)) for (const added of change.added) { order = insertAccount(order, added.id, added.plugin) disabled = disabled.filter((id) => id !== added.id) diff --git a/src/lib/plugin-errors.test.ts b/src/lib/plugin-errors.test.ts index 784943c9..6ff1b3eb 100644 --- a/src/lib/plugin-errors.test.ts +++ b/src/lib/plugin-errors.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs" import { afterEach, describe, expect, it } from "vitest" import { LANGUAGES, useLocaleStore } from "@/lib/i18n" -import { getPluginErrorAction, PLUGIN_ERROR_PATTERNS, translatePluginError } from "@/lib/plugin-errors" +import { getPluginErrorAction, isNotSetUpError, PLUGIN_ERROR_PATTERNS, translatePluginError } from "@/lib/plugin-errors" import enUS, { type PluginErrorKey } from "@/locales/plugin-errors/en-US" import deDE from "@/locales/plugin-errors/de-DE" import es419 from "@/locales/plugin-errors/es-419" @@ -230,6 +230,23 @@ describe("plugin error dictionaries", () => { } }) +describe("isNotSetUpError", () => { + it("is true only when nothing of the provider is on this PC", () => { + expect(isNotSetUpError("Not logged in. Run `claude` to authenticate.")).toBe(true) + expect(isNotSetUpError("No ZAI_API_KEY found. Set up environment variable first.")).toBe(true) + expect(isNotSetUpError("Amp not installed. Install Amp Code to get started.")).toBe(true) + // The user has the provider: an old login, a bad key, a busy or unreachable server. + expect(isNotSetUpError("Token expired. Run `claude` to log in again.")).toBe(false) + expect(isNotSetUpError("Session expired. Run `agy` to log in again.")).toBe(false) + expect(isNotSetUpError("Antigravity quota data unavailable. Try again later.")).toBe(false) + expect(isNotSetUpError("API key invalid. Check your Z.ai API key.")).toBe(false) + expect(isNotSetUpError("Rate limited by Claude, retry in ~5m")).toBe(false) + expect(isNotSetUpError("Request failed. Check your connection.")).toBe(false) + expect(isNotSetUpError("quota exceeded for org")).toBe(false) + expect(isNotSetUpError("")).toBe(false) + }) +}) + describe("getPluginErrorAction", () => { it("offers the login command or the missing API-key variables", () => { expect(getPluginErrorAction("Start Antigravity or run `agy` and try again.")).toEqual({ kind: "run", command: "agy" }) diff --git a/src/lib/plugin-errors.ts b/src/lib/plugin-errors.ts index 80245a9f..3469f7bc 100644 --- a/src/lib/plugin-errors.ts +++ b/src/lib/plugin-errors.ts @@ -133,7 +133,29 @@ export function translatePluginError(message: string): string { return MESSAGES[getLocale()][match.key].replace(/\{(\w+)\}/g, (text, name: string) => match.vars[name] ?? text) } -export type PluginErrorAction = { kind: "run"; command: string } | { kind: "env"; names: string[] } +/** What a provider says when nothing of it is on this PC: no login, no API key, not installed. */ +const NOT_SET_UP: ReadonlySet = new Set([ + "notLoggedInRun", + "notLoggedInRunFirst", + "notLoggedInSignInOrRun", + "startAppOrRun", + "runOrSignIn", + "productNotLoggedInRun", + "openAppSignIn", + "apiKeyMissingSetEnv", + "apiKeyNotFoundSetEnvOrFile", + "envKeyNotFound", + "notDetectedLogIn", + "notInstalled", + "jetbrainsNotDetected", +]) + +export function isNotSetUpError(message: string) { + const key = matchPluginError(message)?.key + return key !== undefined && NOT_SET_UP.has(key) +} + +export type PluginErrorAction ={ kind: "run"; command: string } | { kind: "env"; names: string[] } /** * One-click fix for a known error: a login command to run in a terminal, or API-key environment diff --git a/src/lib/settings-backup.test.ts b/src/lib/settings-backup.test.ts index 3a36a9fa..79f829f0 100644 --- a/src/lib/settings-backup.test.ts +++ b/src/lib/settings-backup.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { buildSettingsBackup, restoreSettingsBackup } from "@/lib/settings-backup" -import { loadAlertSettings, loadThemeMode } from "@/lib/settings" +import { loadAlertSettings, loadProviderDetectionPending, loadThemeMode } from "@/lib/settings" const storeState = new Map() @@ -42,6 +42,14 @@ describe("settings backup", () => { expect(storeState.get("themeMode")).toBe("light") }) + it("ends a pending first-run detection when it restores the card list", async () => { + storeState.set("plugins", { order: ["claude", "codex"], disabled: ["codex"] }) + const text = await buildSettingsBackup() + storeState.set("providerDetectionPending", true) + expect(await restoreSettingsBackup(text)).toEqual({ ok: true }) + expect(await loadProviderDetectionPending()).toBe(false) + }) + it("puts a preference the backup left at its default back to the default", async () => { storeState.set("plugins", { order: ["claude"], disabled: [] }) const text = await buildSettingsBackup() diff --git a/src/lib/settings-backup.ts b/src/lib/settings-backup.ts index a804bd5f..8becd752 100644 --- a/src/lib/settings-backup.ts +++ b/src/lib/settings-backup.ts @@ -1,5 +1,5 @@ import { insertAccount, isAccountId, isWslId } from "@/lib/accounts" -import { backupStore, DEFAULT_ALERT_SETTINGS, DEFAULT_TASKBAR_STRIP_STYLE, normalizeAlertSettings } from "@/lib/settings" +import { backupStore, DEFAULT_ALERT_SETTINGS, DEFAULT_TASKBAR_STRIP_STYLE, normalizeAlertSettings, PROVIDER_DETECTION_KEY } from "@/lib/settings" /** * The preferences a settings backup carries (store keys). Left out: accounts and logins (never in @@ -159,6 +159,8 @@ export async function restoreSettingsBackup(text: string): Promise { expect(normalized).toEqual({ order: ["b", "a"], disabled: ["a"] }) }) - it("auto-disables new non-default plugins", () => { + it("starts a new install with every provider off, Claude and Codex too", () => { const plugins: PluginMeta[] = [ + { id: "codex", name: "Codex", iconUrl: "", lines: [], primaryCandidates: [] }, { id: "claude", name: "Claude", iconUrl: "", lines: [], primaryCandidates: [] }, - { id: "copilot", name: "Copilot", iconUrl: "", lines: [], primaryCandidates: [] }, { id: "devin", name: "Devin", iconUrl: "", lines: [], primaryCandidates: [] }, ] const result = normalizePluginSettings({ order: [], disabled: [] }, plugins) - expect(result.order).toEqual(["claude", "copilot", "devin"]) - expect(result.disabled).toEqual(["copilot", "devin"]) + expect(result.order).toEqual(["claude", "codex", "devin"]) + expect(result.disabled).toEqual(["claude", "codex", "devin"]) + }) + + it("turns off only the providers an update adds", () => { + const plugins: PluginMeta[] = [ + { id: "claude", name: "Claude", iconUrl: "", lines: [], primaryCandidates: [] }, + { id: "codex", name: "Codex", iconUrl: "", lines: [], primaryCandidates: [] }, + ] + const result = normalizePluginSettings({ order: ["claude"], disabled: [] }, plugins) + expect(result).toEqual({ order: ["claude", "codex"], disabled: ["codex"] }) }) it("migrates enabled windsurf settings to enabled devin settings", () => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a18e1322..60005db7 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -7,7 +7,8 @@ import { normalizeCurrency, type CurrencySetting } from "@/lib/currency"; // Refresh cooldown duration in milliseconds (5 minutes) export const REFRESH_COOLDOWN_MS = 300_000; -// Spec: persist plugin order + disabled list; new plugins append, default disabled unless in DEFAULT_ENABLED_PLUGINS. +// Spec: persist plugin order + disabled list; new plugins append, disabled (a new install turns on +// what `use-provider-detection.ts` finds). export type PluginSettings = { order: string[]; disabled: string[]; @@ -70,6 +71,7 @@ const GLOBAL_SHORTCUT_KEY = "globalShortcut"; const START_ON_LOGIN_KEY = "startOnLogin"; const LANGUAGE_KEY = "language"; const UPDATE_RELAUNCH_KEY = "updateRelaunchVersion"; +export const PROVIDER_DETECTION_KEY = "providerDetectionPending"; const UPDATE_NOTIFIED_KEY = "updateNotifiedVersion"; const STAR_PROMPT_KEY = "starPrompt"; @@ -145,8 +147,6 @@ export const TIME_FORMAT_OPTIONS: SettingOption[] = [ const store = new LazyStore(SETTINGS_STORE_PATH); -const DEFAULT_ENABLED_PLUGINS = new Set(["claude", "codex"]); - const PREFERRED_ORDER = ["claude", "codex", "gemini"]; export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { @@ -249,11 +249,8 @@ export function normalizePluginSettings( } const disabled = settings.disabled.filter((id) => knownSet.has(id)); - const defaultDisabledIds = hasSavedOrder ? newlyAdded : order; - for (const id of defaultDisabledIds) { - if (!DEFAULT_ENABLED_PLUGINS.has(id) && !disabled.includes(id)) { - disabled.push(id); - } + for (const id of hasSavedOrder ? newlyAdded : order) { + if (!disabled.includes(id)) disabled.push(id); } return { order, disabled }; } @@ -551,6 +548,17 @@ export async function saveUpdateRelaunchVersion(version: string | null): Promise await store.save(); } +/** Set while a new install's provider detection runs, so a start cut short detects again. */ +export async function loadProviderDetectionPending(): Promise { + return (await store.get(PROVIDER_DETECTION_KEY)) === true; +} + +export async function saveProviderDetectionPending(pending: boolean): Promise { + if (pending) await store.set(PROVIDER_DETECTION_KEY, true); + else await store.delete(PROVIDER_DETECTION_KEY); + await store.save(); +} + /** The last version whose "update ready" notification was shown: each version notifies once. */ export async function loadUpdateNotifiedVersion(): Promise { const stored = await store.get(UPDATE_NOTIFIED_KEY);