Add an opt-in feedback submission flow that posts to the central feedback-api service. Integrated into the existing LogViewerCard so the same card now covers diagnostics capture (logs) and diagnostics forwarding (feedback). - Rust command `send_feedback` forwards the payload via reqwest, so the Tauri origin never needs a CORS whitelist entry server-side - First submission shows a one-time consent dialog explaining that this is the only app feature that talks to a server besides updates and Maximus sign-in - Three opt-in checkboxes (all unchecked by default): navigation context, recent error logs (appended as a suffix to the content), identify with the Maximus account - Context keys are limited to the server whitelist (page, locale, theme, viewport, userAgent, timestamp); app_version + OS are packed into userAgent via `get_feedback_user_agent` so we don't pull in an extra Tauri plugin - Error codes are stable strings (invalid, rate_limit, server_error, network_error) mapped to i18n messages on the frontend - Wording follows the cross-app convention documented in `la-compagnie-maximus/docs/feedback-hub-ops.md` - CSP `connect-src` extended with feedback.lacompagniemaximus.com - Docs: CHANGELOG (EN + FR), guide utilisateur, docs.settings (features/steps/tips) updated in both locales Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
9.5 KiB
Rust
226 lines
9.5 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,
|
|
commands::get_token_store_mode,
|
|
commands::send_feedback,
|
|
commands::get_feedback_user_agent,
|
|
])
|
|
.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
|
|
}
|