diff --git a/docs/communities.md b/docs/communities.md index 896c96c9a1..6bdec43da8 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -115,6 +115,26 @@ empty state there. Try drafting in A, switching to B, then returning to A. Selected channels, drafts and reading offsets are partitioned by the canonical community origin and viewer; channel IDs alone are not sufficient keys. +## Import from the previous desktop app + +After native startup, Buzz offers the previous desktop app's saved communities +for the signed-in identity. The dialog lists eligible secure origins and warns +that importing may restore communities previously removed. Nothing is added or +queued for account sync until **Import communities** is chosen. **Skip import**, +Close, or Escape declines the import and keeps that decision across launches. + +Every nonempty import requires consent. An empty local record or account list +cannot establish an untouched account: acknowledged removals leave no history, +and pending or unsuccessful sync cannot establish it either. Existing memberships +and settings stay unchanged. Memberships and removals observed while the read or +consent is pending are checked again before adding anything. + +The native reader opens the previous app's macOS WebKit storage read-only and +skips symlinks. An absent root reads as empty; traversal or database errors leave +the import retryable on the next launch. A read or save that does not complete +does not record approval; disposing a pending prompt does not record a decline. +Other platforms have no supported legacy storage source. + ## Known communities The device record also carries which saved destinations the account service diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 05737b10b8..759d11e2c7 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -114,6 +114,7 @@ fn main() { "get_os_idle_seconds", "workspace_pick_folder", "repositories_folder_validate", + "legacy_communities", "plugin_import_folder", "plugin_update_check", "plugin_update_preview", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 018db8def2..35b56db814 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -76,6 +76,7 @@ "allow-get-os-idle-seconds", "allow-workspace-pick-folder", "allow-repositories-folder-validate", + "allow-legacy-communities", "allow-plugin-import-folder", "allow-plugin-import-git", "allow-plugin-import-install", diff --git a/src-tauri/src/legacy_communities.rs b/src-tauri/src/legacy_communities.rs new file mode 100644 index 0000000000..7ccf045d15 --- /dev/null +++ b/src-tauri/src/legacy_communities.rs @@ -0,0 +1,133 @@ +//! The relays installed old Buzz saved, read from its WebKit localStorage so the +//! app can add them once. Old Buzz kept its community list only there, outside +//! its data folder. Never written. +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +const COMMUNITIES_KEY: &str = "buzz-communities"; + +/// One saved community: its relay and the identity it was saved for, when +/// old Buzz recorded one. +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Saved { + relay_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pubkey: Option, +} + +#[cfg(target_os = "macos")] +fn root() -> Option { + let identifier = buzz_agent_controller::LegacySource::Installed.app_directory(); + Some( + dirs::home_dir()? + .join("Library/WebKit") + .join(identifier) + .join("WebsiteData"), + ) +} +#[cfg(not(target_os = "macos"))] +fn root() -> Option { + None +} + +fn databases(entries: std::fs::ReadDir, found: &mut Vec) -> std::io::Result<()> { + for entry in entries { + let entry = entry?; + let path = entry.path(); + // `file_type` does not follow symlinks, so a link cannot loop the walk. + let kind = entry.file_type()?; + if kind.is_dir() { + databases(std::fs::read_dir(&path)?, found)?; + } else if kind.is_file() && entry.file_name() == "localstorage.sqlite3" { + found.push(path); + } + } + Ok(()) +} + +/// WebKit stores values as UTF-16LE; UTF-8 is the fallback old Buzz's own reader used. +fn decode(bytes: &[u8]) -> Option { + let (pairs, rest) = bytes.as_chunks::<2>(); + if rest.is_empty() && pairs.iter().any(|pair| pair[1] == 0) { + let units: Vec = pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect(); + if let Ok(value) = String::from_utf16(&units) { + return Some(value.trim_end_matches('\0').to_owned()); + } + } + String::from_utf8(bytes.to_vec()).ok() +} + +/// Each saved community with a `relayUrl`; a list that is not one reads as empty. +fn read(path: &Path) -> rusqlite::Result> { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let value: Option> = conn + .query_row( + "SELECT value FROM ItemTable WHERE key = ?1", + [COMMUNITIES_KEY], + |row| row.get(0), + ) + .optional()?; + let list = value + .and_then(|bytes| decode(&bytes)) + .and_then(|text| serde_json::from_str::>(&text).ok()) + .unwrap_or_default(); + Ok(list + .iter() + .filter_map(|entry| { + Some(Saved { + relay_url: entry.get("relayUrl")?.as_str()?.to_owned(), + pubkey: entry + .get("pubkey") + .and_then(|pubkey| pubkey.as_str()) + .map(str::to_owned), + }) + }) + .collect()) +} + +/// Every saved community once, in saved order. Missing storage reads as none; +/// a traversal or database error fails the whole read so the caller retries. +fn read_all(root: &Path) -> Result, String> { + let mut found = Vec::new(); + let traversal = match std::fs::read_dir(root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + entries => entries, + }; + traversal + .and_then(|entries| databases(entries, &mut found)) + .map_err(|error| { + eprintln!( + "buzz-desktop: legacy communities: {}: {error}", + root.display() + ); + "Could not read old Buzz's communities".to_string() + })?; + let mut communities = Vec::new(); + for path in found { + let saved = read(&path).map_err(|error| { + eprintln!( + "buzz-desktop: legacy communities: {}: {error}", + path.display() + ); + "Could not read old Buzz's communities".to_string() + })?; + for community in saved { + if !communities.contains(&community) { + communities.push(community); + } + } + } + Ok(communities) +} + +#[tauri::command] +pub(crate) async fn legacy_communities() -> Result, String> { + tauri::async_runtime::spawn_blocking(|| root().map_or(Ok(vec![]), |root| read_all(&root))) + .await + .map_err(|error| error.to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64aeea4f1f..93ab6f81c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,6 +50,7 @@ use nsec_backup_choice::{nsec_backup_choice, nsec_backup_set_choice}; mod image_clipboard; mod jev; mod keep_awake; +mod legacy_communities; #[cfg(any(not(target_os = "macos"), test))] mod microphone; @@ -59,6 +60,7 @@ mod repositories_folder; mod window_controls; mod window_state; mod workspace_folder; +use legacy_communities::legacy_communities; use os_idle::get_os_idle_seconds; use repositories_folder::repositories_folder_validate; mod relay; @@ -550,6 +552,7 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen get_os_idle_seconds, workspace_pick_folder, repositories_folder_validate, + legacy_communities, plugin_import_folder, plugin_update_check, plugin_update_preview, diff --git a/src/app/App.tsx b/src/app/App.tsx index f1f5e90484..bd76e7cb08 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -46,10 +46,12 @@ import { PanelLaunchers } from "./shell/PanelLaunchers"; import { PanelCard } from "../features/panels/PanelCard"; import { communityDestination } from "../features/communities/destination"; import { CommunityLanding } from "../features/communities/CommunityLanding"; +import { LegacyCommunityImport } from "../features/communities/LegacyCommunityImport"; import { launchVisible, setLaunchReady, subscribeLaunch } from "./launch"; export function App({ services }: { services: AppServices }) { const identity = services.identity; + const launching = useSyncExternalStore(subscribeLaunch, launchVisible); useEffect(() => { if (!identity) return; const update = () => { @@ -69,6 +71,9 @@ export function App({ services }: { services: AppServices }) { {services.identity ? ( + {!launching && ( + + )} ) : ( diff --git a/src/features/communities/LegacyCommunityImport.tsx b/src/features/communities/LegacyCommunityImport.tsx new file mode 100644 index 0000000000..dbc4e063c0 --- /dev/null +++ b/src/features/communities/LegacyCommunityImport.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; +import { Button } from "../../shared/design-system/ui/Button"; +import { importLegacyCommunities } from "./legacy-communities"; +import type { Communities } from "./service"; + +/** Old storage cannot distinguish a removed community from a new one. */ +export function LegacyCommunityImport({ + communities, +}: { + communities: Communities; +}) { + const [request, setRequest] = useState<{ + origins: string[]; + resolve(approved: boolean): void; + }>(); + useEffect(() => { + let answer: ((approved: boolean) => void) | undefined; + const dispose = importLegacyCommunities( + communities, + (origins) => + new Promise((resolve) => { + answer = resolve; + setRequest({ origins, resolve }); + }), + ); + return () => { + dispose(); + answer?.(false); + }; + }, [communities]); + if (!request) return null; + const respond = (approved: boolean) => { + setRequest(undefined); + request.resolve(approved); + }; + return ( + respond(false)} + actions={ + <> + + + + } + > +
    + {request.origins.map((origin) => ( +
  • {origin}
  • + ))} +
