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 .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,14 @@ jobs:
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Signing identity: $CERT_ID"

- name: Stamp release date
run: echo "APP_RELEASE_DATE=$(date -u +%F)" >> $GITHUB_ENV

- name: Build and Upload Artifacts
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APP_RELEASE_DATE: ${{ env.APP_RELEASE_DATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **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).

## [0.8.7] - 2026-08-17

### Added
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"json-with-bigint": "^3.5.8",
"json5": "^2.2.3",
"lodash": "^4.18.1",
"lucide-vue-next": "^1.0.0",
"markdown-it": "^14.2.0",
"markdown-it-task-lists": "^2.1.1",
"monaco-editor": "^0.55.1",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ zip = { version = "2", features = [ "deflate" ] }
# TOML parsing (drivers.toml)
toml = "0.8"
sha2 = "0.10"
hmac = "0.12"
sqlparser = "0.62"
russh = "0.60"
axum = "0.8"
Expand Down
23 changes: 23 additions & 0 deletions src-tauri/src/agent_adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ pub async fn run_agent_loop(
settings: Value,
app: AppHandle,
) -> Result<(), String> {
crate::entitlement::ensure_local_ultimate(
&app.state::<crate::entitlement::EntitlementState>(),
"AI",
)?;
let db_state: State<storage::db::AgentDb> = app.state::<storage::db::AgentDb>();
let store = storage::session_store::SqliteSessionStore::new(db_state.inner().clone());
let emitter = TauriEmitter(app.clone());
Expand Down Expand Up @@ -151,6 +155,10 @@ pub async fn compact_agent_session(
settings: Value,
app: AppHandle,
) -> Result<Value, String> {
crate::entitlement::ensure_local_ultimate(
&app.state::<crate::entitlement::EntitlementState>(),
"AI",
)?;
let db_state: State<storage::db::AgentDb> = app.state::<storage::db::AgentDb>();
let store = storage::session_store::SqliteSessionStore::new(db_state.inner().clone());
let emitter = TauriEmitter(app.clone());
Expand Down Expand Up @@ -185,6 +193,11 @@ pub async fn run_agent_step(
api_key: String,
base_url: Option<String>,
) -> Result<String, String> {
use tauri::Manager;
crate::entitlement::ensure_local_ultimate(
&window.state::<crate::entitlement::EntitlementState>(),
"AI",
)?;
let result = lib::harness::run_agent_step(
provider, model, messages, tools, http_proxy, proxy_mode, api_key, base_url,
)
Expand All @@ -198,25 +211,35 @@ pub async fn run_agent_step(

#[tauri::command]
pub async fn validate_llm_config(
app: AppHandle,
provider: String,
api_key: String,
model: String,
http_proxy: Option<String>,
proxy_mode: Option<String>,
base_url: Option<String>,
) -> Result<bool, String> {
crate::entitlement::ensure_local_ultimate(
&app.state::<crate::entitlement::EntitlementState>(),
"AI",
)?;
lib::harness::validate_llm_config(provider, api_key, model, http_proxy, proxy_mode, base_url)
.await
}

#[tauri::command]
pub async fn list_llm_models(
app: AppHandle,
provider: String,
api_key: String,
http_proxy: Option<String>,
proxy_mode: Option<String>,
base_url: Option<String>,
) -> Result<Vec<String>, String> {
crate::entitlement::ensure_local_ultimate(
&app.state::<crate::entitlement::EntitlementState>(),
"AI",
)?;
lib::harness::list_llm_models(provider, api_key, http_proxy, proxy_mode, base_url).await
}

Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/commands/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ pub async fn connection_host_port(
return Ok((config.host.clone(), config.port));
}

// Gate before any transport is established — an unentitled user must
// not spin up tunnels at all.
crate::entitlement::ensure_local_ultimate_global("SSH tunnel")?;

match start_transport_layers(connection_id, &layers, &config.host, config.port, tunnels).await?
{
Some(local_port) => Ok(("127.0.0.1".to_string(), local_port)),
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/commands/transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub async fn preview_export_data(
preview_rows: u32,
state: State<'_, AppState>,
) -> Result<ExportPreview, String> {
crate::entitlement::ensure_local_ultimate_global("Transfer")?;
let connection = state.ensure_connection(&request.connection_id).await?;

match connection {
Expand Down Expand Up @@ -44,6 +45,7 @@ pub async fn execute_export_data(
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<TransferResult, String> {
crate::entitlement::ensure_local_ultimate_global("Transfer")?;
let connection = state.ensure_connection(&request.connection_id).await?;

match connection {
Expand Down Expand Up @@ -87,6 +89,7 @@ pub async fn execute_import_data(
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<TransferResult, String> {
crate::entitlement::ensure_local_ultimate_global("Transfer")?;
let connection = state.ensure_connection(&request.connection_id).await?;

match connection {
Expand Down Expand Up @@ -115,6 +118,7 @@ pub async fn preview_migration_data(
request: MigrationRequest,
state: State<'_, AppState>,
) -> Result<MigrationPreview, String> {
crate::entitlement::ensure_local_ultimate_global("Transfer")?;
let source_connection = state
.ensure_connection(&request.source_connection_id)
.await?;
Expand Down Expand Up @@ -146,6 +150,7 @@ pub async fn execute_migration_data(
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<TransferResult, String> {
crate::entitlement::ensure_local_ultimate_global("Transfer")?;
let source_connection = state
.ensure_connection(&request.source_connection_id)
.await?;
Expand Down Expand Up @@ -254,6 +259,7 @@ pub async fn auto_map_migration_columns(
target_engine: String,
state: State<'_, AppState>,
) -> Result<Vec<crate::transfer::MigrationMapping>, String> {
crate::entitlement::ensure_local_ultimate_global("Transfer")?;
use crate::database::DatabaseAdapter;

let connection = state.ensure_connection(&connection_id).await?;
Expand Down Expand Up @@ -291,6 +297,7 @@ pub async fn generate_ddl_for_objects(
request: DdlRequest,
state: State<'_, AppState>,
) -> Result<String, String> {
crate::entitlement::ensure_local_ultimate_global("Transfer")?;
let connection = state.ensure_connection(&request.connection_id).await?;

let engine = match connection {
Expand Down Expand Up @@ -422,6 +429,7 @@ pub async fn execute_sql_content(
on_error: Option<String>,
state: State<'_, AppState>,
) -> Result<TransferResult, String> {
crate::entitlement::ensure_local_ultimate_global("Transfer")?;
let connection = state.ensure_connection(&connection_id).await?;

let strategy = on_error.as_deref().unwrap_or("stop");
Expand Down
27 changes: 27 additions & 0 deletions src-tauri/src/common/console.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Shared Geekfun console API plumbing: base URL resolution and a process
//! wide HTTP client (the console endpoints are low-frequency, so one client
//! is built once and reused).

use std::sync::OnceLock;
use std::time::Duration;

const CONSOLE_PROD_URL: &str = "https://console-geekfun.wentsen.com";
const CONSOLE_DEV_URL: &str = "http://localhost:5174";

pub fn api_base_url() -> &'static str {
if cfg!(debug_assertions) {
CONSOLE_DEV_URL
} else {
CONSOLE_PROD_URL
}
}

pub fn client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("reqwest client with static configuration")
})
}
1 change: 1 addition & 0 deletions src-tauri/src/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod console;
pub mod format;
pub mod http_client;
Loading
Loading