Repository navigation
feat(communities): add relays saved by the previous buzz app #1786
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
29b7fbd
2b82e3a
8333a2f
672e0ec
10fc185
fa1f3e4
617f4bb
29a2eac
3ddb185
afe0beb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String>, | ||
| } | ||
|
|
||
| #[cfg(target_os = "macos")] | ||
| fn root() -> Option<PathBuf> { | ||
| 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<PathBuf> { | ||
| None | ||
| } | ||
|
|
||
| fn databases(entries: std::fs::ReadDir, found: &mut Vec<PathBuf>) -> 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<String> { | ||
| let (pairs, rest) = bytes.as_chunks::<2>(); | ||
| if rest.is_empty() && pairs.iter().any(|pair| pair[1] == 0) { | ||
| let units: Vec<u16> = 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<Vec<Saved>> { | ||
| let conn = Connection::open_with_flags( | ||
| path, | ||
| OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, | ||
| )?; | ||
| let value: Option<Vec<u8>> = 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::<Vec<serde_json::Value>>(&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<Vec<Saved>, String> { | ||
| let mut found = Vec::new(); | ||
| let traversal = match std::fs::read_dir(root) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 nonblocking:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Confirmed from the traversal code: symlinks are skipped inside the storage folder; read_dir follows a linked root or parent. The PR description now states that boundary. Deferring the optional source/docs wording change and preserving existing behavior. Leaving this thread open. |
||
| 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<Vec<Saved>, String> { | ||
| tauri::async_runtime::spawn_blocking(|| root().map_or(Ok(vec![]), |root| read_all(&root))) | ||
| .await | ||
| .map_err(|error| error.to_string())? | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<boolean>((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 ( | ||
| <AlertDialog | ||
| title="Import communities from old Buzz?" | ||
| description="Add these saved communities to your list and account. This may restore communities you previously removed." | ||
| onClose={() => respond(false)} | ||
| actions={ | ||
| <> | ||
| <Button type="button" onClick={() => respond(false)}> | ||
| Skip import | ||
| </Button> | ||
| <Button | ||
| type="button" | ||
| variant="prominent" | ||
| onClick={() => respond(true)} | ||
| > | ||
| Import communities | ||
| </Button> | ||
| </> | ||
| } | ||
| > | ||
| <ul | ||
| aria-label="Communities to import" | ||
| // biome-ignore lint/a11y/noNoninteractiveTabindex: The bounded list must support keyboard scrolling. | ||
| tabIndex={0} | ||
| className="max-h-48 overflow-y-auto break-words text-body-sm" | ||
| > | ||
| {request.origins.map((origin) => ( | ||
| <li key={origin}>{origin}</li> | ||
| ))} | ||
| </ul> | ||
| </AlertDialog> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string>(); | ||
| 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<boolean>((resolve) => { | ||
| answer = resolve; | ||
| }); | ||
| const confirm = vi.fn(() => pending); | ||
| const read = vi.fn(async () => [ | ||
| { relayUrl: "wss://old.example", pubkey: viewer }, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 nonblocking: with the trim, nothing covers the identity filter anymore (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Deferred under the author's latest explicit request to remove almost all migration tests. The production identity filter is unchanged; the single retained test covers consent and the completion marker. The PR description explicitly records the reduced regression coverage. Leaving this optional request open. |
||
| ]); | ||
| 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(); | ||
| } | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 nonblocking:
SQLITE_OPEN_READ_ONLYisn't filesystem read-only. if old Buzz left alocalstorage.sqlite3-walbehind with no-shm(crash, or killed without a checkpoint), opening it createslocalstorage.sqlite3-shmin old Buzz's storage. I reproduced that with a WAL db +-walcopied without its-shm, openedmode=ro, and the-shmappeared. harmless in practice, but it contradicts "Never written" on line 3. I'd just reword the module comment ("opened read-only; SQLite may create its WAL index") rather than reach forimmutable=1, which isn't safe if old Buzz is still running.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 Recorded the SQLite WAL-index caveat in the PR description: database read-only mode does not guarantee zero filesystem side effects. Deferring the optional module-comment clarification in this test-reduction follow-up; no immutable-mode change is planned. Leaving this thread open.