+
+ ); +} diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts new file mode 100644 index 0000000000..183a270d9f --- /dev/null +++ b/src/features/communities/legacy-communities.test.ts @@ -0,0 +1,56 @@ +import { Context } from "@deepseek-ai/cordis"; +import { expect, it, vi } from "vitest"; +import { createCommunities } from "./service"; +import { importLegacyCommunities } from "./legacy-communities"; +import { flush } from "../relay/testing"; + +it("imports once and only after consent", async () => { + const viewer = "a".repeat(64); + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }); + const root = new Context(); + const communities = createCommunities( + root, + false, + undefined, + "", + undefined, + Promise.resolve(viewer), + ); + let answer!: (approved: boolean) => void; + const pending = new Promise((resolve) => { + answer = resolve; + }); + const confirm = vi.fn(() => pending); + const read = vi.fn(async () => [ + { relayUrl: "wss://old.example", pubkey: viewer }, + ]); + const dispose = importLegacyCommunities(communities, confirm, read); + try { + await flush(); + expect(confirm).toHaveBeenCalledWith(["https://old.example"]); + expect(communities.snapshot().memberships).toEqual([]); + expect(storage.size).toBe(0); + answer(true); + await flush(); + expect(communities.snapshot().memberships).toEqual([ + { id: "https://old.example", name: "old.example" }, + ]); + expect(communities.snapshot().sync.outbox).toEqual([ + { url: "wss://old.example", removed: false }, + ]); + expect(storage.get(`buzz-legacy-communities.v1:${viewer}`)).toBe("1"); + dispose(); + importLegacyCommunities(communities, confirm, read)(); + await flush(); + expect(read).toHaveBeenCalledTimes(1); + } finally { + dispose(); + answer(false); + await root.fiber.dispose(); + vi.unstubAllGlobals(); + } +}); diff --git a/src/features/communities/legacy-communities.ts b/src/features/communities/legacy-communities.ts new file mode 100644 index 0000000000..fb0f81af93 --- /dev/null +++ b/src/features/communities/legacy-communities.ts @@ -0,0 +1,141 @@ +import { invoke } from "@tauri-apps/api/core"; +import { communityDestination, relayAddress } from "./destination"; +import { enqueue } from "./known-communities"; +import type { Communities } from "./service"; + +/** A community installed old Buzz saved, and the identity it saved it for. */ +export type LegacyCommunity = { relayUrl: string; pubkey?: string }; + +const importedKey = (viewer: string) => `buzz-legacy-communities.v1:${viewer}`; +const imported = "1"; + +/** The removal addresses a pending import recorded for this viewer. */ +function pendingRemovals(viewer: string): string[] { + try { + const value: unknown = JSON.parse( + localStorage.getItem(importedKey(viewer)) ?? "[]", + ); + return Array.isArray(value) + ? value.filter((url): url is string => typeof url === "string") + : []; + } catch { + return []; + } +} + +/** Offers the communities installed old Buzz saved for explicit import once, + * through the same write a known-community list uses: saved without a + * session or join, so a relay that is unreachable fails only when opened, as + * any saved community does. Only the communities old Buzz recorded for this + * viewer's identity are taken; one saved for another identity, or that + * records none, is not. Existing memberships stay as they are; a relay + * already saved, not a secure origin, or saved or queued for removal at any + * point while this ran is skipped, and each added one is queued for upload + * as a join would be. Account state cannot prove there were no acknowledged + * removals, so every nonempty import requires consent, even with an empty + * device record or pending sync. A per-viewer marker, written after declining + * or once the list is saved, + * keeps a later removal from being undone; until then it holds the removals + * seen, so a failed read or save tries again on the next launch without + * restoring them. The returned disposer also abandons a read still in + * flight. */ +export function importLegacyCommunities( + communities: Pick, + confirm: (origins: string[]) => Promise, + read = () => invoke("legacy_communities"), +) { + let disposed = false; + const attempted = new Set(); + // Every membership each viewer held and removal it queued since this + // started, or before a launch whose import did not finish, so one left + // before or while the read was pending stays gone once its removal is + // acknowledged. Removals not yet written are retried on each observation. + const seen = new Map< + string, + { held: Set; removed: Set; saved: boolean } + >(); + function observe() { + const { viewer, memberships, sync } = communities.snapshot(); + if (!viewer) + return { + held: new Set(), + removed: new Set(), + saved: true, + }; + const known = seen.get(viewer) ?? { + held: new Set(), + removed: new Set(pendingRemovals(viewer)), + saved: true, + }; + seen.set(viewer, known); + const before = known.removed.size; + for (const { id } of memberships) known.held.add(id); + for (const { url, removed } of sync.outbox) + if (removed) known.removed.add(url); + if (known.removed.size > before) known.saved = false; + if (!known.saved) { + try { + if (localStorage.getItem(importedKey(viewer)) !== imported) + localStorage.setItem( + importedKey(viewer), + JSON.stringify([...known.removed]), + ); + known.saved = true; + } catch (error) { + console.warn("Couldn't save old Buzz's import progress", error); + } + } + return known; + } + async function run() { + observe(); + const { status, viewer } = communities.snapshot(); + if (disposed || status !== "ready" || !viewer || attempted.has(viewer)) + return; + attempted.add(viewer); + try { + if (localStorage.getItem(importedKey(viewer)) === imported) return; + const saved = await read(); + const ready = communities.snapshot(); + if (disposed || ready.status !== "ready" || ready.viewer !== viewer) + return; + const { held, removed } = observe(); + const candidates = new Set(); + for (const { relayUrl, pubkey } of saved) { + if (pubkey !== viewer) continue; + let id: string; + try { + id = communityDestination(relayUrl).id; + } catch { + continue; + } + const address = relayAddress(id); + if (held.has(id) || removed.has(address)) continue; + candidates.add(id); + } + const approved = candidates.size ? await confirm([...candidates]) : true; + const current = communities.snapshot(); + if (disposed || current.status !== "ready" || current.viewer !== viewer) + return; + const latest = observe(); + const add = approved + ? [...candidates].filter( + (id) => + !latest.held.has(id) && !latest.removed.has(relayAddress(id)), + ) + : []; + let sync = current.sync; + for (const id of add) sync = enqueue(sync, relayAddress(id), false); + if (add.length) await communities.applySync(sync, add); + localStorage.setItem(importedKey(viewer), imported); + } catch (error) { + console.warn("Couldn't add old Buzz's communities", error); + } + } + const unsubscribe = communities.subscribe(() => void run()); + void run(); + return () => { + disposed = true; + unsubscribe(); + }; +}