First-run chooser, node shutdown on switch, file picker, export path fix

- First-run: show Start Fresh / Import chooser only when single auto-created
  identity with no profile (not on every boot without a display name).
- Identity switch: shut down old node's endpoint before starting new one.
  Fixes lockup after multiple switches (zombie background tasks).
- File picker: native Browse buttons on export (folder) and import (ZIP file)
  via tauri-plugin-dialog.
- Export path: resolve relative paths against home dir (was using process cwd).
- Lightbox close: only close on overlay/image click, not inner form content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-06 01:58:02 -04:00
parent a349d33422
commit ec731fdb4b
6 changed files with 171 additions and 15 deletions

67
Cargo.lock generated
View file

@ -2780,6 +2780,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tokio",
"tracing",
@ -4632,6 +4633,30 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@ -5598,6 +5623,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.11+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"

View file

@ -257,11 +257,10 @@ impl IdentityManager {
anyhow::bail!("Identity {} not found", &target_hex[..12]);
}
// Shutdown current node if running
// Shutdown current node before switching
if let Some(ref node) = self.active_node {
info!(old = hex::encode(node.node_id), new = target_hex, "Switching identity");
// Node::shutdown would go here once implemented
// For now, dropping the Arc triggers cleanup when last reference is released
info!(old = hex::encode(node.node_id), new = target_hex, "Switching identity — shutting down old node");
node.network.shutdown_ref().await;
}
self.active_node = None;
self.active_id = None;

View file

