diff --git a/src-tauri/src/device_activation.rs b/src-tauri/src/device_activation.rs index d1bbb07a..b2015aeb 100644 --- a/src-tauri/src/device_activation.rs +++ b/src-tauri/src/device_activation.rs @@ -67,8 +67,10 @@ pub struct ActivatedResult { pub limit: u32, pub used: u32, /// Device-bound 30-day lease — persisted into the session store. - /// The backend keeps `access_token`-style snake_case for token fields. - #[serde(rename = "refresh_token", default)] + /// The backend keeps `access_token`-style snake_case for token fields; + /// `refreshToken` is aliased because camelCase responses were observed + /// in the wild and a silent None here is what kills session renewal. + #[serde(rename = "refresh_token", alias = "refreshToken", default)] pub refresh_token: Option, } @@ -295,6 +297,17 @@ mod tests { let result: ActivatedResult = serde_json::from_value(raw).expect("result"); assert_eq!(result.refresh_token.as_deref(), Some("opaque-lease")); + // camelCase lease responses must land too — a silent None here is + // exactly what broke session renewal in the field. + let camel = json!({ + "deviceId": "dev_9", + "limit": 3, + "used": 3, + "refreshToken": "camel-lease", + }); + let result: ActivatedResult = serde_json::from_value(camel).expect("camel result"); + assert_eq!(result.refresh_token.as_deref(), Some("camel-lease")); + // tolerate responses without the lease (older backends) let bare: ActivatedResult = serde_json::from_value(json!({ "deviceId": "d", "limit": 3, "used": 1 })) diff --git a/src-tauri/src/entitlement.rs b/src-tauri/src/entitlement.rs index c69dc322..5300ea08 100644 --- a/src-tauri/src/entitlement.rs +++ b/src-tauri/src/entitlement.rs @@ -427,6 +427,26 @@ pub fn get_entitlement(state: State<'_, EntitlementState>) -> EntitlementView { state.view(true, None) } +/// Seed the entitlement cache from the login handoff so the plan shows +/// instantly; the regular refresh remains the source of truth. +#[tauri::command] +pub fn seed_entitlement( + ultimate_expires_at: Option, + version_lock_horizon: Option, + cancel_scheduled_at: Option, + state: State<'_, EntitlementState>, +) -> EntitlementView { + if ultimate_expires_at.is_some() || version_lock_horizon.is_some() { + state.set_cache(SubscriptionCache { + fetched_at_ms: now_unix_ms(), + ultimate_expires_at, + version_lock_horizon, + cancel_scheduled_at, + }); + } + state.view(true, None) +} + /// Called on logout — entitlements are account-scoped, so the cached state /// must not outlive the session (a different account on the same machine /// must not inherit the previous account's version lock). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cd296139..096e6217 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -386,7 +386,10 @@ pub fn run() { crate::consume_pending_auth, crate::entitlement::refresh_entitlement, crate::entitlement::get_entitlement, + crate::entitlement::seed_entitlement, crate::entitlement::clear_entitlement, + crate::session::rotate_session_now, + crate::session::revoke_session, crate::device_activation::activate_device, crate::mcp_bridge::get_mcp_status, crate::mcp_bridge::save_mcp_config, diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 225427c0..5d980ae8 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; +use tauri::{AppHandle, Emitter, State}; use crate::common::console; use crate::device_identity; @@ -93,6 +94,51 @@ pub async fn rotate_session( Ok(session) } +/// Proactive renewal: returns the successor pair (also emitted via +/// `session-refreshed`); rejections carry `SESSION_REJECTED` for the +/// frontend to drop the lease. +#[tauri::command] +pub async fn rotate_session_now( + refresh_token: Option, + state: State<'_, SessionState>, + identity: State<'_, crate::device_activation::DeviceIdentityState>, + app: AppHandle, +) -> Result { + let lease = refresh_token.as_deref().unwrap_or("").trim().to_string(); + if lease.is_empty() { + return Err("no stored refresh token".to_string()); + } + let refreshed = rotate_session(&state, &lease, &identity.payload()).await?; + let _ = app.emit( + "session-refreshed", + json!({ + "accessToken": refreshed.access_token, + "refreshToken": refreshed.refresh_token, + }), + ); + Ok(refreshed) +} + +/// Logout: best-effort revocation of the presented lease; idempotent and +/// non-fatal by contract — the client clears its local copy regardless. +#[tauri::command] +pub async fn revoke_session(refresh_token: Option) -> Result<(), String> { + let lease = refresh_token.as_deref().unwrap_or("").trim(); + if lease.is_empty() { + return Ok(()); + } + let response = console::client() + .post(format!("{}/api/v1/auth/logout", console::api_base_url())) + .json(&json!({ "refresh_token": lease })) + .send() + .await + .map_err(|e| format!("network error: {e}"))?; + if !response.status().is_success() { + return Err(format!("logout endpoint returned HTTP {}", response.status())); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/App.vue b/src/App.vue index bf55118d..fa8ba9cb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event' import { storeToRefs } from 'pinia' import { onMounted, onUnmounted, watch } from 'vue' import { RouterView } from 'vue-router' +import { shouldRotateToken } from '@/common' import DeviceReplaceDialog from '@/components/DeviceReplaceDialog.vue' import AppNotifications from '@/components/ui/notification/AppNotifications.vue' import UpdateNotification from '@/components/UpdateNotification.vue' @@ -21,6 +22,9 @@ type AuthPayload = { email: string userId?: string avatar?: string + ultimateExpiresAt?: string | null + versionLockHorizon?: string | null + cancelScheduledAt?: string | null } const appStore = useAppStore() @@ -39,18 +43,40 @@ let unlistenAuth: UnlistenFn | null = null let unlistenSessionRefresh: UnlistenFn | null = null // Idempotent: events and the cold-start pull may both deliver the same link. -function handleAuth(payload: AuthPayload) { +async function handleAuth(payload: AuthPayload) { accountStore.setAuth(payload.token, payload.username, payload.email, payload.userId, payload.avatar) + // Await the local seed so it can never land after the network refresh + // below and overwrite a fresher server answer with the snapshot. + await entitlementStore.seedFromHandoff(payload) entitlementStore.refreshEntitlement(true) // The deep-linked token comes from a web login with no device attached — // register/verify this machine right away. deviceStore.ensureActivated(true) } +async function rotateStaleSession() { + if (!accountStore.refreshToken || !shouldRotateToken(accountStore.token, accountStore.refreshToken)) + return + try { + const refreshed = await invoke<{ access_token: string, refresh_token: string }>( + 'rotate_session_now', + { refreshToken: accountStore.refreshToken }, + ) + accountStore.setToken(refreshed.access_token) + accountStore.setRefreshToken(refreshed.refresh_token) + } + catch { + // rejected or transient — the reactive 401 paths still recover + } +} + onMounted(async () => { checkForUpdates(false) if (accountStore.isLoggedIn) { + await entitlementStore.hydrate() + // Rotate before the refresh so it goes out with a valid bearer. + await rotateStaleSession() entitlementStore.refreshEntitlement(true) // geekfun#59: entitlement-activation point — register/verify this device. deviceStore.ensureActivated() diff --git a/src/common/index.ts b/src/common/index.ts index bec3f53e..722ff37e 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -4,4 +4,5 @@ export const pureObject = (obj: T): T => JSON.parse(JSON.stringify(obj)) as T export * from './entitlement' +export * from './sessionExpiry' export * from './sqlParser' diff --git a/src/common/sessionExpiry.ts b/src/common/sessionExpiry.ts new file mode 100644 index 00000000..9cd15555 --- /dev/null +++ b/src/common/sessionExpiry.ts @@ -0,0 +1,28 @@ +const TOKEN_EXPIRY_MARGIN_MS = 10 * 60 * 1000 + +export function parseJwtExpMs(token: string): number | null { + const parts = token.split('.') + if (parts.length !== 3 || !parts[1]) + return null + try { + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const payload = JSON.parse(atob(b64)) as { exp?: unknown } + const exp = payload.exp + return typeof exp === 'number' && Number.isFinite(exp) && exp > 0 ? exp * 1000 : null + } + catch { + return null + } +} + +/** Opaque (non-JWT) tokens return false — the reactive 401 path decides. */ +export function shouldRotateToken(token: string, refreshToken: string, now = Date.now(), marginMs = TOKEN_EXPIRY_MARGIN_MS): boolean { + if (!refreshToken.trim()) + return false + if (!token.trim()) + return true + const expMs = parseJwtExpMs(token) + if (expMs === null) + return false + return expMs - now <= marginMs +} diff --git a/src/components/layout/AiAssistantSidebar.vue b/src/components/layout/AiAssistantSidebar.vue index d47e6a92..40404dee 100644 --- a/src/components/layout/AiAssistantSidebar.vue +++ b/src/components/layout/AiAssistantSidebar.vue @@ -2,6 +2,8 @@ import { onMounted, onUnmounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import ChatPanel from '@/components/chat-panel.vue' +// NOTE: without this import the tag resolves to an unknown element +import { PaidGate } from '@/components/upgrade' import { useSidebarChatAgent } from '@/composables/useSidebarChatAgent' import { useDataStudioStore } from '@/store/dataStudioStore' import SessionHistoryPanel from '@/views/data-studio/components/session-history-panel.vue' @@ -106,76 +108,78 @@ onUnmounted(() => {
- - -
- -
-
- - - - + + + + + +
diff --git a/src/components/layout/UserMenu.vue b/src/components/layout/UserMenu.vue index 7030007f..fddaacfe 100644 --- a/src/components/layout/UserMenu.vue +++ b/src/components/layout/UserMenu.vue @@ -1,4 +1,5 @@ @@ -232,11 +230,11 @@ async function handleSubscribe() { {{ $t('plan.upgrade.cta') }}
@@ -360,6 +358,12 @@ async function handleSubscribe() { .gate-card__bg { position: absolute; inset: 0; + /* In the compact card the poster is ambiance, not content — at full + opacity its scenario collides with the gate copy stacked above it. */ + opacity: 0.2; + filter: blur(2px); + -webkit-mask-image: linear-gradient(to bottom, black, transparent 78%); + mask-image: linear-gradient(to bottom, black, transparent 78%); } .gate-card__bg :deep(.poster) { @@ -600,6 +604,7 @@ async function handleSubscribe() { .gate-card__actions { display: flex; + flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px; diff --git a/src/components/upgrade/UpgradeDialog.vue b/src/components/upgrade/UpgradeDialog.vue index c898d396..c9068b40 100644 --- a/src/components/upgrade/UpgradeDialog.vue +++ b/src/components/upgrade/UpgradeDialog.vue @@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogContent } from '@/components/ui/dialog' import { UPGRADE_URL } from '../../common' import { useAccountStore, useEntitlementStore } from '../../store' -import { openRegisterUrl } from '../../utils/authService' +import { openLoginUrl, openRegisterUrl } from '../../utils/authService' import { AuroraBackground, ShimmerButton } from './effects' import ProBadge from './ProBadge.vue' import { registerUpgradeDialog } from './upgradeDialogService' @@ -99,6 +99,10 @@ async function handleStartFree() { await openRegisterUrl() } +async function handleLogin() { + await openLoginUrl() +} + async function handleRefresh() { refreshing.value = true try { @@ -201,11 +205,11 @@ onUnmounted(() => { diff --git a/src/lang/enUS.ts b/src/lang/enUS.ts index dc032b7d..d8425521 100644 --- a/src/lang/enUS.ts +++ b/src/lang/enUS.ts @@ -61,8 +61,6 @@ export const enUS = { cancel: 'Cancel anytime', }, cta: { - trial: 'Start 7-day free trial', - subscribe: 'Subscribe', unlock: 'Unlock with Ultimate', unlockSub: '7-day free trial · $9.9/mo · $99/yr', }, @@ -108,6 +106,8 @@ export const enUS = { expiresAt: 'Active until {time}', cancelScheduled: 'Subscription is scheduled to end at the close of the current billing period.', checkFailed: 'Could not verify entitlements — showing cached state. Check your network and refresh.', + sessionExpired: 'Session expired — log in again to verify your plan.', + deviceActivationFailed: 'Device registration failed — automatic session renewal is unavailable.', notLoggedIn: 'You are not logged in.', loginLink: 'Log in with Geekfun', refresh: 'Refresh', diff --git a/src/lang/zhCN.ts b/src/lang/zhCN.ts index f8da1776..4a4ec573 100644 --- a/src/lang/zhCN.ts +++ b/src/lang/zhCN.ts @@ -61,8 +61,6 @@ export const zhCN = { cancel: '随时取消', }, cta: { - trial: '开始 7 天免费试用', - subscribe: '订阅', unlock: '解锁 Ultimate', unlockSub: '7 天免费试用 · $9.9/月 · $99/年', }, @@ -108,6 +106,8 @@ export const zhCN = { expiresAt: '有效期至 {time}', cancelScheduled: '订阅已计划于本期期末结束。', checkFailed: '权益校验失败,当前展示缓存状态。请检查网络后刷新。', + sessionExpired: '登录已过期,请重新登录以验证订阅状态。', + deviceActivationFailed: '设备注册失败,会话自动续期暂不可用。', notLoggedIn: '尚未登录。', loginLink: '通过 Geekfun 登录', refresh: '刷新', diff --git a/src/store/deviceStore.ts b/src/store/deviceStore.ts index c72f4fe4..639b6bf7 100644 --- a/src/store/deviceStore.ts +++ b/src/store/deviceStore.ts @@ -32,10 +32,17 @@ export type ActivatedResult = { refreshToken?: string | null } -/** One activation attempt per app run, plus re-activation after each login. */ const ACTIVATION_THROTTLE_MS = 24 * 60 * 60 * 1000 +const ACTIVATION_RETRY_DELAYS_MS = [1500, 3000, 6000] const LAST_ACTIVATED_KEY = 'device_last_activated_at' +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +function isActivationFatal(err: unknown): boolean { + const raw = typeof err === 'string' ? err : String(err) + return raw.includes('session expired') || raw.includes('not logged in') +} + export const useDeviceStore = defineStore('device', { state: (): { deviceId: string @@ -44,6 +51,8 @@ export const useDeviceStore = defineStore('device', { limitInfo: DeviceLimitInfo | null showReplaceDialog: boolean activating: boolean + /** Last activation failure — shown in the plan section (plan-section.vue). */ + activationError: string | null } => ({ deviceId: '', limit: 0, @@ -51,6 +60,7 @@ export const useDeviceStore = defineStore('device', { limitInfo: null, showReplaceDialog: false, activating: false, + activationError: null, }), getters: { isActivated: state => state.deviceId.length > 0, @@ -64,10 +74,8 @@ export const useDeviceStore = defineStore('device', { const last = Number(localStorage.getItem(LAST_ACTIVATED_KEY) ?? 0) return !this.isActivated && Date.now() - last > ACTIVATION_THROTTLE_MS }, - /** - * 权益激活执行点:login 成功与应用启动时调用。失败静默降级为提示—— - * 服务器不会因为设备超限锁账号,5030 弹出替换选择器由用户决定。 - */ + // Activation issues the renewal lease — final failure means the session + // cannot self-heal; 5030 needs the user's replace decision instead. async ensureActivated(force = false): Promise { const accountStore = useAccountStore() if (!accountStore.isLoggedIn || this.activating || !this.shouldAttempt(force)) { @@ -75,19 +83,31 @@ export const useDeviceStore = defineStore('device', { } this.activating = true try { - const result = await invoke('activate_device', { - token: accountStore.token, - refreshToken: accountStore.refreshToken || null, - replaceDeviceId: null, - }) - this.applyActivated(result) - localStorage.setItem(LAST_ACTIVATED_KEY, String(Date.now())) - } - catch (err) { - const limitInfo = parseLimitReached(err) - if (limitInfo) { - this.limitInfo = limitInfo - this.showReplaceDialog = true + for (let attempt = 0; ; attempt += 1) { + try { + const result = await invoke('activate_device', { + token: accountStore.token, + refreshToken: accountStore.refreshToken || null, + replaceDeviceId: null, + }) + this.applyActivated(result) + this.activationError = null + localStorage.setItem(LAST_ACTIVATED_KEY, String(Date.now())) + return + } + catch (err) { + const limitInfo = parseLimitReached(err) + if (limitInfo) { + this.limitInfo = limitInfo + this.showReplaceDialog = true + return + } + if (attempt >= ACTIVATION_RETRY_DELAYS_MS.length || isActivationFatal(err)) { + this.activationError = typeof err === 'string' ? err : String(err) + return + } + await sleep(ACTIVATION_RETRY_DELAYS_MS[attempt]) + } } } finally { @@ -108,6 +128,7 @@ export const useDeviceStore = defineStore('device', { replaceDeviceId: deviceId, }) this.applyActivated(result) + this.activationError = null localStorage.setItem(LAST_ACTIVATED_KEY, String(Date.now())) return true } diff --git a/src/store/entitlementStore.ts b/src/store/entitlementStore.ts index 0f8b2b33..473bd6df 100644 --- a/src/store/entitlementStore.ts +++ b/src/store/entitlementStore.ts @@ -6,6 +6,10 @@ import { useAccountStore } from './accountStore' export type PlanState = 'ultimate' | 'community' | 'unknown' +const REFRESH_RETRY_DELAYS_MS = [1500, 3000, 6000] + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + export const useEntitlementStore = defineStore('entitlement', { state: (): { view: EntitlementView | null } => ({ view: null, @@ -24,27 +28,64 @@ export const useEntitlementStore = defineStore('entitlement', { }, cancelScheduled: (state): boolean => Boolean(state.view?.cancelScheduledAt), hasEntitlementError: (state): boolean => Boolean(state.view?.lastError), + // matches the plain-string errors Rust refresh_entitlement classifies by + sessionExpired: (state): boolean => { + const err = state.view?.lastError + return err === 'session expired' || err === 'not logged in' + }, }, actions: { - async refreshEntitlement(force = false): Promise { - const accountStore = useAccountStore() + async seedFromHandoff(payload: { + ultimateExpiresAt?: string | null + versionLockHorizon?: string | null + cancelScheduledAt?: string | null + }): Promise { + const { ultimateExpiresAt, versionLockHorizon, cancelScheduledAt } = payload + if (!ultimateExpiresAt && !versionLockHorizon) + return try { - this.view = await invoke('refresh_entitlement', { - token: accountStore.token, - refreshToken: accountStore.refreshToken || null, - force, + this.view = await invoke('seed_entitlement', { + ultimateExpiresAt: ultimateExpiresAt ?? null, + versionLockHorizon: versionLockHorizon ?? null, + cancelScheduledAt: cancelScheduledAt ?? null, }) } - catch (e) { - if (isSessionRejected(e)) { - // The lease is dead server-side — drop it so the next login starts - // clean instead of presenting a revoked token. - accountStore.setRefreshToken('') + catch { + // best effort — the refresh below still verifies + } + }, + // only a real server answer (fetchedAtMs) may surface as a confirmed plan + async hydrate(): Promise { + const cached = await invoke('get_entitlement').catch(() => null) + if (cached?.fetchedAtMs != null) + this.view = cached + }, + async refreshEntitlement(force = false): Promise { + const accountStore = useAccountStore() + for (let attempt = 0; ; attempt += 1) { + try { + this.view = await invoke('refresh_entitlement', { + token: accountStore.token, + refreshToken: accountStore.refreshToken || null, + force, + }) + return } - if (!isEntitlementError(e) && !isSessionRejected(e)) { - throw e + catch (e) { + if (isSessionRejected(e)) { + // dead lease — drop it so the next login starts clean + accountStore.setRefreshToken('') + this.view = null + return + } + if (attempt >= REFRESH_RETRY_DELAYS_MS.length) { + if (!isEntitlementError(e)) + throw e + this.view = null + return + } + await sleep(REFRESH_RETRY_DELAYS_MS[attempt]) } - this.view = null } }, async clearCachedEntitlement(): Promise { diff --git a/src/views/setting/plan-section.vue b/src/views/setting/plan-section.vue index e96dd5ae..553798e9 100644 --- a/src/views/setting/plan-section.vue +++ b/src/views/setting/plan-section.vue @@ -1,4 +1,5 @@