Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/communities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions src-tauri/src/legacy_communities.rs
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,
)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nonblocking: SQLITE_OPEN_READ_ONLY isn't filesystem read-only. if old Buzz left a localstorage.sqlite3-wal behind with no -shm (crash, or killed without a checkpoint), opening it creates localstorage.sqlite3-shm in old Buzz's storage. I reproduced that with a WAL db + -wal copied without its -shm, opened mode=ro, and the -shm appeared. 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 for immutable=1, which isn't safe if old Buzz is still running.

Copy link
Copy Markdown
Contributor Author

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.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nonblocking: read_dir(root) follows symlinks, so only entries under WebsiteData are link-checked. if WebsiteData or a parent is a symlink, the walk reads wherever it points. not a privilege issue (needs local fs control, and results still go through the identity filter and consent), but the docs say the reader "skips symlinks" (docs/communities.md:133). either check symlink_metadata(&root) up front or narrow the docs to "skips symlinks inside the storage folder".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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())?
}
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -550,6 +552,7 @@ fn commands<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -> bool + Sen
get_os_idle_seconds,
workspace_pick_folder,
repositories_folder_validate,
legacy_communities,
plugin_import_folder,
plugin_update_check,
plugin_update_preview,
Expand Down
5 changes: 5 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -69,6 +71,9 @@ export function App({ services }: { services: AppServices }) {
{services.identity ? (
<IdentitySetup identity={services.identity}>
<ConnectedApp services={services} />
{!launching && (
<LegacyCommunityImport communities={services.communities} />
)}
</IdentitySetup>
) : (
<ConnectedApp services={services} />
Expand Down
69 changes: 69 additions & 0 deletions src/features/communities/LegacyCommunityImport.tsx
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>
);
}
56 changes: 56 additions & 0 deletions src/features/communities/legacy-communities.test.ts
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 },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nonblocking: with the trim, nothing covers the identity filter anymore (pubkey !== viewer at legacy-communities.ts:105). deleting that line keeps this test green. that's the guard that keeps another identity's saved relays out, so IMO it's worth two more fixture entries here without adding a test: { relayUrl: "wss://other.example", pubkey: "b".repeat(64) } and { relayUrl: "wss://unattributed.example" }. the existing confirm assertion (["https://old.example"]) then proves both are dropped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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();
}
});
Loading
Loading