@ -2335,6 +2335,14 @@ impl Network {
Ok(())
}
/// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit.
pub async fn shutdown_ref(&self) {
if let Some(ref mapping) = self.upnp_mapping {
crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await;
}
self.endpoint.close().await;
}
/// Propagate an engagement diff to all downstream holders of a post (CDN tree).
/// Excludes the sender to avoid loops.
pub async fn propagate_engagement_diff(

View file

@ -24,4 +24,5 @@ base64 = "0.22"
dirs = "5"
open = "5"
tauri-plugin-notification = "2"
tauri-plugin-dialog = "2"
notify-rust = "4"

View file

@ -1643,6 +1643,25 @@ struct AddressInfoDto {
status: String, // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server"
}
#[tauri::command]
async fn pick_file(app: tauri::AppHandle, title: String, filter_name: Option<String>, filter_ext: Option<Vec<String>>) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let mut builder = app.dialog().file().set_title(&title);
if let (Some(name), Some(exts)) = (filter_name, filter_ext) {
let ext_refs: Vec<&str> = exts.iter().map(|s| s.as_str()).collect();
builder = builder.add_filter(&name, &ext_refs);
}
let path = builder.blocking_pick_file();
Ok(path.map(|p| p.to_string()))
}
#[tauri::command]
async fn pick_folder(app: tauri::AppHandle, title: String) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let path = app.dialog().file().set_title(&title).blocking_pick_folder();
Ok(path.map(|p| p.to_string()))
}
#[tauri::command]
async fn get_our_info(state: State<'_, AppNode>) -> Result<OurInfoDto, String> {
let node = get_node(&state).await;
@ -2276,13 +2295,21 @@ async fn export_data(
"everything" => itsgoin_core::export::ExportScope::Everything,
_ => return Err("Invalid scope".to_string()),
};
// Resolve relative paths against user's home directory
let resolved_dir = if std::path::Path::new(&output_dir).is_relative() {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(&output_dir)
} else {
std::path::PathBuf::from(&output_dir)
};
let result = itsgoin_core::export::export_data(
&node.data_dir,
&node.storage,
&node.blob_store,
&node.node_id,
export_scope,
std::path::Path::new(&output_dir),
&resolved_dir,
).await.map_err(|e| e.to_string())?;
let paths: Vec<String> = result.paths.iter()
@ -2372,6 +2399,7 @@ pub fn run() {
info!("Starting Tauri app");
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init())
.setup(move |app| {
// Desktop: store data next to the AppImage/executable so each copy
// gets its own identity. Mobile: use the standard app data dir.
@ -2407,15 +2435,17 @@ pub fn run() {
itsgoin_core::types::DeviceProfile::Desktop
};
let (node, identity_mgr) = tauri::async_runtime::block_on(async {
let mgr = IdentityManager::open(&data_dir, None, profile).await?;
let mut mgr = IdentityManager::open(&data_dir, None, profile).await?;
// If no identity exists yet, create one
let n = if let Some(node) = mgr.active_node() {
Arc::clone(node)
} else {
// No identities at all — this is a fresh install after migration
// The IdentityManager should have either migrated or the user needs to create one
anyhow::bail!("No identity available — create one first")
// No identity — create a temporary one so the app can start.
// Frontend detects this via get_node_info and shows the first-run chooser.
info!("No identity found — creating initial identity for first-run setup");
let node_id = mgr.create_identity("My Identity")?;
mgr.switch_identity(&node_id).await?;
Arc::clone(mgr.active_node().expect("just created"))
};
// Start background networking
@ -2502,6 +2532,8 @@ pub fn run() {
sync_from_peer,
get_network_summary,
get_our_info,
pick_file,
pick_folder,
get_activity_log,
trigger_rebalance,
request_referrals,

View file

@ -3372,7 +3372,10 @@ $('#export-btn').addEventListener('click', () => {
</div>
<div style="margin-bottom:0.75rem">
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
<input id="export-output-dir" type="text" value="Downloads" style="width:100%;margin-top:0.25rem;font-size:0.8rem" />
<div style="display:flex;gap:0.25rem;margin-top:0.25rem">
<input id="export-output-dir" type="text" value="Downloads" style="flex:1;font-size:0.8rem" />
<button class="btn btn-ghost btn-sm" id="export-browse">Browse</button>
</div>
<p style="font-size:0.65rem;color:#555;margin-top:0.2rem">Relative to your home directory, or absolute path</p>
</div>
<div style="display:flex;gap:0.5rem;justify-content:center">
@ -3406,6 +3409,12 @@ $('#export-btn').addEventListener('click', () => {
overlay.querySelector('#export-go').disabled = false;
}
});
overlay.querySelector('#export-browse').addEventListener('click', async () => {
try {
const path = await invoke('pick_folder', { title: 'Choose export folder' });
if (path) overlay.querySelector('#export-output-dir').value = path;
} catch (_) {}
});
overlay.querySelector('#export-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
@ -3418,8 +3427,11 @@ $('#import-btn').addEventListener('click', () => {
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Enter the path to an ItsGoin export ZIP file.</p>
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="width:100%;margin-bottom:0.75rem;font-size:0.8rem" />
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP file.</p>
<div style="display:flex;gap:0.25rem;margin-bottom:0.75rem">
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="flex:1;font-size:0.8rem" />
<button class="btn btn-ghost btn-sm" id="import-browse">Browse</button>
</div>
<div id="import-summary-box" style="display:none;text-align:left;background:#111;border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;font-size:0.75rem"></div>
<div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem">
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="add_identity" /> Add as new identity (requires key in export)</label>
@ -3438,6 +3450,13 @@ $('#import-btn').addEventListener('click', () => {
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#import-browse').addEventListener('click', async () => {
try {
const path = await invoke('pick_file', { title: 'Select export ZIP', filterName: 'ZIP files', filterExt: ['zip'] });
if (path) overlay.querySelector('#import-zip-path').value = path;
} catch (_) {}
});
overlay.querySelector('#import-preview').addEventListener('click', async () => {
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
if (!zipPath) { toast('Enter a ZIP path'); return; }
@ -3646,9 +3665,39 @@ async function init() {
}
}
const info = await loadNodeInfo();
// Show first-run chooser only if no profile AND only one identity (auto-created)
let isFirstRun = false;
if (info && !info.hasProfile) {
try {
const ids = await invoke('list_identities');
isFirstRun = ids.length <= 1;
} catch (_) {}
}
if (isFirstRun) {
// First-run chooser: start fresh or import
const chooser = document.createElement('div');
chooser.className = 'image-lightbox';
chooser.style.cursor = 'default';
chooser.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:2rem;max-width:360px;width:90%;text-align:center">
<h2 style="color:#7fdbca;margin:0 0 0.5rem">Welcome to ItsGoin</h2>
<p style="color:#888;font-size:0.85rem;margin-bottom:1.5rem">How would you like to get started?</p>
<div style="display:flex;flex-direction:column;gap:0.75rem">
<button id="first-run-new" class="btn btn-primary" style="padding:0.75rem">Start Fresh</button>
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import an Identity</button>
</div>
</div>`;
document.body.appendChild(chooser);
chooser.querySelector('#first-run-new').addEventListener('click', () => {
chooser.remove();
setupOverlay.classList.remove('hidden');
setupName.focus();
});
chooser.querySelector('#first-run-import').addEventListener('click', () => {
chooser.remove();
// Open the import wizard
document.getElementById('import-btn')?.click();
});
}
// Pre-load feed + messages from local DB (instant — no network needed)
await loadFeed(true).catch(() => {});