All checks were successful
Release / build-and-release (push) Successful in 27m50s
The listener `app.listen("deep-link://new-url", ...)` did not reliably
fire when tauri-plugin-single-instance (deep-link feature) forwarded a
simpl-resultat://auth/callback URL to the running instance. The user
saw the browser complete the OAuth flow, the app regain focus, and
then sit in "loading" forever because the listener never received the
URL.
Switch to the canonical Tauri v2 API — `app.deep_link().on_open_url()`
via DeepLinkExt — which is directly coupled to the deep-link plugin
and catches URLs from both initial launch and single-instance forwards.
Also surface OAuth error responses: if the callback URL contains an
`error` parameter instead of a `code`, emit `auth-callback-error` so
the UI can show the error instead of staying stuck in "loading".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
223 lines
9.4 KiB
Rust
223 lines
9.4 KiB
Rust
mod commands;
|
|
mod database;
|
|
|
|
use std::sync::Mutex;
|
|
use tauri::{Emitter, Manager};
|
|
use tauri_plugin_deep_link::DeepLinkExt;
|
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
let migrations = vec![
|
|
Migration {
|
|
version: 1,
|
|
description: "create initial schema",
|
|
sql: database::SCHEMA,
|
|
kind: MigrationKind::Up,
|
|
},
|
|
Migration {
|
|
version: 2,
|
|
description: "seed categories and keywords",
|
|
sql: database::SEED_CATEGORIES,
|
|
kind: MigrationKind::Up,
|
|
},
|
|
Migration {
|
|
version: 3,
|
|
description: "add has_header to import_sources",
|
|
sql: "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;",
|
|
kind: MigrationKind::Up,
|
|
},
|
|
Migration {
|
|
version: 4,
|
|
description: "add is_inputable to categories",
|
|
sql: "ALTER TABLE categories ADD COLUMN is_inputable INTEGER NOT NULL DEFAULT 1;",
|
|
kind: MigrationKind::Up,
|
|
},
|
|
Migration {
|
|
version: 5,
|
|
description: "create import_config_templates table",
|
|
sql: "CREATE TABLE IF NOT EXISTS import_config_templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
delimiter TEXT NOT NULL DEFAULT ';',
|
|
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
|
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
|
skip_lines INTEGER NOT NULL DEFAULT 0,
|
|
has_header INTEGER NOT NULL DEFAULT 1,
|
|
column_mapping TEXT NOT NULL,
|
|
amount_mode TEXT NOT NULL DEFAULT 'single',
|
|
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);",
|
|
kind: MigrationKind::Up,
|
|
},
|
|
Migration {
|
|
version: 6,
|
|
description: "change imported_files unique constraint from hash to filename",
|
|
sql: "CREATE TABLE imported_files_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER NOT NULL REFERENCES import_sources(id),
|
|
filename TEXT NOT NULL,
|
|
file_hash TEXT NOT NULL,
|
|
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
row_count INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'completed',
|
|
notes TEXT,
|
|
UNIQUE(source_id, filename)
|
|
);
|
|
INSERT INTO imported_files_new SELECT * FROM imported_files;
|
|
DROP TABLE imported_files;
|
|
ALTER TABLE imported_files_new RENAME TO imported_files;",
|
|
kind: MigrationKind::Up,
|
|
},
|
|
Migration {
|
|
version: 7,
|
|
description: "add level-3 insurance subcategories",
|
|
sql: "INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1);
|
|
INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2);
|
|
INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3);
|
|
UPDATE categories SET is_inputable = 0 WHERE id = 31;
|
|
UPDATE keywords SET category_id = 310 WHERE keyword = 'BELAIR' AND category_id = 31;
|
|
UPDATE keywords SET category_id = 311 WHERE keyword = 'PRYSM' AND category_id = 31;
|
|
UPDATE keywords SET category_id = 312 WHERE keyword = 'INS/ASS' AND category_id = 31;",
|
|
kind: MigrationKind::Up,
|
|
},
|
|
];
|
|
|
|
tauri::Builder::default()
|
|
// Single-instance plugin MUST be registered first. With the `deep-link`
|
|
// feature, it forwards `simpl-resultat://` URLs to the running instance
|
|
// so the OAuth2 callback reaches the process that holds the PKCE verifier.
|
|
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.set_focus();
|
|
}
|
|
}))
|
|
.manage(commands::auth_commands::OAuthState {
|
|
code_verifier: Mutex::new(None),
|
|
})
|
|
.plugin(tauri_plugin_opener::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_process::init())
|
|
.plugin(tauri_plugin_deep_link::init())
|
|
.setup(|app| {
|
|
#[cfg(desktop)]
|
|
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
|
|
|
// Register the custom scheme at runtime on Linux (the .desktop file
|
|
// handles it in prod, but register_all is a no-op there and required
|
|
// for AppImage/dev builds).
|
|
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
|
{
|
|
let _ = app.deep_link().register_all();
|
|
}
|
|
|
|
// Canonical Tauri v2 pattern: on_open_url fires for both initial-launch
|
|
// URLs and subsequent URLs forwarded by tauri-plugin-single-instance
|
|
// (with the `deep-link` feature).
|
|
let handle = app.handle().clone();
|
|
app.deep_link().on_open_url(move |event| {
|
|
for url in event.urls() {
|
|
let url_str = url.as_str();
|
|
let h = handle.clone();
|
|
if let Some(code) = extract_auth_code(url_str) {
|
|
tauri::async_runtime::spawn(async move {
|
|
match commands::handle_auth_callback(h.clone(), code).await {
|
|
Ok(account) => {
|
|
let _ = h.emit("auth-callback-success", &account);
|
|
}
|
|
Err(err) => {
|
|
let _ = h.emit("auth-callback-error", &err);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
// No `code` param — likely an OAuth error response. Surface
|
|
// it to the frontend instead of leaving the UI stuck in
|
|
// "loading" forever.
|
|
let err_msg = extract_auth_error(url_str)
|
|
.unwrap_or_else(|| "OAuth callback did not include a code".to_string());
|
|
let _ = h.emit("auth-callback-error", &err_msg);
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
})
|
|
.plugin(
|
|
tauri_plugin_sql::Builder::default()
|
|
.add_migrations("sqlite:simpl_resultat.db", migrations)
|
|
.build(),
|
|
)
|
|
.invoke_handler(tauri::generate_handler![
|
|
commands::scan_import_folder,
|
|
commands::read_file_content,
|
|
commands::hash_file,
|
|
commands::detect_encoding,
|
|
commands::get_file_preview,
|
|
commands::pick_folder,
|
|
commands::pick_save_file,
|
|
commands::pick_import_file,
|
|
commands::write_export_file,
|
|
commands::read_import_file,
|
|
commands::is_file_encrypted,
|
|
commands::load_profiles,
|
|
commands::save_profiles,
|
|
commands::delete_profile_db,
|
|
commands::get_new_profile_init_sql,
|
|
commands::hash_pin,
|
|
commands::verify_pin,
|
|
commands::repair_migrations,
|
|
commands::validate_license_key,
|
|
commands::store_license,
|
|
commands::store_activation_token,
|
|
commands::read_license,
|
|
commands::get_edition,
|
|
commands::get_machine_id,
|
|
commands::check_entitlement,
|
|
commands::activate_machine,
|
|
commands::deactivate_machine,
|
|
commands::list_activated_machines,
|
|
commands::get_activation_status,
|
|
commands::start_oauth,
|
|
commands::refresh_auth_token,
|
|
commands::get_account_info,
|
|
commands::check_subscription_status,
|
|
commands::logout,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|
|
|
|
/// Extract the `code` query parameter from a deep-link callback URL.
|
|
/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123")
|
|
fn extract_auth_code(url: &str) -> Option<String> {
|
|
extract_query_param(url, "code")
|
|
}
|
|
|
|
/// Extract an OAuth error description from a callback URL. Returns a
|
|
/// formatted string combining `error` and `error_description` when present.
|
|
fn extract_auth_error(url: &str) -> Option<String> {
|
|
let error = extract_query_param(url, "error")?;
|
|
match extract_query_param(url, "error_description") {
|
|
Some(desc) => Some(format!("{}: {}", error, desc)),
|
|
None => Some(error),
|
|
}
|
|
}
|
|
|
|
fn extract_query_param(url: &str, key: &str) -> Option<String> {
|
|
let url = url.trim();
|
|
if !url.starts_with("simpl-resultat://auth/callback") {
|
|
return None;
|
|
}
|
|
let query = url.split('?').nth(1)?;
|
|
for pair in query.split('&') {
|
|
let mut kv = pair.splitn(2, '=');
|
|
if kv.next()? == key {
|
|
return kv.next().map(|v| {
|
|
urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string())
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|