Feed pagination, duplicate identity detection, pkarr leak fix, Android SAF
Feed pagination: - Cursor-based pagination: get_feed_page/get_all_posts_page (20 posts/page) - Batched engagement queries (3 bulk SQL queries instead of 4 per post) - IntersectionObserver for infinite scroll (sentinel at midpoint) - Viewport-based media loading (blobs only load when post enters view) - Pre-fetch next page immediately after current page renders Duplicate identity detection: - Anchor detects when a NodeId is already mesh-connected during initial exchange and sets duplicate_active flag in response - Client skips sync tasks when duplicate detected - Frontend shows red warning banner Privacy: - Fixed pkarr leak: clear_address_lookup() removes default dns.iroh.link publishing. Only mDNS (local network) discovery enabled. Android: - SAF integration via tauri-plugin-android-fs: exports open native "Save As" dialog so users can save to Downloads/Drive/etc. - Download/export paths use app data dir on Android (writable) - File picker gated behind desktop cfg (blocking_pick not on Android) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5e7eed9638
commit
288b53ffb1
12 changed files with 910 additions and 120 deletions
|
|
@ -26,3 +26,6 @@ open = "5"
|
|||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
notify-rust = "4"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
tauri-plugin-android-fs = "8"
|
||||
|
|
|
|||
|
|
@ -176,6 +176,18 @@
|
|||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-noop`",
|
||||
"type": "string",
|
||||
"const": "android-fs:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-noop`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the noop command.",
|
||||
"type": "string",
|
||||
"const": "android-fs:allow-noop",
|
||||
"markdownDescription": "Enables the noop command."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
|
@ -2144,6 +2156,72 @@
|
|||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -176,6 +176,18 @@
|
|||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-noop`",
|
||||
"type": "string",
|
||||
"const": "android-fs:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-noop`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the noop command.",
|
||||
"type": "string",
|
||||
"const": "android-fs:allow-noop",
|
||||
"markdownDescription": "Enables the noop command."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
|
@ -2144,6 +2156,72 @@
|
|||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ struct NodeInfoDto {
|
|||
connect_string: String,
|
||||
display_name: Option<String>,
|
||||
has_profile: bool,
|
||||
duplicate_detected: bool,
|
||||
anchors: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
@ -359,6 +360,7 @@ async fn get_node_info(state: State<'_, AppNode>) -> Result<NodeInfoDto, String>
|
|||
connect_string,
|
||||
display_name: profile.as_ref().map(|p| p.display_name.clone()),
|
||||
has_profile: profile.is_some(),
|
||||
duplicate_detected: node.network.duplicate_detected.load(std::sync::atomic::Ordering::Relaxed),
|
||||
anchors,
|
||||
})
|
||||
}
|
||||
|
|
@ -582,10 +584,9 @@ async fn save_and_open_blob(
|
|||
let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?;
|
||||
let safe_name = sanitize_download_filename(&filename);
|
||||
|
||||
// Save to Downloads
|
||||
let downloads = dirs::download_dir()
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
// Save to Downloads — use app cache dir on Android (no access to shared storage without SAF)
|
||||
let downloads = get_writable_download_dir(&node);
|
||||
std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?;
|
||||
let dest = downloads.join(&safe_name);
|
||||
tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
|
||||
|
||||
|
|
@ -595,6 +596,22 @@ async fn save_and_open_blob(
|
|||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Get a writable directory for downloads/exports.
|
||||
/// On desktop: ~/Downloads. On Android: app data dir + "exports".
|
||||
fn get_writable_download_dir(node: &Node) -> std::path::PathBuf {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
node.data_dir.join("exports")
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = node;
|
||||
dirs::download_dir()
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a blob to Downloads without opening it.
|
||||
#[tauri::command]
|
||||
async fn save_blob(
|
||||
|
|
@ -612,9 +629,8 @@ async fn save_blob(
|
|||
let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?;
|
||||
let safe_name = sanitize_download_filename(&filename);
|
||||
|
||||
let downloads = dirs::download_dir()
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
let downloads = get_writable_download_dir(&node);
|
||||
std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?;
|
||||
let dest = downloads.join(&safe_name);
|
||||
tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
|
||||
|
||||
|
|
@ -649,6 +665,141 @@ async fn get_feed(state: State<'_, AppNode>) -> Result<Vec<PostDto>, String> {
|
|||
Ok(dtos)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FeedPageDto {
|
||||
posts: Vec<PostDto>,
|
||||
has_more: bool,
|
||||
oldest_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_feed_page(
|
||||
state: State<'_, AppNode>,
|
||||
before_ms: Option<u64>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<FeedPageDto, String> {
|
||||
let node = get_node(&state).await;
|
||||
let page_size = limit.unwrap_or(20);
|
||||
// Fetch one extra to know if there are more
|
||||
let posts = node.get_feed_page(before_ms, page_size + 1).await.map_err(|e| e.to_string())?;
|
||||
let has_more = posts.len() > page_size;
|
||||
let page: Vec<_> = posts.into_iter().take(page_size).collect();
|
||||
let oldest_ms = page.last().map(|(_, p, _, _)| p.timestamp_ms);
|
||||
let dtos = post_to_dto_batch(&page, &node).await;
|
||||
Ok(FeedPageDto { posts: dtos, has_more, oldest_ms })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_all_posts_page(
|
||||
state: State<'_, AppNode>,
|
||||
before_ms: Option<u64>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<FeedPageDto, String> {
|
||||
let node = get_node(&state).await;
|
||||
let page_size = limit.unwrap_or(20);
|
||||
let posts = node.get_all_posts_page(before_ms, page_size + 1).await.map_err(|e| e.to_string())?;
|
||||
let has_more = posts.len() > page_size;
|
||||
let page: Vec<_> = posts.into_iter().take(page_size).collect();
|
||||
let oldest_ms = page.last().map(|(_, p, _, _)| p.timestamp_ms);
|
||||
let dtos = post_to_dto_batch(&page, &node).await;
|
||||
Ok(FeedPageDto { posts: dtos, has_more, oldest_ms })
|
||||
}
|
||||
|
||||
/// Batched DTO assembly: 3 bulk queries instead of 4 per post
|
||||
async fn post_to_dto_batch(
|
||||
posts: &[(itsgoin_core::types::PostId, itsgoin_core::types::Post, itsgoin_core::types::PostVisibility, Option<String>)],
|
||||
node: &Node,
|
||||
) -> Vec<PostDto> {
|
||||
use std::collections::HashMap;
|
||||
if posts.is_empty() { return vec![]; }
|
||||
|
||||
let post_ids: Vec<itsgoin_core::types::PostId> = posts.iter().map(|(id, _, _, _)| *id).collect();
|
||||
|
||||
// Batch queries — 3 queries total instead of 4 × N
|
||||
let (reaction_map, comment_map, intent_map) = {
|
||||
let storage = node.storage.get().await;
|
||||
let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default();
|
||||
let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
|
||||
let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
|
||||
(reactions, comments, intents)
|
||||
};
|
||||
|
||||
// Batch resolve display names
|
||||
let mut name_cache: HashMap<itsgoin_core::types::NodeId, Option<String>> = HashMap::new();
|
||||
|
||||
let mut dtos = Vec::with_capacity(posts.len());
|
||||
for (id, post, vis, decrypted) in posts {
|
||||
let is_me = post.author == node.node_id;
|
||||
|
||||
let author_name = if let Some(cached) = name_cache.get(&post.author) {
|
||||
cached.clone()
|
||||
} else {
|
||||
let name = match node.resolve_display_name(&post.author).await {
|
||||
Ok((name, _, _)) if !name.is_empty() => Some(name),
|
||||
_ => None,
|
||||
};
|
||||
name_cache.insert(post.author, name.clone());
|
||||
name
|
||||
};
|
||||
|
||||
let intent_kind = if let Some(intent_json) = intent_map.get(id) {
|
||||
match serde_json::from_str::<VisibilityIntent>(intent_json) {
|
||||
Ok(VisibilityIntent::Public) => "public",
|
||||
Ok(VisibilityIntent::Friends) => "friends",
|
||||
Ok(VisibilityIntent::Circle(_)) => "circle",
|
||||
Ok(VisibilityIntent::Direct(_)) => "direct",
|
||||
_ => "unknown",
|
||||
}
|
||||
} else {
|
||||
"unknown"
|
||||
}.to_string();
|
||||
|
||||
let (visibility, decrypted_content) = match vis {
|
||||
PostVisibility::Public => ("public".to_string(), None),
|
||||
PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted {
|
||||
Some(text) if is_me => ("encrypted".to_string(), Some(text.clone())),
|
||||
Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
|
||||
None => ("encrypted".to_string(), None),
|
||||
},
|
||||
};
|
||||
let recipients = match vis {
|
||||
PostVisibility::Encrypted { recipients } => {
|
||||
recipients.iter().map(|wk| hex::encode(wk.recipient)).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
let attachments = post.attachments.iter().map(|a| AttachmentDto {
|
||||
cid: hex::encode(a.cid),
|
||||
mime_type: a.mime_type.clone(),
|
||||
size_bytes: a.size_bytes,
|
||||
}).collect();
|
||||
|
||||
let reaction_counts = reaction_map.get(id).cloned().unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
|
||||
.collect();
|
||||
let comment_count = comment_map.get(id).copied().unwrap_or(0);
|
||||
|
||||
dtos.push(PostDto {
|
||||
id: hex::encode(id),
|
||||
author: hex::encode(post.author),
|
||||
author_name,
|
||||
content: post.content.clone(),
|
||||
timestamp_ms: post.timestamp_ms,
|
||||
is_me,
|
||||
visibility,
|
||||
intent_kind,
|
||||
decrypted_content,
|
||||
attachments,
|
||||
recipients,
|
||||
reaction_counts,
|
||||
comment_count,
|
||||
});
|
||||
}
|
||||
dtos
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_all_posts(state: State<'_, AppNode>) -> Result<Vec<PostDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
|
|
@ -2311,13 +2462,23 @@ 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)
|
||||
// Resolve output directory — on Android use app data dir, on desktop resolve relative to home
|
||||
let resolved_dir = {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = &output_dir;
|
||||
node.data_dir.join("exports")
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
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,
|
||||
|
|
@ -2336,6 +2497,37 @@ async fn export_data(
|
|||
paths.join(", ")))
|
||||
}
|
||||
|
||||
/// On Android: save a file from the app's internal storage to a user-chosen location via SAF.
|
||||
/// On desktop: no-op (files are already in ~/Downloads).
|
||||
#[tauri::command]
|
||||
async fn share_file(app: tauri::AppHandle, file_path: String, mime_type: String) -> Result<String, String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use tauri_plugin_android_fs::{AndroidFsExt, AndroidFs};
|
||||
let path = std::path::Path::new(&file_path);
|
||||
let filename = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "export.zip".to_string());
|
||||
let data = std::fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
let api = app.android_fs();
|
||||
let uri = api.show_save_file_dialog(None, &filename, Some(&mime_type))
|
||||
.map_err(|e| format!("Save dialog failed: {}", e))?;
|
||||
match uri {
|
||||
Some(uri) => {
|
||||
api.write(&uri, &data).map_err(|e| format!("Write failed: {}", e))?;
|
||||
Ok(format!("Saved to device"))
|
||||
}
|
||||
None => Ok("Cancelled".to_string()),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (app, mime_type);
|
||||
// Desktop: just return the path — file is already accessible
|
||||
Ok(file_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_summary(zip_path: String) -> Result<String, String> {
|
||||
let summary = itsgoin_core::import::read_import_summary(std::path::Path::new(&zip_path))
|
||||
|
|
@ -2416,6 +2608,12 @@ pub fn run() {
|
|||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin({
|
||||
#[cfg(target_os = "android")]
|
||||
{ tauri_plugin_android_fs::init() }
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{ tauri::plugin::Builder::<tauri::Wry>::new("android-fs-stub").build() }
|
||||
})
|
||||
.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.
|
||||
|
|
@ -2476,6 +2674,12 @@ pub fn run() {
|
|||
tracing::warn!(error = %e, "Background bootstrap failed");
|
||||
}
|
||||
|
||||
// Skip sync if duplicate identity detected
|
||||
if boot_node.network.duplicate_detected.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::warn!("Duplicate identity detected — skipping sync tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
// Start all background networking tasks
|
||||
boot_node.start_accept_loop();
|
||||
boot_node.start_pull_cycle(300);
|
||||
|
|
@ -2520,6 +2724,8 @@ pub fn run() {
|
|||
save_and_open_blob,
|
||||
save_blob,
|
||||
get_feed,
|
||||
get_feed_page,
|
||||
get_all_posts_page,
|
||||
get_all_posts,
|
||||
get_stats,
|
||||
connect_peer,
|
||||
|
|
@ -2596,6 +2802,7 @@ pub fn run() {
|
|||
import_identity_key,
|
||||
get_active_identity,
|
||||
export_data,
|
||||
share_file,
|
||||
import_summary,
|
||||
import_public_posts,
|
||||
import_as_new_identity,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue