feat(portable): add secure Windows portable release (#15)

Implement marker-based portable storage, portable WebView and log paths, secure queue and migration sanitization, and Windows portable ZIP validation while preserving the NSIS installer path.

Refs #15
This commit is contained in:
NimBold
2026-07-12 23:08:48 +03:30
parent 56b4c9f511
commit a0f44b79ad
20 changed files with 905 additions and 82 deletions
+378 -18
View File
@@ -3,7 +3,6 @@ use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Manager;
const DATABASE_NAME: &str = "firelink.sqlite";
const LEGACY_STORE_NAME: &str = "store.bin";
@@ -16,6 +15,7 @@ static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(());
pub struct DbState {
conn: Mutex<Connection>,
portable: bool,
}
impl DbState {
@@ -49,20 +49,16 @@ struct LegacyData {
pairing_token: Option<String>,
}
pub fn init(app_handle: &tauri::AppHandle) -> Result<DbState, String> {
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|error| format!("failed to resolve app data directory: {error}"))?;
init_at_path_internal(&app_data_dir)
pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> {
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable())
}
#[cfg(test)]
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
init_at_path_internal(app_data_dir)
init_at_path_internal(app_data_dir, false)
}
fn init_at_path_internal(app_data_dir: &Path) -> Result<DbState, String> {
fn init_at_path_internal(app_data_dir: &Path, portable: bool) -> Result<DbState, String> {
fs::create_dir_all(app_data_dir)
.map_err(|error| format!("failed to create app data directory: {error}"))?;
let database_path = app_data_dir.join(DATABASE_NAME);
@@ -73,7 +69,12 @@ fn init_at_path_internal(app_data_dir: &Path) -> Result<DbState, String> {
let version = connection
.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))
.map_err(|error| format!("failed to read database schema version: {error}"))?;
if existed && version < CURRENT_SCHEMA_VERSION {
// Portable mode intentionally does not create raw migration backups:
// those backups would duplicate any legacy transfer secrets beside the
// executable. The imported data is sanitized before the portable DB is
// used, and any legacy source is sanitized in place after a successful
// import so it cannot remain as an unsanitized sidecar.
if existed && version < CURRENT_SCHEMA_VERSION && !portable {
backup_database(&connection, &database_path, &format!("schema-v{version}"))?;
}
migrate_schema(&mut connection, version)?;
@@ -81,13 +82,23 @@ fn init_at_path_internal(app_data_dir: &Path) -> Result<DbState, String> {
// We no longer touch the keychain on backend startup.
// Legacy imports will safely preserve any pairing token in the JSON payload.
// The frontend will manually trigger migration to the keychain via IPC if access is granted.
import_legacy_data(&mut connection, app_data_dir, false)?;
import_legacy_data(&mut connection, app_data_dir, false, portable)?;
if portable {
sanitize_persisted_downloads(&mut connection)?;
}
Ok(DbState {
conn: Mutex::new(connection),
portable,
})
}
impl DbState {
pub fn is_portable(&self) -> bool {
self.portable
}
}
fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), String> {
if from_version > CURRENT_SCHEMA_VERSION {
return Err(format!(
@@ -176,6 +187,7 @@ fn import_legacy_data(
connection: &mut Connection,
app_data_dir: &Path,
migrate_keychain: bool,
portable: bool,
) -> Result<(), String> {
let legacy_app_dir = app_data_dir
.parent()
@@ -194,9 +206,14 @@ fn import_legacy_data(
}
let marker = format!("legacy-import:{}", candidate.to_string_lossy());
if metadata_exists(connection, &marker)? {
if portable {
sanitize_legacy_source(&candidate)?;
}
continue;
}
backup_file(&candidate, "legacy-import")?;
if !portable {
backup_file(&candidate, "legacy-import")?;
}
let mut legacy = if candidate
.file_name()
.is_some_and(|name| name == DATABASE_NAME)
@@ -230,11 +247,85 @@ fn import_legacy_data(
params![marker],
)
.map_err(|error| format!("failed to record legacy import: {error}"))?;
if portable {
sanitize_legacy_source(&candidate)?;
}
}
}
Ok(())
}
fn sanitize_legacy_source(path: &Path) -> Result<(), String> {
if path
.file_name()
.is_some_and(|name| name == DATABASE_NAME)
{
let mut connection = Connection::open(path).map_err(|error| {
format!(
"failed to open legacy database '{}' for portable sanitization: {error}",
path.display()
)
})?;
if table_exists(&connection, "downloads")? {
return sanitize_persisted_downloads(&mut connection);
}
return Ok(());
}
let text = fs::read_to_string(path).map_err(|error| {
format!(
"failed to read legacy store '{}' for portable sanitization: {error}",
path.display()
)
})?;
let mut document: Value = serde_json::from_str(&text).map_err(|error| {
format!(
"failed to decode legacy store '{}' for portable sanitization: {error}",
path.display()
)
})?;
let Some(downloads) = document
.get_mut("download_queue")
.and_then(Value::as_array_mut)
else {
return Ok(());
};
for download in downloads {
remove_persisted_transfer_secrets(download);
}
let sanitized = serde_json::to_string(&document).map_err(|error| {
format!(
"failed to encode legacy store '{}' for portable sanitization: {error}",
path.display()
)
})?;
if sanitized == text {
return Ok(());
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("invalid legacy store path '{}'", path.display()))?;
let temporary = path.with_file_name(format!(".{file_name}.portable-sanitized.tmp"));
fs::write(&temporary, sanitized).map_err(|error| {
format!(
"failed to write temporary sanitized legacy store '{}': {error}",
temporary.display()
)
})?;
if let Err(rename_error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(path);
fs::rename(&temporary, path).map_err(|error| {
format!(
"failed to replace legacy store '{}' after rename error ({rename_error}): {error}",
path.display()
)
})?;
}
Ok(())
}
fn merge_legacy_data(connection: &mut Connection, legacy: LegacyData) -> Result<(), String> {
let transaction = connection
.transaction()
@@ -595,13 +686,20 @@ pub fn load_downloads(connection: &Connection) -> Result<Vec<String>, String> {
query_string_column(connection, "SELECT data FROM downloads ORDER BY rowid")
}
pub fn replace_downloads(connection: &mut Connection, data: &str) -> Result<(), String> {
pub fn replace_downloads(
connection: &mut Connection,
data: &str,
portable: bool,
) -> Result<(), String> {
let values: Vec<Value> = serde_json::from_str(data)
.map_err(|error| format!("failed to decode downloads: {error}"))?;
let strings = values
.iter()
.map(|value| {
serde_json::to_string(value)
.into_iter()
.map(|mut value| {
if portable {
remove_persisted_transfer_secrets(&mut value);
}
serde_json::to_string(&value)
.map_err(|error| format!("failed to encode download: {error}"))
})
.collect::<Result<Vec<_>, _>>()?;
@@ -614,6 +712,106 @@ pub fn replace_downloads(connection: &mut Connection, data: &str) -> Result<(),
.map_err(|error| format!("failed to commit download save: {error}"))
}
fn remove_persisted_transfer_secrets(value: &mut Value) {
let Some(object) = value.as_object_mut() else {
return;
};
// These values are accepted from users, browser extensions, or URLs and
// may contain credentials or bearer tokens. Portable queues keep their
// useful metadata, but never persist these values beside the executable.
for key in ["password", "cookies", "headers", "mirrors", "proxy"] {
object.remove(key);
}
if let Some(url) = object.get("url").and_then(Value::as_str) {
if let Ok(mut parsed) = url::Url::parse(url) {
let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some();
let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some();
if had_userinfo || had_query_or_fragment {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_query(None);
parsed.set_fragment(None);
object.insert("url".to_string(), Value::String(parsed.to_string()));
// A queued transfer whose URL depended on query/fragment
// credentials must not silently auto-resume with a truncated
// URL after a portable restart.
if had_userinfo || had_query_or_fragment {
mark_portable_download_unresumable(object);
}
}
} else {
object.insert("url".to_string(), Value::String(String::new()));
mark_portable_download_unresumable(object);
}
}
}
fn mark_portable_download_unresumable(object: &mut serde_json::Map<String, Value>) {
if object
.get("status")
.and_then(Value::as_str)
.is_some_and(|status| status != "completed")
{
object.insert("status".to_string(), Value::String("failed".to_string()));
object.insert("resumable".to_string(), Value::Bool(false));
object.insert(
"lastError".to_string(),
Value::String(
"Portable mode removed credentials from this persisted download; add it again to resume."
.to_string(),
),
);
}
}
fn sanitize_persisted_downloads(connection: &mut Connection) -> Result<(), String> {
let transaction = connection
.transaction()
.map_err(|error| format!("failed to begin portable download sanitization: {error}"))?;
let records = {
let mut statement = transaction
.prepare("SELECT id, data FROM downloads")
.map_err(|error| {
format!("failed to prepare portable download sanitization: {error}")
})?;
let rows = statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|error| {
format!("failed to read downloads for portable sanitization: {error}")
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(|error| {
format!("failed to read download for portable sanitization: {error}")
})?
};
for (id, data) in records {
let mut value: Value = serde_json::from_str(&data).map_err(|error| {
format!("failed to decode download '{id}' for portable sanitization: {error}")
})?;
remove_persisted_transfer_secrets(&mut value);
let sanitized = serde_json::to_string(&value).map_err(|error| {
format!("failed to encode download '{id}' for portable sanitization: {error}")
})?;
if sanitized != data {
transaction
.execute(
"UPDATE downloads SET data = ?1 WHERE id = ?2",
params![sanitized, id],
)
.map_err(|error| format!("failed to sanitize download '{id}': {error}"))?;
}
}
transaction
.commit()
.map_err(|error| format!("failed to commit portable download sanitization: {error}"))
}
fn replace_downloads_tx(transaction: &Transaction<'_>, downloads: &[String]) -> Result<(), String> {
transaction
.execute("DELETE FROM downloads", [])
@@ -844,10 +1042,22 @@ pub fn load_pairing_token_from_settings(connection: &Connection) -> Result<Optio
pub fn save_pairing_token_to_settings(
connection: &Connection,
token: &str,
initialize_if_missing: bool,
) -> Result<(), String> {
let Some(settings_json) = load_settings(connection)? else {
// Settings haven't been persisted yet — nothing to update.
return Ok(());
if !initialize_if_missing {
// Settings have not been persisted yet. Standard mode keeps the
// first-run token session-only until the user grants credential
// store access; portable mode opts into initialization explicitly.
return Ok(());
}
let initial = serde_json::json!({
"state": { "extensionPairingToken": token },
"version": 3
});
let serialized = serde_json::to_string(&initial)
.map_err(|error| format!("failed to encode initial settings: {error}"))?;
return save_settings(connection, &serialized);
};
let mut value: serde_json::Value = serde_json::from_str(&settings_json)
.map_err(|error| format!("failed to decode settings: {error}"))?;
@@ -1077,6 +1287,43 @@ mod tests {
}));
}
#[test]
fn portable_migration_does_not_create_raw_schema_backup() {
let temp = TempDir::new().unwrap();
let path = temp.path().join(DATABASE_NAME);
let connection = Connection::open(&path).unwrap();
connection
.execute_batch(
"
CREATE TABLE downloads (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
queue_id TEXT NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL);
INSERT INTO downloads VALUES (
'one', 'queued', 'main',
'{\"id\":\"one\",\"status\":\"queued\",\"password\":\"secret\"}'
);
",
)
.unwrap();
drop(connection);
let state = init_at_path_internal(temp.path(), true).unwrap();
let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none());
assert!(!fs::read_dir(temp.path()).unwrap().flatten().any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("firelink.sqlite.backup-schema-v0-")
}));
}
#[test]
fn imports_legacy_bundle_store_and_preserves_token() {
let root = TempDir::new().unwrap();
@@ -1126,6 +1373,33 @@ mod tests {
}));
}
#[test]
fn portable_import_sanitizes_legacy_source_after_success() {
let root = TempDir::new().unwrap();
let current = root.path().join("com.nimbold.firelink");
let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER);
fs::create_dir_all(&legacy).unwrap();
let store_path = legacy.join(LEGACY_STORE_NAME);
let store = json!({
"settings": json!({"state": {"theme": "dark"}}).to_string(),
"download_queue": [{
"id": "download-1",
"status": "queued",
"url": "https://example.com/file",
"password": "legacy-secret"
}],
"queues": []
});
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
let state = init_at_path_internal(&current, true).unwrap();
let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none());
let sanitized_store = fs::read_to_string(&store_path).unwrap();
assert!(!sanitized_store.contains("legacy-secret"));
}
#[test]
fn imports_legacy_bundle_sqlite_database() {
let root = TempDir::new().unwrap();
@@ -1178,6 +1452,92 @@ mod tests {
}));
}
#[test]
fn portable_download_persistence_removes_transfer_secrets() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "download-1",
"status": "queued",
"queueId": "main",
"url": "https://user:secret@example.com/file?token=secret#fragment",
"password": "secret",
"cookies": "session=secret",
"headers": "Authorization: Bearer secret",
"mirrors": "https://user:secret@example.com/mirror",
"proxy": "http://user:secret@example.com:8080"
}])
.to_string();
replace_downloads(&mut connection, &data, true).unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert_eq!(saved["url"], "https://example.com/file");
assert_eq!(saved["status"], "failed");
assert_eq!(saved["resumable"], false);
assert!(!saved.to_string().contains("secret"));
for key in ["password", "cookies", "headers", "mirrors", "proxy"] {
assert!(saved.get(key).is_none(), "portable data retained {key}");
}
}
#[test]
fn portable_persistence_redacts_unparseable_download_urls() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "download-1",
"status": "queued",
"url": "not a URL secret=secret"
}])
.to_string();
replace_downloads(&mut connection, &data, true).unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert_eq!(saved["url"], "");
assert_eq!(saved["status"], "failed");
assert!(!saved.to_string().contains("secret"));
}
#[test]
fn portable_initialization_sanitizes_existing_downloads() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "download-1",
"status": "queued",
"url": "https://example.com/file",
"password": "secret"
}])
.to_string();
replace_downloads(&mut connection, &data, false).unwrap();
drop(connection);
drop(state);
let state = init_at_path_internal(temp.path(), true).unwrap();
let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none());
}
#[test]
fn pairing_token_is_persisted_before_frontend_settings_exist() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
save_pairing_token_to_settings(&connection, "initial-token", true).unwrap();
assert_eq!(
load_pairing_token_from_settings(&connection).unwrap().as_deref(),
Some("initial-token")
);
}
#[test]
fn token_decision_preserves_keychain_and_legacy_values() {
let keychain = decide_pairing_token(Some("keychain"), Some("legacy"), true, "generated");
+8
View File
@@ -30,6 +30,7 @@ const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce";
const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
const PROTOCOL_VERSION: &str = "4";
@@ -140,6 +141,13 @@ async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
PROTOCOL_VERSION_HEADER,
HeaderValue::from_static(PROTOCOL_VERSION),
);
if std::env::var_os("FIRELINK_SMOKE_TEST").is_some() {
if let Ok(process_id) = HeaderValue::from_str(&std::process::id().to_string()) {
response
.headers_mut()
.insert(SMOKE_PROCESS_ID_HEADER, process_id);
}
}
response
}
+1
View File
@@ -297,6 +297,7 @@ pub struct PlatformInfo {
pub os: String,
pub arch: String,
pub target_triple: String,
pub portable: bool,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
+126 -21
View File
@@ -1959,6 +1959,7 @@ pub mod queue;
pub mod process;
pub mod retry;
mod settings;
mod storage;
pub use error::AppError;
// Retained only for compatibility with the optional aria2 diagnostic monitor.
@@ -1970,6 +1971,7 @@ pub enum TaskHandle {
pub struct AppState {
pub download_coordinator: download::DownloadCoordinator,
pub storage_layout: crate::storage::StorageLayout,
pub extension_pairing_token: extension_server::SharedExtensionToken,
pub extension_frontend_ready: extension_server::SharedFrontendReady,
pub extension_server_port: extension_server::SharedServerPort,
@@ -3818,11 +3820,12 @@ fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) {
}
#[tauri::command]
fn get_platform_info() -> crate::ipc::PlatformInfo {
fn get_platform_info(state: tauri::State<'_, AppState>) -> crate::ipc::PlatformInfo {
crate::ipc::PlatformInfo {
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
target_triple: crate::platform::target_triple(),
portable: state.storage_layout.is_portable(),
}
}
@@ -4346,17 +4349,42 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String,
}
#[tauri::command]
fn set_keychain_password(id: String, password: String) -> Result<(), String> {
fn set_keychain_password(
state: tauri::State<'_, AppState>,
id: String,
password: String,
) -> Result<(), String> {
if state.storage_layout.is_portable()
&& id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID
{
return Ok(());
}
crate::db::set_keychain_password(&id, &password)
}
#[tauri::command]
fn get_keychain_password(id: String) -> Result<String, String> {
fn get_keychain_password(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<String, String> {
if state.storage_layout.is_portable()
&& id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID
{
return Err("portable pairing token is stored in portable settings".to_string());
}
crate::db::get_keychain_password(&id)
}
#[tauri::command]
fn delete_keychain_password(id: String) -> Result<(), String> {
fn delete_keychain_password(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
if state.storage_layout.is_portable()
&& id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID
{
return Ok(());
}
crate::db::delete_keychain_password(&id)
}
@@ -4399,17 +4427,23 @@ fn hydrate_extension_pairing_token(
});
}
// No token in the DB yet — generate one and save it so future launches
// find it without prompting.
// No token in the DB yet — generate one. Portable mode initializes the
// settings row immediately so the pairing secret travels with the folder;
// standard mode remains session-only until the user grants credential-store
// access when settings have not been persisted yet.
let generated = crate::db::generate_pairing_token();
crate::db::save_pairing_token_to_settings(&connection, &generated)?;
crate::db::save_pairing_token_to_settings(
&connection,
&generated,
app_state.storage_layout.is_portable(),
)?;
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = generated.clone();
}
Ok(PairingTokenHydration {
token: generated,
token_changed: false,
persistent: false,
persistent: app_state.storage_layout.is_portable(),
error: None,
})
}
@@ -4421,6 +4455,26 @@ fn grant_keychain_access(
) -> Result<PairingTokenHydration, String> {
let mut connection = database.lock()?;
if app_state.storage_layout.is_portable() {
let token = if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)?
{
existing
} else {
let generated = crate::db::generate_pairing_token();
crate::db::save_pairing_token_to_settings(&connection, &generated, true)?;
generated
};
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = token.clone();
}
return Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: true,
error: None,
});
}
// Explicitly force migration of any legacy token to the keychain.
// This is the ONLY code path that touches the OS keychain and it is
// reached exclusively through the frontend's "Grant Access" button,
@@ -4431,7 +4485,7 @@ fn grant_keychain_access(
Ok((token, token_changed)) => {
// Persist the token to the settings DB so future startups
// can read it without touching the keychain at all.
let _ = crate::db::save_pairing_token_to_settings(&connection, &token);
let _ = crate::db::save_pairing_token_to_settings(&connection, &token, false);
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = token.clone();
}
@@ -4475,6 +4529,11 @@ fn db_save_settings(
let connection = state.lock()?;
let existing = crate::db::load_settings(&connection)?;
let merged = crate::settings::preserve_scheduler_runtime_keys(existing.as_deref(), &data)?;
let merged = if state.is_portable() {
crate::settings::preserve_portable_pairing_token(existing.as_deref(), &merged)?
} else {
merged
};
crate::db::save_settings(&connection, &merged)?;
let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?;
if let Ok(mut cached) = app_state.scheduler_settings.write() {
@@ -4502,8 +4561,9 @@ fn db_replace_downloads(
state: tauri::State<'_, crate::db::DbState>,
data: String,
) -> Result<(), String> {
let portable = state.is_portable();
let mut connection = state.lock()?;
crate::db::replace_downloads(&mut connection, &data)
crate::db::replace_downloads(&mut connection, &data, portable)
}
#[tauri::command]
@@ -4531,8 +4591,11 @@ fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool {
}
async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, String> {
use tauri::Manager;
let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?;
let log_dir = app_handle
.state::<AppState>()
.storage_layout
.log_dir()
.to_path_buf();
let mut files = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
@@ -5563,6 +5626,20 @@ fn set_log_stream_active(active: bool) {
pub fn run() {
ensure_reqwest_crypto_provider();
let storage_mode = crate::storage::StorageMode::detect();
let setup_storage_mode = storage_mode.clone();
let log_target = match &storage_mode {
crate::storage::StorageMode::Standard => tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir { file_name: None },
),
crate::storage::StorageMode::Portable { root } => tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Folder {
path: root.join("data").join("logs"),
file_name: None,
},
),
};
let extension_pairing_token = Arc::new(RwLock::new(String::new()));
let server_pairing_token = extension_pairing_token.clone();
let extension_frontend_ready = Arc::new(AtomicBool::new(false));
@@ -5587,11 +5664,26 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init())
.manage(Aria2DaemonGuard::new())
.setup(move |app| {
#[cfg(target_os = "windows")]
if let Some(window) = app.get_webview_window("main") {
window
.set_decorations(false)
.map_err(|error| format!("failed to disable Windows native frame: {error}"))?;
let storage_layout = crate::storage::StorageLayout::resolve(
app.handle(),
setup_storage_mode.clone(),
)?;
let main_window_config = app
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.cloned()
.ok_or_else(|| "main window configuration is missing".to_string())?;
let mut main_window_builder = tauri::WebviewWindowBuilder::from_config(
app.handle(),
&main_window_config,
)
.map_err(|error| format!("failed to prepare main window: {error}"))?;
if storage_layout.is_portable() {
main_window_builder =
main_window_builder.data_directory(storage_layout.webview_dir().to_path_buf());
}
let mut sys = sysinfo::System::new_all();
@@ -5607,7 +5699,7 @@ pub fn run() {
build_main_tray(app.handle())
.map_err(|error| format!("failed to create tray menu: {error}"))?;
let database = crate::db::init(app.handle())
let database = crate::db::init(&storage_layout)
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
let initial_pairing_token = {
// Generate a temporary session token for the extension server on startup.
@@ -5671,6 +5763,7 @@ pub fn run() {
app.manage(AppState {
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
storage_layout,
extension_pairing_token,
extension_frontend_ready,
extension_server_port,
@@ -5683,6 +5776,20 @@ pub fn run() {
queue_manager,
});
// Build the window only after all command state is registered. This
// prevents the frontend from racing startup and invoking IPC before
// the database and portable storage layout are available.
main_window_builder
.build()
.map_err(|error| format!("failed to create main window: {error}"))?;
#[cfg(target_os = "windows")]
if let Some(window) = app.get_webview_window("main") {
window
.set_decorations(false)
.map_err(|error| format!("failed to disable Windows native frame: {error}"))?;
}
let deep_link_app = app.handle().clone();
#[cfg(target_os = "linux")]
if let Err(error) = app.deep_link().register_all() {
@@ -5970,9 +6077,7 @@ pub fn run() {
tauri_plugin_log::Builder::new()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
file_name: None,
}),
log_target,
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview)
.filter(|_| {
LOG_STREAM_ACTIVE.load(std::sync::atomic::Ordering::Acquire)
+72 -6
View File
@@ -77,6 +77,41 @@ pub fn preserve_scheduler_runtime_keys(
.map_err(|error| format!("failed to encode persisted settings: {error}"))
}
pub fn preserve_portable_pairing_token(
existing: Option<&str>,
incoming: &str,
) -> Result<String, String> {
let Some(existing) = existing else {
return Ok(incoming.to_string());
};
let existing_document = decode_document(&Value::String(existing.to_string()))?;
let existing_state = settings_state(&existing_document)?;
let Some(token) = existing_state
.get("extensionPairingToken")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
else {
return Ok(incoming.to_string());
};
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
let incoming_state = settings_state_mut(&mut incoming_document)?;
let incoming_token_present = incoming_state
.get("extensionPairingToken")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
if !incoming_token_present {
incoming_state.insert(
"extensionPairingToken".to_string(),
Value::String(token.to_string()),
);
}
serde_json::to_string(&incoming_document)
.map_err(|error| format!("failed to encode portable settings: {error}"))
}
fn decode_document(stored: &Value) -> Result<Value, String> {
match stored {
Value::String(text) => serde_json::from_str(text)
@@ -320,7 +355,9 @@ fn default_settings() -> PersistedSettings {
#[cfg(test)]
mod tests {
use super::{decode_stored_settings, preserve_scheduler_runtime_keys};
use super::{
decode_stored_settings, preserve_portable_pairing_token, preserve_scheduler_runtime_keys,
};
use serde_json::{json, Value};
#[test]
@@ -510,11 +547,12 @@ mod tests {
#[test]
fn ignores_legacy_extension_pairing_token_field() {
// Older versions persisted `extensionPairingToken` as plaintext inside
// the settings document. It now lives in the OS keychain and is no
// longer part of PersistedSettings. serde ignores the unknown field so
// existing installs decode without error; the plaintext value is
// simply dropped and a fresh token is minted by the frontend.
// Older standard installs persisted `extensionPairingToken` as
// plaintext inside the settings document. It now lives in the OS
// keychain and is no longer part of PersistedSettings; portable mode
// deliberately keeps its token in the portable settings document.
// serde ignores the unknown field so existing installs decode without
// error; standard-mode migration drops the plaintext value.
let stored = json!({
"state": {
"extensionPairingToken": "plaintext-leaked-secret",
@@ -527,4 +565,32 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 5);
}
#[test]
fn preserves_portable_pairing_token_when_startup_save_races() {
let existing = json!({
"state": {
"extensionPairingToken": "portable-token",
"maxConcurrentDownloads": 3
},
"version": 3
})
.to_string();
let incoming = json!({
"state": {
"extensionPairingToken": "",
"maxConcurrentDownloads": 5
},
"version": 3
})
.to_string();
let merged = preserve_portable_pairing_token(Some(&existing), &incoming).unwrap();
let document: Value = serde_json::from_str(&merged).unwrap();
assert_eq!(
document["state"]["extensionPairingToken"],
"portable-token"
);
assert_eq!(document["state"]["maxConcurrentDownloads"], 5);
}
}
+130
View File
@@ -0,0 +1,130 @@
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Runtime};
pub const PORTABLE_MARKER: &str = "portable.flag";
const PORTABLE_DATA_DIR: &str = "data";
const PORTABLE_LOG_DIR: &str = "logs";
const PORTABLE_WEBVIEW_DIR: &str = "webview";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageMode {
Standard,
Portable { root: PathBuf },
}
impl StorageMode {
pub fn detect() -> Self {
let Some(executable) = std::env::current_exe().ok() else {
return Self::Standard;
};
let Some(root) = executable.parent() else {
return Self::Standard;
};
if root.join(PORTABLE_MARKER).is_file() {
Self::Portable {
root: root.to_path_buf(),
}
} else {
Self::Standard
}
}
#[cfg(test)]
fn detect_from_root(root: &Path) -> Self {
if root.join(PORTABLE_MARKER).is_file() {
Self::Portable {
root: root.to_path_buf(),
}
} else {
Self::Standard
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StorageLayout {
mode: StorageMode,
data_dir: PathBuf,
log_dir: PathBuf,
webview_dir: PathBuf,
}
impl StorageLayout {
pub fn resolve<R: Runtime>(
app_handle: &AppHandle<R>,
mode: StorageMode,
) -> Result<Self, String> {
match mode {
StorageMode::Standard => Ok(Self {
mode: StorageMode::Standard,
data_dir: app_handle
.path()
.app_data_dir()
.map_err(|error| format!("failed to resolve app data directory: {error}"))?,
log_dir: app_handle
.path()
.app_log_dir()
.map_err(|error| format!("failed to resolve app log directory: {error}"))?,
webview_dir: app_handle.path().app_local_data_dir().map_err(|error| {
format!("failed to resolve app local data directory: {error}")
})?,
}),
StorageMode::Portable { root } => {
let data_dir = root.join(PORTABLE_DATA_DIR);
Ok(Self {
mode: StorageMode::Portable { root },
log_dir: data_dir.join(PORTABLE_LOG_DIR),
webview_dir: data_dir.join(PORTABLE_WEBVIEW_DIR),
data_dir,
})
}
}
}
pub fn is_portable(&self) -> bool {
matches!(self.mode, StorageMode::Portable { .. })
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn log_dir(&self) -> &Path {
&self.log_dir
}
pub fn webview_dir(&self) -> &Path {
&self.webview_dir
}
}
#[cfg(test)]
mod tests {
use super::{StorageMode, PORTABLE_MARKER};
use std::fs;
use tempfile::TempDir;
#[test]
fn marker_selects_portable_mode() {
let root = TempDir::new().unwrap();
fs::write(root.path().join(PORTABLE_MARKER), b"portable\n").unwrap();
assert_eq!(
StorageMode::detect_from_root(root.path()),
StorageMode::Portable {
root: root.path().to_path_buf()
}
);
}
#[test]
fn missing_marker_keeps_standard_mode() {
let root = TempDir::new().unwrap();
assert_eq!(
StorageMode::detect_from_root(root.path()),
StorageMode::Standard
);
}
}