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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Entitlements & version-lock subscription model (geekfun#56)** — client-side implementation of the Ultimate entitlement contract: the two server-computed fields `ultimateExpiresAt` + `versionLockHorizon` are consumed via a new Rust entitlement module (persisted cache, offline tolerance, failure degradation, 5-minute refresh throttle, `app.releaseDate <= versionLockHorizon` unlock check). Rust command gates return `ENTITLEMENT_REQUIRED` for AI (agent loop/step, compaction, LLM validation), the whole Transfer module (import/export/migration/structure execution), SSH tunnel establishment, and the MCP bridge (config/policy save + auto-start). The frontend adds an entitlement store, Geekfun login entry, paid-feature gates with upgrade guidance on Data Studio, Transfer, AI assistant sidebar, ER diagram actions, AI/MCP settings, SSH tunnel option in the connection form, plus an Account & Plan settings tab showing the version-lock state with Geekfun login and logout (logout clears the local entitlement cache so entitlements never outlive the account session).

### Fixed

- **Deep-link sign-in on cold start** — a `sqlkit://auth` link that launches the app no longer drops the token: the payload is parked in a pending-auth slot during startup and the frontend pulls it via `consume_pending_auth` once mounted; running-instance links double-write the same slot, closing the startup window too.

## [0.8.7] - 2026-08-17

### Added
Expand Down
39 changes: 34 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,28 @@ fn parse_auth_from_url(url: &str) -> Option<AuthPayload> {
})
}

/// Deep links that arrive before the frontend has mounted cannot be delivered
/// via events (Tauri events are not queued) — they are parked here and the
/// frontend pulls them via `consume_pending_auth` once its listeners are up.
#[derive(Default)]
struct PendingAuthState(std::sync::Mutex<Option<AuthPayload>>);

impl PendingAuthState {
fn store(&self, payload: AuthPayload) {
*self.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(payload);
}

/// Take-and-clear so a delivered token can never be replayed.
fn consume(&self) -> Option<AuthPayload> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).take()
}
}

#[tauri::command]
fn consume_pending_auth(state: tauri::State<'_, PendingAuthState>) -> Option<AuthPayload> {
state.consume()
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
use crate::connection::guardian::ConnectionGuardian;
Expand Down Expand Up @@ -195,25 +217,31 @@ pub fn run() {

use tauri::{Emitter, Listener};

// Handle deep links received while the app is already running
app.manage(PendingAuthState::default());

// Handle deep links received while the app is already running.
// Double-write: the event reaches a loaded frontend, the pending
// slot covers the window before its listeners exist.
let app_handle = app.handle().clone();
app.listen("deep-link://new-url", move |event: tauri::Event| {
if let Ok(urls) = serde_json::from_str::<Vec<String>>(event.payload()) {
for url in &urls {
if let Some(payload) = parse_auth_from_url(url) {
let _ = app_handle.emit("sqlkit://auth", payload.clone());
app_handle.state::<PendingAuthState>().store(payload.clone());
let _ = app_handle.emit("sqlkit://auth", payload);
}
}
}
});

// Handle deep links passed at launch (cold start)
// Cold start: the URL arrived via argv before any frontend
// listener could exist — park it for the pull above.
let pending = app.state::<PendingAuthState>();
use tauri_plugin_deep_link::DeepLinkExt;
if let Ok(Some(urls)) = app.deep_link().get_current() {
let app_handle = app.handle().clone();
for url in &urls {
if let Some(payload) = parse_auth_from_url(url.as_str()) {
let _ = app_handle.emit("sqlkit://auth", payload);
pending.store(payload);
}
}
}
Expand Down Expand Up @@ -345,6 +373,7 @@ pub fn run() {
commands::generate_ddl_for_objects,
commands::execute_sql_content,
commands::get_app_version,
crate::consume_pending_auth,
crate::entitlement::refresh_entitlement,
crate::entitlement::get_entitlement,
crate::entitlement::clear_entitlement,
Expand Down
37 changes: 27 additions & 10 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, watch } from 'vue'
Expand All @@ -14,6 +15,8 @@ import { useAppStore } from '@/store/appStore'
import { useDeviceStore } from '@/store/deviceStore'
import { useEntitlementStore } from '@/store/entitlementStore'

type AuthPayload = { token: string, username: string, email: string }

const appStore = useAppStore()
const { themeType } = storeToRefs(appStore)
const accountStore = useAccountStore()
Expand All @@ -29,6 +32,15 @@ watch(themeType, (newTheme) => {
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) {
accountStore.setAuth(payload.token, payload.username, payload.email)
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)
}

onMounted(async () => {
checkForUpdates(false)

Expand All @@ -38,16 +50,9 @@ onMounted(async () => {
deviceStore.ensureActivated()
}

unlistenAuth = await listen<{
token: string
username: string
email: string
}>('sqlkit://auth', ({ payload }) => {
accountStore.setAuth(payload.token, payload.username, payload.email)
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)
// Listeners must exist before the pending-auth pull below.
unlistenAuth = await listen<AuthPayload>('sqlkit://auth', ({ payload }) => {
handleAuth(payload)
})

// Transparent session refresh (Rust rotates the lease): keep the frontend
Expand All @@ -56,6 +61,18 @@ onMounted(async () => {
accountStore.setToken(payload.accessToken)
accountStore.setRefreshToken(payload.refreshToken)
})

// Cold start: a deep link can arrive before these listeners exist — Rust
// parks it in pending state; consume it now.
try {
const pending = await invoke<AuthPayload | null>('consume_pending_auth')
if (pending) {
handleAuth(pending)
}
}
catch {
// no pending auth is the normal case
}
})

onUnmounted(() => {
Expand Down
Loading