Platform: Reset wipe, empty name, Android browse + backup-off, import as personas
Reset All Data: - Sentinel now written at the app-level data_dir instead of the active identity's subdir. On Android the subdir path was never checked at startup, so reset silently did nothing. - On detection, wipe EVERYTHING under the app data_dir: identity.key, itsgoin.db + WAL + SHM, blobs, all identity subdirs. Next launch is truly fresh — new network key, new posting key, no prior data. First-run name: - Display name is optional. Blank submits as anonymous. - First-run modal + profile overlay placeholder updated to say "Display name (optional)". Android file picker: - pick_file on Android now uses tauri-plugin-android-fs' show_open_file_dialog (Storage Access Framework OPEN_DOCUMENT). Read the picked URI's bytes, stage them in the app's private cache as a timestamped file, return the staged path so existing import_* code can read it as a regular filesystem path. - Zip filter passes application/zip + application/octet-stream (some file providers report the latter for .zip). Android auto-backup off: - AndroidManifest: allowBackup="false", fullBackupContent="false", dataExtractionRules pointing at new data_extraction_rules.xml - New data_extraction_rules.xml excludes all domains from both cloud-backup and device-transfer. Prior default (allowBackup=true) silently replicated identity.key to Google Drive for any user with cloud backup on — which effectively published the root secret to a third party without asking. Users who want off-device backup use Settings -> Export (explicit zip they control). Import as personas: - New import_as_personas function in core/import.rs + new import_as_personas_cmd Tauri IPC. - Reads identity.key from the bundle and adds it to posting_identities as a persona. Also reads posting_identities.json (v0.6+ bundles) and adds each entry. Dedupes by node_id. - Posts stay AS-AUTHORED — original post_id, original author, original signatures, original wrapped_key recipients. No re-encryption. Content encrypted to any of the imported keys becomes decryptable because we now hold the secrets. - Blobs, follows, profiles copied across. - If current device has <=1 posting identity (the fresh-install one) and the bundle brings more, auto-switch the default to the first imported persona. Covers first-run-then-import flow cleanly. Import wizard UI: - New default option: "Restore as personas" — posts keep original authors; source's keys become personas you can post as. - Old "Merge with decryption key" retained as "Consolidate under current default persona (requires source key)" for the case where a user intentionally abandons a persona. - "Public posts only" and "Add as separate identity" retained. deploy.sh made executable (chmod +x tracked). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a1db1ce7f
commit
7e1e1dd738
7 changed files with 365 additions and 21 deletions
|
|
@ -16,7 +16,10 @@
|
|||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.itsgoin_desktop"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Disable cloud backup and device-to-device transfer of app data.
|
||||
|
||||
The identity secret in identity.key grants full access to all of a user's
|
||||
private content (DMs, encrypted posts, persona keys). Silently replicating
|
||||
it to Google Drive / device-transfer without a conscious user action is not
|
||||
an acceptable default. Users who want backup can use in-app
|
||||
Settings -> Export, which produces a ZIP the user explicitly handles.
|
||||
|
||||
Android 12+ (API 31+) reads this file. Combined with allowBackup="false"
|
||||
and fullBackupContent="false" in AndroidManifest.xml for older Android.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
|
@ -299,7 +299,9 @@ async fn post_to_dto(
|
|||
}
|
||||
}
|
||||
|
||||
/// Decrypt a just-created post for immediate display.
|
||||
/// Decrypt a just-created post for immediate display. The post was authored
|
||||
/// by one of our held posting identities (default or a specific persona);
|
||||
/// look up that identity's secret to decrypt.
|
||||
async fn decrypt_just_created(
|
||||
node: &Node,
|
||||
post: &Post,
|
||||
|
|
@ -308,11 +310,15 @@ async fn decrypt_just_created(
|
|||
match vis {
|
||||
PostVisibility::Public => None,
|
||||
PostVisibility::Encrypted { recipients } => {
|
||||
let author_identity = {
|
||||
let s = node.storage.get().await;
|
||||
s.get_posting_identity(&post.author).ok().flatten()
|
||||
}?;
|
||||
itsgoin_core::crypto::decrypt_post(
|
||||
&post.content,
|
||||
&node.secret_seed_bytes(),
|
||||
&node.node_id,
|
||||
&node.node_id,
|
||||
&author_identity.secret_seed,
|
||||
&author_identity.node_id,
|
||||
&author_identity.node_id,
|
||||
recipients,
|
||||
)
|
||||
.ok()
|
||||
|
|
@ -1946,10 +1952,47 @@ async fn pick_file(app: tauri::AppHandle, title: String, filter_name: Option<Str
|
|||
let path = builder.blocking_pick_file();
|
||||
Ok(path.map(|p| p.to_string()))
|
||||
}
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// Android: SAF "open document" dialog. The dialog returns a content
|
||||
// URI, not a filesystem path, so we read the bytes via the plugin
|
||||
// and stage them in the app's private cache so existing import code
|
||||
// (which expects a path) can read the file normally.
|
||||
let _ = (title,);
|
||||
use tauri_plugin_android_fs::AndroidFsExt;
|
||||
let mime_types: Vec<&str> = match filter_ext.as_deref() {
|
||||
Some(exts) if exts.iter().any(|e| e == "zip") => vec!["application/zip", "application/octet-stream"],
|
||||
_ => vec!["*/*"],
|
||||
};
|
||||
let api = app.android_fs();
|
||||
let uris = api.show_open_file_dialog(None, &mime_types, false)
|
||||
.map_err(|e| format!("Open dialog failed: {}", e))?;
|
||||
let uri = match uris.into_iter().next() {
|
||||
Some(u) => u,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let data = api.read(&uri).map_err(|e| format!("Read failed: {}", e))?;
|
||||
// Stage in private cache so import_* can open it by path.
|
||||
let cache_dir = app.path().app_cache_dir()
|
||||
.map_err(|e| format!("no cache dir: {}", e))?;
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||
// Name includes a timestamp so repeated picks don't clobber.
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let filename = match filter_ext.as_deref() {
|
||||
Some(exts) if exts.iter().any(|e| e == "zip") => format!("import-{}.zip", stamp),
|
||||
_ => format!("import-{}", stamp),
|
||||
};
|
||||
let dest = cache_dir.join(filename);
|
||||
std::fs::write(&dest, &data).map_err(|e| format!("Stage write failed: {}", e))?;
|
||||
Ok(Some(dest.to_string_lossy().to_string()))
|
||||
}
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
let _ = (app, title, filter_name, filter_ext);
|
||||
Ok(None) // Mobile: file picker not supported via this command
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2202,7 +2245,14 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String>
|
|||
#[tauri::command]
|
||||
async fn reset_data(state: State<'_, AppNode>) -> Result<String, String> {
|
||||
let node = get_node(&state).await;
|
||||
let sentinel = node.data_dir.join(".reset");
|
||||
// Write the sentinel at the APP-level data_dir (parent of the active
|
||||
// identity's dir). The startup sentinel check runs at the same level.
|
||||
// Earlier versions wrote to node.data_dir which is the identity subdir,
|
||||
// making the check miss on Android.
|
||||
let app_data_dir = node.data_dir.parent()
|
||||
.ok_or_else(|| "no parent data dir".to_string())?
|
||||
.to_path_buf();
|
||||
let sentinel = app_data_dir.join(".reset");
|
||||
std::fs::write(&sentinel, b"reset").map_err(|e| e.to_string())?;
|
||||
Ok("Reset scheduled. Restart the app to apply.".to_string())
|
||||
}
|
||||
|
|
@ -2730,6 +2780,23 @@ async fn import_public_posts(
|
|||
Ok(result.message)
|
||||
}
|
||||
|
||||
/// Import a bundle as personas on the current identity. The bundle's posting
|
||||
/// keys become additional personas; imported content keeps its original author
|
||||
/// and encrypted content becomes decryptable because we now hold those keys.
|
||||
#[tauri::command]
|
||||
async fn import_as_personas_cmd(
|
||||
state: State<'_, AppNode>,
|
||||
zip_path: String,
|
||||
) -> Result<String, String> {
|
||||
let node = get_node(&state).await;
|
||||
let result = itsgoin_core::import::import_as_personas(
|
||||
std::path::Path::new(&zip_path),
|
||||
&node.storage,
|
||||
&node.blob_store,
|
||||
).await.map_err(|e| e.to_string())?;
|
||||
Ok(result.message)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_as_new_identity(
|
||||
state: State<'_, AppIdentity>,
|
||||
|
|
@ -2812,12 +2879,24 @@ pub fn run() {
|
|||
};
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
|
||||
// Check for reset sentinel from previous session
|
||||
// Check for reset sentinel from previous session. A "Reset All
|
||||
// Data" request wipes EVERYTHING under the app data dir so the
|
||||
// next launch starts truly fresh — new network key, new posting
|
||||
// key, no posts, no blobs, no identities.
|
||||
let sentinel = data_dir.join(".reset");
|
||||
if sentinel.exists() {
|
||||
info!("Reset sentinel found — clearing data");
|
||||
let _ = std::fs::remove_file(data_dir.join("itsgoin.db"));
|
||||
let _ = std::fs::remove_dir_all(data_dir.join("blobs"));
|
||||
info!("Reset sentinel found — wiping all app data");
|
||||
if let Ok(entries) = std::fs::read_dir(&data_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.ends_with(".reset") { continue; }
|
||||
if path.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&sentinel);
|
||||
}
|
||||
|
||||
|
|
@ -2992,6 +3071,7 @@ pub fn run() {
|
|||
import_summary,
|
||||
import_public_posts,
|
||||
import_as_new_identity,
|
||||
import_as_personas_cmd,
|
||||
import_merge_with_key,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue