v0.5.0-beta: merge-with-key import, prior_author provenance, beta versioning
Merge-with-key: decrypt exported posts using original identity seed, re-create under current identity with prior_author in BlobHeader for provenance tracking. Download page restructured with stable (v0.4.4) + beta (v0.5.0-beta) sections. Version bumped across all crates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8ef32e6df6
commit
97dc83f9f1
13 changed files with 311 additions and 10 deletions
50
Cargo.lock
generated
50
Cargo.lock
generated
|
|
@ -90,6 +90,15 @@ version = "1.0.101"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayref"
|
name = "arrayref"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|
@ -974,6 +983,17 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
|
|
@ -2742,6 +2762,7 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -7537,12 +7558,41 @@ dependencies = [
|
||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"displaydoc",
|
||||||
|
"flate2",
|
||||||
|
"indexmap 2.13.0",
|
||||||
|
"memchr",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.19"
|
version = "1.0.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.10.0"
|
version = "5.10.0"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "itsgoin-cli"
|
name = "itsgoin-cli"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "itsgoin-core"
|
name = "itsgoin-core"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -6334,6 +6334,7 @@ impl ConnectionManager {
|
||||||
thread_splits: vec![],
|
thread_splits: vec![],
|
||||||
receipt_slots: vec![],
|
receipt_slots: vec![],
|
||||||
comment_slots: vec![],
|
comment_slots: vec![],
|
||||||
|
prior_author: None,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
header.reactions = reactions;
|
header.reactions = reactions;
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,181 @@ pub fn import_as_identity(
|
||||||
Ok(manifest.node_id)
|
Ok(manifest.node_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge posts from another identity into the current one using the original key for decryption.
|
||||||
|
/// Decrypts encrypted posts, creates new posts under the current identity, preserves timestamps.
|
||||||
|
/// BlobHeader gets `prior_author` set for provenance.
|
||||||
|
pub async fn merge_with_key(
|
||||||
|
zip_path: &Path,
|
||||||
|
original_key_hex: &str,
|
||||||
|
storage: &StoragePool,
|
||||||
|
blob_store: &BlobStore,
|
||||||
|
our_node_id: &NodeId,
|
||||||
|
_our_seed: &[u8; 32],
|
||||||
|
) -> anyhow::Result<ImportResult> {
|
||||||
|
// Derive the original identity from the provided key
|
||||||
|
let original_seed_bytes = hex::decode(original_key_hex)?;
|
||||||
|
let original_seed: [u8; 32] = original_seed_bytes.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("key must be 32 bytes (64 hex chars)"))?;
|
||||||
|
let original_secret_key = iroh::SecretKey::from_bytes(&original_seed);
|
||||||
|
let original_node_id: NodeId = *original_secret_key.public().as_bytes();
|
||||||
|
|
||||||
|
// Phase 1: Read and decrypt everything from ZIP synchronously
|
||||||
|
let parsed = {
|
||||||
|
let zip_path = zip_path.to_path_buf();
|
||||||
|
let our_nid = *our_node_id;
|
||||||
|
let orig_seed = original_seed;
|
||||||
|
let orig_nid = original_node_id;
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || -> anyhow::Result<ParsedImport> {
|
||||||
|
let file = std::fs::File::open(&zip_path)?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file)?;
|
||||||
|
|
||||||
|
let posts: Vec<ExportedPost> = {
|
||||||
|
let mut entry = archive.by_name("itsgoin-export/posts.json")?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
entry.read_to_string(&mut buf)?;
|
||||||
|
serde_json::from_str(&buf)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut result_posts = Vec::new();
|
||||||
|
let mut skipped = 0usize;
|
||||||
|
|
||||||
|
for ep in &posts {
|
||||||
|
let vis: PostVisibility = serde_json::from_str(&ep.visibility_json)
|
||||||
|
.unwrap_or(PostVisibility::Public);
|
||||||
|
let attachments: Vec<Attachment> = serde_json::from_str(&ep.attachments_json)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Decrypt content if encrypted
|
||||||
|
let plaintext = match &vis {
|
||||||
|
PostVisibility::Public => ep.content.clone(),
|
||||||
|
PostVisibility::Encrypted { recipients } => {
|
||||||
|
match crate::crypto::decrypt_post(
|
||||||
|
&ep.content, &orig_seed, &orig_nid, &orig_nid, recipients,
|
||||||
|
) {
|
||||||
|
Ok(Some(text)) => text,
|
||||||
|
Ok(None) => {
|
||||||
|
debug!(post = ep.id, "Not a recipient of this post — skipping");
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(post = ep.id, error = %e, "Failed to decrypt post — skipping");
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PostVisibility::GroupEncrypted { .. } => {
|
||||||
|
// Group decryption needs the group seed — skip for now
|
||||||
|
debug!(post = ep.id, "Group-encrypted post — skipping (group merge not yet supported)");
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new post under our identity
|
||||||
|
let new_post = Post {
|
||||||
|
author: our_nid,
|
||||||
|
content: plaintext,
|
||||||
|
attachments: attachments.clone(),
|
||||||
|
timestamp_ms: ep.timestamp_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read blob data from archive (may need decryption for encrypted posts)
|
||||||
|
let mut blob_data = Vec::new();
|
||||||
|
for att in &attachments {
|
||||||
|
let cid_hex = hex::encode(att.cid);
|
||||||
|
let blob_path = format!("itsgoin-export/blobs/{}", cid_hex);
|
||||||
|
if let Ok(mut blob_entry) = archive.by_name(&blob_path) {
|
||||||
|
let mut data = Vec::new();
|
||||||
|
blob_entry.read_to_end(&mut data)?;
|
||||||
|
|
||||||
|
// If the post was encrypted, blobs are also encrypted with the same CEK
|
||||||
|
if matches!(vis, PostVisibility::Encrypted { .. }) {
|
||||||
|
if let PostVisibility::Encrypted { ref recipients } = vis {
|
||||||
|
if let Ok(Some(cek)) = crate::crypto::unwrap_cek_for_recipient(
|
||||||
|
&orig_seed, &orig_nid, &orig_nid, recipients,
|
||||||
|
) {
|
||||||
|
if let Ok(decrypted) = crate::crypto::decrypt_bytes_with_cek(&data, &cek) {
|
||||||
|
data = decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blob_data.push((att.clone(), data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged posts go in as public (decrypted content, new author)
|
||||||
|
result_posts.push((new_post, PostVisibility::Public, blob_data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedImport { posts: result_posts, skipped })
|
||||||
|
}).await??
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 2: Store with prior_author provenance
|
||||||
|
let mut imported = 0usize;
|
||||||
|
let mut blobs_imported = 0usize;
|
||||||
|
|
||||||
|
for (new_post, _vis, blob_data) in &parsed.posts {
|
||||||
|
let new_id = compute_post_id(new_post);
|
||||||
|
|
||||||
|
let s = storage.get().await;
|
||||||
|
if s.get_post(&new_id).ok().flatten().is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s.store_post_with_visibility(&new_id, new_post, &PostVisibility::Public)?;
|
||||||
|
|
||||||
|
// Create BlobHeader with prior_author
|
||||||
|
let now = now_ms();
|
||||||
|
let header = crate::types::BlobHeader {
|
||||||
|
post_id: new_id,
|
||||||
|
author: *our_node_id,
|
||||||
|
reactions: vec![],
|
||||||
|
comments: vec![],
|
||||||
|
policy: crate::types::CommentPolicy::default(),
|
||||||
|
updated_at: now,
|
||||||
|
thread_splits: vec![],
|
||||||
|
receipt_slots: vec![],
|
||||||
|
comment_slots: vec![],
|
||||||
|
prior_author: Some(original_node_id),
|
||||||
|
};
|
||||||
|
let header_json = serde_json::to_string(&header).unwrap_or_default();
|
||||||
|
let _ = s.store_blob_header(&new_id, our_node_id, &header_json, now);
|
||||||
|
drop(s);
|
||||||
|
|
||||||
|
for (att, data) in blob_data {
|
||||||
|
if !blob_store.has(&att.cid) {
|
||||||
|
blob_store.store(&att.cid, data)?;
|
||||||
|
let s = storage.get().await;
|
||||||
|
let _ = s.record_blob(&att.cid, &new_id, our_node_id, data.len() as u64, &att.mime_type, att.size_bytes);
|
||||||
|
blobs_imported += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
imported, skipped = parsed.skipped, blobs = blobs_imported,
|
||||||
|
original = hex::encode(original_node_id),
|
||||||
|
"Merge with key complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ImportResult {
|
||||||
|
posts_imported: imported,
|
||||||
|
posts_skipped: parsed.skipped,
|
||||||
|
blobs_imported,
|
||||||
|
message: format!(
|
||||||
|
"Merged {} posts from {} ({} skipped), {} blobs",
|
||||||
|
imported, &hex::encode(original_node_id)[..12], parsed.skipped, blobs_imported
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn now_ms() -> u64 {
|
fn now_ms() -> u64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,10 @@ impl Node {
|
||||||
|
|
||||||
// ---- Identity export/import ----
|
// ---- Identity export/import ----
|
||||||
|
|
||||||
|
pub fn secret_seed(&self) -> [u8; 32] {
|
||||||
|
self.secret_seed
|
||||||
|
}
|
||||||
|
|
||||||
pub fn export_identity_hex(&self) -> anyhow::Result<String> {
|
pub fn export_identity_hex(&self) -> anyhow::Result<String> {
|
||||||
let key_path = self.data_dir.join("identity.key");
|
let key_path = self.data_dir.join("identity.key");
|
||||||
let key_bytes = std::fs::read(&key_path)?;
|
let key_bytes = std::fs::read(&key_path)?;
|
||||||
|
|
@ -753,6 +757,7 @@ impl Node {
|
||||||
thread_splits: vec![],
|
thread_splits: vec![],
|
||||||
receipt_slots,
|
receipt_slots,
|
||||||
comment_slots,
|
comment_slots,
|
||||||
|
prior_author: None,
|
||||||
};
|
};
|
||||||
let header_json = serde_json::to_string(&blob_header)?;
|
let header_json = serde_json::to_string(&blob_header)?;
|
||||||
storage.store_blob_header(&post_id, &self.node_id, &header_json, now)?;
|
storage.store_blob_header(&post_id, &self.node_id, &header_json, now)?;
|
||||||
|
|
@ -3912,6 +3917,7 @@ impl Node {
|
||||||
thread_splits: vec![],
|
thread_splits: vec![],
|
||||||
receipt_slots: vec![],
|
receipt_slots: vec![],
|
||||||
comment_slots: vec![],
|
comment_slots: vec![],
|
||||||
|
prior_author: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
crate::types::BlobHeader {
|
crate::types::BlobHeader {
|
||||||
|
|
@ -3924,6 +3930,7 @@ impl Node {
|
||||||
thread_splits: vec![],
|
thread_splits: vec![],
|
||||||
receipt_slots: vec![],
|
receipt_slots: vec![],
|
||||||
comment_slots: vec![],
|
comment_slots: vec![],
|
||||||
|
prior_author: None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -4001,6 +4008,7 @@ impl Node {
|
||||||
thread_splits: vec![],
|
thread_splits: vec![],
|
||||||
receipt_slots: vec![],
|
receipt_slots: vec![],
|
||||||
comment_slots: vec![],
|
comment_slots: vec![],
|
||||||
|
prior_author: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
crate::types::BlobHeader {
|
crate::types::BlobHeader {
|
||||||
|
|
@ -4013,6 +4021,7 @@ impl Node {
|
||||||
thread_splits: vec![],
|
thread_splits: vec![],
|
||||||
receipt_slots: vec![],
|
receipt_slots: vec![],
|
||||||
comment_slots: vec![],
|
comment_slots: vec![],
|
||||||
|
prior_author: None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -859,6 +859,9 @@ pub struct BlobHeader {
|
||||||
/// Encrypted comment slots (each 256 bytes) — only for encrypted posts
|
/// Encrypted comment slots (each 256 bytes) — only for encrypted posts
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub comment_slots: Vec<Vec<u8>>,
|
pub comment_slots: Vec<Vec<u8>>,
|
||||||
|
/// Original author NodeId before post merge (set during cross-identity import)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prior_author: Option<NodeId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receipt slot state byte values
|
/// Receipt slot state byte values
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "itsgoin-desktop"
|
name = "itsgoin-desktop"
|
||||||
version = "0.4.4"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|
|
||||||
|
|
@ -2216,6 +2216,24 @@ async fn import_as_new_identity(
|
||||||
Ok(format!("Identity {} imported — switch to it in Settings", &node_id[..12]))
|
Ok(format!("Identity {} imported — switch to it in Settings", &node_id[..12]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn import_merge_with_key(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
zip_path: String,
|
||||||
|
key_hex: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let result = itsgoin_core::import::merge_with_key(
|
||||||
|
std::path::Path::new(&zip_path),
|
||||||
|
&key_hex,
|
||||||
|
&node.storage,
|
||||||
|
&node.blob_store,
|
||||||
|
&node.node_id,
|
||||||
|
&node.secret_seed(),
|
||||||
|
).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(result.message)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
|
|
@ -2408,6 +2426,7 @@ pub fn run() {
|
||||||
import_summary,
|
import_summary,
|
||||||
import_public_posts,
|
import_public_posts,
|
||||||
import_as_new_identity,
|
import_as_new_identity,
|
||||||
|
import_merge_with_key,
|
||||||
])
|
])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"productName": "itsgoin",
|
"productName": "itsgoin",
|
||||||
"version": "0.4.4",
|
"version": "0.5.0",
|
||||||
"identifier": "com.itsgoin.app",
|
"identifier": "com.itsgoin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../../frontend",
|
"frontendDist": "../../frontend",
|
||||||
|
|
|
||||||
|
|
@ -3379,6 +3379,10 @@ $('#import-btn').addEventListener('click', () => {
|
||||||
<div id="import-action-box" style="display:none;text-align:left;margin-bottom:0.75rem">
|
<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>
|
<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>
|
||||||
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="import_posts" checked /> Import public posts into current identity</label>
|
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="import_posts" checked /> Import public posts into current identity</label>
|
||||||
|
<label class="checkbox-label" style="font-size:0.8rem"><input type="radio" name="import-action" value="merge_key" /> Merge with decryption key (decrypt + re-create)</label>
|
||||||
|
<div id="merge-key-input" style="display:none;margin-top:0.4rem">
|
||||||
|
<input id="import-merge-key" type="text" placeholder="Original identity key (64 hex chars)" maxlength="64" style="width:100%;font-family:monospace;font-size:0.7rem" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:0.5rem;justify-content:center">
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
||||||
<button class="btn btn-ghost btn-sm" id="import-preview">Preview</button>
|
<button class="btn btn-ghost btn-sm" id="import-preview">Preview</button>
|
||||||
|
|
@ -3418,6 +3422,14 @@ $('#import-btn').addEventListener('click', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show/hide merge key input based on selected action
|
||||||
|
overlay.querySelectorAll('input[name="import-action"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
const mergeInput = overlay.querySelector('#merge-key-input');
|
||||||
|
mergeInput.style.display = radio.value === 'merge_key' && radio.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
overlay.querySelector('#import-go').addEventListener('click', async () => {
|
overlay.querySelector('#import-go').addEventListener('click', async () => {
|
||||||
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
|
const zipPath = overlay.querySelector('#import-zip-path').value.trim();
|
||||||
const action = overlay.querySelector('input[name="import-action"]:checked')?.value;
|
const action = overlay.querySelector('input[name="import-action"]:checked')?.value;
|
||||||
|
|
@ -3429,6 +3441,10 @@ $('#import-btn').addEventListener('click', () => {
|
||||||
let result;
|
let result;
|
||||||
if (action === 'add_identity') {
|
if (action === 'add_identity') {
|
||||||
result = await invoke('import_as_new_identity', { zipPath });
|
result = await invoke('import_as_new_identity', { zipPath });
|
||||||
|
} else if (action === 'merge_key') {
|
||||||
|
const keyHex = overlay.querySelector('#import-merge-key').value.trim();
|
||||||
|
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); overlay.querySelector('#import-go').disabled = false; return; }
|
||||||
|
result = await invoke('import_merge_with_key', { zipPath, keyHex });
|
||||||
} else {
|
} else {
|
||||||
result = await invoke('import_public_posts', { zipPath });
|
result = await invoke('import_public_posts', { zipPath });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
|
|
||||||
<div class="container wide">
|
<div class="container wide">
|
||||||
<section>
|
<section>
|
||||||
<span class="version-badge">v0.4.4 — 2026-03-31</span>
|
<span class="version-badge">v0.5.0-beta — 2026-04-05</span>
|
||||||
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.5rem;">Design Document</h1>
|
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.5rem;">Design Document</h1>
|
||||||
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem — with full implementation detail. This document is versioned; each update records what changed.</p>
|
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem — with full implementation detail. This document is versioned; each update records what changed.</p>
|
||||||
<div class="card" style="margin-top: 1rem;">
|
<div class="card" style="margin-top: 1rem;">
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,33 @@
|
||||||
<section>
|
<section>
|
||||||
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
|
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
|
||||||
<p>Available for Android and Linux. Free and open source.</p>
|
<p>Available for Android and Linux. Free and open source.</p>
|
||||||
|
|
||||||
|
<h2 style="margin-top: 2rem;">Stable Release</h2>
|
||||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.4.4 — March 23, 2026</p>
|
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.4.4 — March 23, 2026</p>
|
||||||
|
|
||||||
<div class="downloads">
|
<div class="downloads">
|
||||||
<a href="itsgoin-0.4.4.apk" class="download-btn btn-android">
|
<a href="itsgoin-0.4.4.apk" class="download-btn btn-android">
|
||||||
Android APK
|
Android APK
|
||||||
<span class="sub">v0.4.4</span>
|
<span class="sub">v0.4.4 stable</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="itsgoin_0.4.4_amd64.AppImage" class="download-btn btn-linux">
|
<a href="itsgoin_0.4.4_amd64.AppImage" class="download-btn btn-linux">
|
||||||
Linux AppImage
|
Linux AppImage
|
||||||
<span class="sub">v0.4.4</span>
|
<span class="sub">v0.4.4 stable</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top: 2rem;">Beta Release</h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.5.0-beta — April 5, 2026</p>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.85rem;">Multi-identity, export/import, post merge with decryption key. May contain bugs — stable release recommended for daily use.</p>
|
||||||
|
|
||||||
|
<div class="downloads">
|
||||||
|
<a href="itsgoin-0.5.0-beta.apk" class="download-btn btn-android" style="border-color: var(--accent);">
|
||||||
|
Android APK
|
||||||
|
<span class="sub">v0.5.0-beta</span>
|
||||||
|
</a>
|
||||||
|
<a href="itsgoin_0.5.0-beta_amd64.AppImage" class="download-btn btn-linux" style="border-color: var(--accent);">
|
||||||
|
Linux AppImage
|
||||||
|
<span class="sub">v0.5.0-beta</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -46,7 +63,7 @@
|
||||||
<h3 style="color: var(--accent);">Android</h3>
|
<h3 style="color: var(--accent);">Android</h3>
|
||||||
<ol class="steps">
|
<ol class="steps">
|
||||||
<li><strong>Download the APK</strong> — Tap the button above. Your browser may warn that this type of file can be harmful — tap <strong>Download anyway</strong>.</li>
|
<li><strong>Download the APK</strong> — Tap the button above. Your browser may warn that this type of file can be harmful — tap <strong>Download anyway</strong>.</li>
|
||||||
<li><strong>Open the file</strong> — When the download finishes, tap the notification or find <code>itsgoin-0.4.4.apk</code> in your Downloads folder and tap it.</li>
|
<li><strong>Open the file</strong> — When the download finishes, tap the notification or find the APK in your Downloads folder and tap it.</li>
|
||||||
<li><strong>Allow installation</strong> — Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li>
|
<li><strong>Allow installation</strong> — Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li>
|
||||||
<li><strong>Launch the app</strong> — Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
|
<li><strong>Launch the app</strong> — Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
@ -59,8 +76,8 @@
|
||||||
<h3 style="color: var(--green);">Linux (AppImage)</h3>
|
<h3 style="color: var(--green);">Linux (AppImage)</h3>
|
||||||
<ol class="steps">
|
<ol class="steps">
|
||||||
<li><strong>Download the AppImage</strong> — Click the button above to download.</li>
|
<li><strong>Download the AppImage</strong> — Click the button above to download.</li>
|
||||||
<li><strong>Make it executable</strong> — Open a terminal and run:<br><code>chmod +x itsgoin_0.4.4_amd64.AppImage</code></li>
|
<li><strong>Make it executable</strong> — Open a terminal and run:<br><code>chmod +x itsgoin_*.AppImage</code></li>
|
||||||
<li><strong>Run it</strong> — Double-click the file, or from the terminal:<br><code>./itsgoin_0.4.4_amd64.AppImage</code></li>
|
<li><strong>Run it</strong> — Double-click the file, or from the terminal:<br><code>./itsgoin_*.AppImage</code></li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="note">
|
<div class="note">
|
||||||
<strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora).
|
<strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora).
|
||||||
|
|
@ -71,6 +88,17 @@
|
||||||
<section>
|
<section>
|
||||||
<h2>Changelog</h2>
|
<h2>Changelog</h2>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
|
<div class="changelog-date">v0.5.0-beta — April 5, 2026</div>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Multi-identity</strong> — Create, switch, and delete multiple identities on one device. Per-identity data directories with hot-swap switching (~3s). Legacy flat layout auto-migrates on first launch.</li>
|
||||||
|
<li><strong>ZIP export</strong> — Export your data as a ZIP with scope selection: identity only (key backup), posts only (safe to share), posts + identity (full migration), or everything (complete backup including follows and settings). Auto-chunks at 4GB.</li>
|
||||||
|
<li><strong>Public post import</strong> — Import public posts from another identity's export ZIP into your current identity. Creates new PostIds under your author, preserving original timestamps.</li>
|
||||||
|
<li><strong>Merge with decryption key</strong> — Import encrypted posts from another identity by providing the original identity key. Decrypts posts and blobs, re-creates them under your current identity. BlobHeader tracks prior_author for provenance.</li>
|
||||||
|
<li><strong>Import as new identity</strong> — Import a ZIP containing an identity key as a new identity. Creates the identity subdir; switch to it to access the data.</li>
|
||||||
|
<li><strong>Hole punch address filtering</strong> — Relay introductions now filter by address family (IPv4/IPv6) and exclude LAN-only addresses for remote peers.</li>
|
||||||
|
<li><strong>Sync pipeline fixes</strong> — Per-peer sync resets last_sync_ms before pulling (fixes stale sync). ManifestPush now fetches blobs after discovering new posts.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="changelog-date">v0.4.4 — March 23, 2026</div>
|
<div class="changelog-date">v0.4.4 — March 23, 2026</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>UI overhaul</strong> — Sticky header with tabs as one floating block on desktop. Fixed header + bottom nav on mobile. Full-width dark header with 15px fade gradient into content.</li>
|
<li><strong>UI overhaul</strong> — Sticky header with tabs as one floating block on desktop. Fixed header + bottom nav on mobile. Full-width dark header with 15px fade gradient into content.</li>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue