mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
fix(desktop): migrate Sprout workspace storage (#1016)
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
46
desktop/src-tauri/Cargo.lock
generated
46
desktop/src-tauri/Cargo.lock
generated
@@ -877,6 +877,7 @@ dependencies = [
|
||||
"reqwest 0.13.4",
|
||||
"rodio",
|
||||
"rubato",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -2333,6 +2334,18 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -3062,6 +3075,15 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapify"
|
||||
version = "0.2.0"
|
||||
@@ -4185,6 +4207,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -7275,6 +7307,20 @@ dependencies = [
|
||||
"windowfunctions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
|
||||
@@ -79,6 +79,7 @@ png = "0.18"
|
||||
zip = "8"
|
||||
sherpa-onnx = "1.12"
|
||||
regex = "1"
|
||||
rusqlite = "0.37"
|
||||
axum = "0.8"
|
||||
rodio = "0.22"
|
||||
earshot = "1.0"
|
||||
|
||||
289
desktop/src-tauri/src/commands/legacy_storage.rs
Normal file
289
desktop/src-tauri/src/commands/legacy_storage.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde::Serialize;
|
||||
|
||||
const BUZZ_RELEASE_IDENTIFIER_PREFIX: &str = "xyz.block.buzz.app";
|
||||
const SPROUT_RELEASE_IDENTIFIER: &str = "xyz.block.sprout.app";
|
||||
const BUZZ_DEV_IDENTIFIER_PREFIX: &str = "xyz.block.buzz.app.dev";
|
||||
const SPROUT_DEV_IDENTIFIER_PREFIX: &str = "xyz.block.sprout.app.dev";
|
||||
|
||||
const SPROUT_WORKSPACES_KEY: &str = "sprout-workspaces";
|
||||
const SPROUT_ACTIVE_WORKSPACE_KEY: &str = "sprout-active-workspace-id";
|
||||
const SPROUT_ONBOARDING_COMPLETE_PREFIX: &str = "sprout-onboarding-complete.v1:";
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LegacyWorkspaceStorage {
|
||||
workspaces: Option<String>,
|
||||
active_workspace_id: Option<String>,
|
||||
onboarding_completions: Vec<LegacyOnboardingCompletion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LegacyOnboardingCompletion {
|
||||
pubkey: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
fn legacy_identifier(current_identifier: &str) -> Option<String> {
|
||||
if current_identifier.starts_with(BUZZ_DEV_IDENTIFIER_PREFIX) {
|
||||
Some(current_identifier.replacen(
|
||||
BUZZ_DEV_IDENTIFIER_PREFIX,
|
||||
SPROUT_DEV_IDENTIFIER_PREFIX,
|
||||
1,
|
||||
))
|
||||
} else if current_identifier.starts_with(BUZZ_RELEASE_IDENTIFIER_PREFIX) {
|
||||
Some(current_identifier.replacen(
|
||||
BUZZ_RELEASE_IDENTIFIER_PREFIX,
|
||||
SPROUT_RELEASE_IDENTIFIER,
|
||||
1,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn legacy_webkit_data_root(identifier: &str) -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| {
|
||||
home.join("Library")
|
||||
.join("WebKit")
|
||||
.join(identifier)
|
||||
.join("WebsiteData")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn legacy_webkit_data_root(_identifier: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn collect_local_storage_databases(root: &Path, databases: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_local_storage_databases(&path, databases);
|
||||
} else if path.file_name().and_then(|name| name.to_str()) == Some("localstorage.sqlite3") {
|
||||
databases.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_webkit_local_storage_value(bytes: &[u8]) -> Option<String> {
|
||||
if bytes.is_empty() {
|
||||
return Some(String::new());
|
||||
}
|
||||
|
||||
if bytes.len() % 2 == 0 {
|
||||
let has_utf16_ascii_shape = bytes.chunks_exact(2).any(|chunk| chunk[1] == 0);
|
||||
if has_utf16_ascii_shape {
|
||||
let utf16: Vec<u16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
||||
.collect();
|
||||
if let Ok(value) = String::from_utf16(&utf16) {
|
||||
return Some(value.trim_end_matches('\0').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8(bytes.to_vec()).ok()
|
||||
}
|
||||
|
||||
fn read_legacy_workspace_storage_db(path: &Path) -> Result<LegacyWorkspaceStorage, String> {
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| format!("open legacy localStorage db: {e}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT key, value FROM ItemTable \
|
||||
WHERE key = ?1 OR key = ?2 OR key LIKE ?3",
|
||||
)
|
||||
.map_err(|e| format!("prepare legacy localStorage query: {e}"))?;
|
||||
let mut rows = stmt
|
||||
.query([
|
||||
SPROUT_WORKSPACES_KEY,
|
||||
SPROUT_ACTIVE_WORKSPACE_KEY,
|
||||
&format!("{SPROUT_ONBOARDING_COMPLETE_PREFIX}%"),
|
||||
])
|
||||
.map_err(|e| format!("query legacy localStorage: {e}"))?;
|
||||
|
||||
let mut result = LegacyWorkspaceStorage::default();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|e| format!("read legacy localStorage row: {e}"))?
|
||||
{
|
||||
let key: String = row
|
||||
.get(0)
|
||||
.map_err(|e| format!("read legacy localStorage key: {e}"))?;
|
||||
let value_bytes: Vec<u8> = row
|
||||
.get(1)
|
||||
.map_err(|e| format!("read legacy localStorage value: {e}"))?;
|
||||
let Some(value) = decode_webkit_local_storage_value(&value_bytes) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if key == SPROUT_WORKSPACES_KEY {
|
||||
result.workspaces = Some(value);
|
||||
} else if key == SPROUT_ACTIVE_WORKSPACE_KEY {
|
||||
result.active_workspace_id = Some(value);
|
||||
} else if let Some(pubkey) = key.strip_prefix(SPROUT_ONBOARDING_COMPLETE_PREFIX) {
|
||||
result
|
||||
.onboarding_completions
|
||||
.push(LegacyOnboardingCompletion {
|
||||
pubkey: pubkey.to_string(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn merge_legacy_workspace_storage(
|
||||
target: &mut LegacyWorkspaceStorage,
|
||||
source: LegacyWorkspaceStorage,
|
||||
) {
|
||||
if target.workspaces.is_none() {
|
||||
target.workspaces = source.workspaces;
|
||||
}
|
||||
if target.active_workspace_id.is_none() {
|
||||
target.active_workspace_id = source.active_workspace_id;
|
||||
}
|
||||
target
|
||||
.onboarding_completions
|
||||
.extend(source.onboarding_completions);
|
||||
}
|
||||
|
||||
/// Return workspace-scoped localStorage values from the legacy Sprout WebKit
|
||||
/// data directory so the frontend can seed Buzz localStorage before first
|
||||
/// render. This is separate from `migrate_legacy_app_data_dir`: Tauri app data
|
||||
/// migration copies files such as `identity.key`, but WebKit localStorage lives
|
||||
/// under `~/Library/WebKit/<identifier>/...` on macOS and is not included in the
|
||||
/// app data directory.
|
||||
#[tauri::command]
|
||||
pub fn get_legacy_workspace_storage(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<LegacyWorkspaceStorage, String> {
|
||||
let Some(identifier) = legacy_identifier(&app.config().identifier) else {
|
||||
return Ok(LegacyWorkspaceStorage::default());
|
||||
};
|
||||
let Some(root) = legacy_webkit_data_root(&identifier) else {
|
||||
return Ok(LegacyWorkspaceStorage::default());
|
||||
};
|
||||
if !root.exists() {
|
||||
return Ok(LegacyWorkspaceStorage::default());
|
||||
}
|
||||
|
||||
let mut databases = Vec::new();
|
||||
collect_local_storage_databases(&root, &mut databases);
|
||||
|
||||
let mut result = LegacyWorkspaceStorage::default();
|
||||
for database in databases {
|
||||
match read_legacy_workspace_storage_db(&database) {
|
||||
Ok(storage) => merge_legacy_workspace_storage(&mut result, storage),
|
||||
Err(error) => eprintln!(
|
||||
"buzz-desktop: legacy-local-storage-migration: {}: {error}",
|
||||
database.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_identifier_maps_release_identifier() {
|
||||
assert_eq!(
|
||||
legacy_identifier("xyz.block.buzz.app"),
|
||||
Some("xyz.block.sprout.app".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_identifier_maps_dev_worktree_identifier() {
|
||||
assert_eq!(
|
||||
legacy_identifier("xyz.block.buzz.app.dev.my-branch"),
|
||||
Some("xyz.block.sprout.app.dev.my-branch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_webkit_local_storage_value_reads_utf16le() {
|
||||
let bytes: Vec<u8> = "true".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
assert_eq!(
|
||||
decode_webkit_local_storage_value(&bytes).as_deref(),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_webkit_local_storage_value_reads_utf8_fallback() {
|
||||
assert_eq!(
|
||||
decode_webkit_local_storage_value(b"plain utf8").as_deref(),
|
||||
Some("plain utf8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_legacy_workspace_storage_db_reads_workspace_keys() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("localstorage.sqlite3");
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB NOT NULL ON CONFLICT FAIL)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fn utf16le(value: &str) -> Vec<u8> {
|
||||
value.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
|
||||
(
|
||||
SPROUT_WORKSPACES_KEY,
|
||||
utf16le("[{\"relayUrl\":\"wss://relay.example.com\"}]"),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
|
||||
(SPROUT_ACTIVE_WORKSPACE_KEY, utf16le("workspace-1")),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
|
||||
(
|
||||
format!("{SPROUT_ONBOARDING_COMPLETE_PREFIX}abc123"),
|
||||
utf16le("true"),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let storage = read_legacy_workspace_storage_db(&path).unwrap();
|
||||
assert_eq!(
|
||||
storage.workspaces.as_deref(),
|
||||
Some("[{\"relayUrl\":\"wss://relay.example.com\"}]")
|
||||
);
|
||||
assert_eq!(storage.active_workspace_id.as_deref(), Some("workspace-1"));
|
||||
assert_eq!(storage.onboarding_completions.len(), 1);
|
||||
assert_eq!(storage.onboarding_completions[0].pubkey, "abc123");
|
||||
assert_eq!(storage.onboarding_completions[0].value, "true");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod engrams;
|
||||
mod export_util;
|
||||
mod identity;
|
||||
mod identity_archive;
|
||||
mod legacy_storage;
|
||||
mod media;
|
||||
mod media_download;
|
||||
mod media_transcode;
|
||||
@@ -37,6 +38,7 @@ pub use dms::*;
|
||||
pub use engrams::*;
|
||||
pub use identity::*;
|
||||
pub use identity_archive::*;
|
||||
pub use legacy_storage::*;
|
||||
pub use media::*;
|
||||
pub use media_download::*;
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
|
||||
@@ -690,6 +690,7 @@ pub fn run() {
|
||||
search_users,
|
||||
get_presence,
|
||||
get_default_relay_url,
|
||||
get_legacy_workspace_storage,
|
||||
is_shared_identity,
|
||||
get_relay_ws_url,
|
||||
get_relay_http_url,
|
||||
|
||||
153
desktop/src/features/workspaces/legacyWorkspaceStorage.test.mjs
Normal file
153
desktop/src/features/workspaces/legacyWorkspaceStorage.test.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { applyLegacyWorkspaceStorage } from "./legacyWorkspaceStorage.ts";
|
||||
|
||||
function createMemoryStorage(initial = {}) {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem(key) {
|
||||
return values.has(key) ? values.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
values.delete(key);
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
},
|
||||
key(index) {
|
||||
return Array.from(values.keys())[index] ?? null;
|
||||
},
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const legacyWorkspaces = JSON.stringify([
|
||||
{
|
||||
id: "legacy-workspace",
|
||||
name: "Existing relay",
|
||||
relayUrl: "wss://relay.example.com",
|
||||
addedAt: "2026-06-12T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const currentWorkspaces = JSON.stringify([
|
||||
{
|
||||
id: "current-workspace",
|
||||
name: "Current relay",
|
||||
relayUrl: "wss://current.example.com",
|
||||
addedAt: "2026-06-12T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const localhostWorkspaces = JSON.stringify([
|
||||
{
|
||||
id: "local-workspace",
|
||||
name: "Local Dev",
|
||||
relayUrl: "ws://localhost:3000",
|
||||
addedAt: "2026-06-12T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
test("applyLegacyWorkspaceStorage seeds missing workspaces and active workspace", () => {
|
||||
const storage = createMemoryStorage();
|
||||
|
||||
applyLegacyWorkspaceStorage(
|
||||
{
|
||||
workspaces: legacyWorkspaces,
|
||||
activeWorkspaceId: "legacy-workspace",
|
||||
onboardingCompletions: [],
|
||||
},
|
||||
storage,
|
||||
);
|
||||
|
||||
assert.equal(storage.getItem("buzz-workspaces"), legacyWorkspaces);
|
||||
assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy-workspace");
|
||||
});
|
||||
|
||||
test("applyLegacyWorkspaceStorage preserves existing non-local Buzz workspaces", () => {
|
||||
const storage = createMemoryStorage({
|
||||
"buzz-workspaces": currentWorkspaces,
|
||||
"buzz-active-workspace-id": "current-workspace",
|
||||
});
|
||||
|
||||
applyLegacyWorkspaceStorage(
|
||||
{
|
||||
workspaces: legacyWorkspaces,
|
||||
activeWorkspaceId: "legacy-workspace",
|
||||
onboardingCompletions: [],
|
||||
},
|
||||
storage,
|
||||
);
|
||||
|
||||
assert.equal(storage.getItem("buzz-workspaces"), currentWorkspaces);
|
||||
assert.equal(
|
||||
storage.getItem("buzz-active-workspace-id"),
|
||||
"current-workspace",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyLegacyWorkspaceStorage replaces broken localhost first-run workspace", () => {
|
||||
const storage = createMemoryStorage({
|
||||
"buzz-workspaces": localhostWorkspaces,
|
||||
"buzz-active-workspace-id": "local-workspace",
|
||||
});
|
||||
|
||||
applyLegacyWorkspaceStorage(
|
||||
{
|
||||
workspaces: legacyWorkspaces,
|
||||
activeWorkspaceId: "legacy-workspace",
|
||||
onboardingCompletions: [],
|
||||
},
|
||||
storage,
|
||||
);
|
||||
|
||||
assert.equal(storage.getItem("buzz-workspaces"), legacyWorkspaces);
|
||||
assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy-workspace");
|
||||
});
|
||||
|
||||
test("applyLegacyWorkspaceStorage treats trailing-slash localhost as broken", () => {
|
||||
const storage = createMemoryStorage({
|
||||
"buzz-workspaces": JSON.stringify([
|
||||
{
|
||||
id: "local-workspace",
|
||||
name: "Local Dev",
|
||||
relayUrl: "ws://localhost:3000/",
|
||||
addedAt: "2026-06-12T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
"buzz-active-workspace-id": "local-workspace",
|
||||
});
|
||||
|
||||
applyLegacyWorkspaceStorage(
|
||||
{
|
||||
workspaces: legacyWorkspaces,
|
||||
activeWorkspaceId: "legacy-workspace",
|
||||
onboardingCompletions: [],
|
||||
},
|
||||
storage,
|
||||
);
|
||||
|
||||
assert.equal(storage.getItem("buzz-workspaces"), legacyWorkspaces);
|
||||
assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy-workspace");
|
||||
});
|
||||
|
||||
test("applyLegacyWorkspaceStorage migrates onboarding completion keys", () => {
|
||||
const storage = createMemoryStorage();
|
||||
|
||||
applyLegacyWorkspaceStorage(
|
||||
{
|
||||
workspaces: null,
|
||||
activeWorkspaceId: null,
|
||||
onboardingCompletions: [{ pubkey: "abc123", value: "true" }],
|
||||
},
|
||||
storage,
|
||||
);
|
||||
|
||||
assert.equal(storage.getItem("buzz-onboarding-complete.v1:abc123"), "true");
|
||||
});
|
||||
138
desktop/src/features/workspaces/legacyWorkspaceStorage.ts
Normal file
138
desktop/src/features/workspaces/legacyWorkspaceStorage.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
const BUZZ_WORKSPACES_KEY = "buzz-workspaces";
|
||||
const BUZZ_ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id";
|
||||
const BUZZ_ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX =
|
||||
"buzz-onboarding-complete.v1:";
|
||||
const LOCAL_DEV_RELAY_URLS = new Set([
|
||||
"ws://localhost:3000",
|
||||
"ws://127.0.0.1:3000",
|
||||
]);
|
||||
|
||||
type LegacyWorkspaceStorageSnapshot = {
|
||||
workspaces: string | null;
|
||||
activeWorkspaceId: string | null;
|
||||
onboardingCompletions: Array<{
|
||||
pubkey: string;
|
||||
value: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type StoredWorkspace = {
|
||||
relayUrl?: unknown;
|
||||
};
|
||||
|
||||
function parseWorkspaceList(raw: string | null): StoredWorkspace[] | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as StoredWorkspace[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelayUrl(relayUrl: string) {
|
||||
return relayUrl.trim().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function hasOnlyLocalDevWorkspace(raw: string | null): boolean {
|
||||
const workspaces = parseWorkspaceList(raw);
|
||||
return (
|
||||
workspaces?.length === 1 &&
|
||||
typeof workspaces[0]?.relayUrl === "string" &&
|
||||
LOCAL_DEV_RELAY_URLS.has(normalizeRelayUrl(workspaces[0].relayUrl))
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonLocalCurrentWorkspaces(raw: string | null): boolean {
|
||||
const workspaces = parseWorkspaceList(raw);
|
||||
return (
|
||||
workspaces !== null &&
|
||||
workspaces.length > 0 &&
|
||||
!hasOnlyLocalDevWorkspace(raw)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldWriteLegacyWorkspaces({
|
||||
currentWorkspacesRaw,
|
||||
legacyWorkspacesRaw,
|
||||
}: {
|
||||
currentWorkspacesRaw: string | null;
|
||||
legacyWorkspacesRaw: string | null;
|
||||
}) {
|
||||
const legacyWorkspaces = parseWorkspaceList(legacyWorkspacesRaw);
|
||||
if (!legacyWorkspaces || legacyWorkspaces.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !hasNonLocalCurrentWorkspaces(currentWorkspacesRaw);
|
||||
}
|
||||
|
||||
export function applyLegacyWorkspaceStorage(
|
||||
legacyStorage: LegacyWorkspaceStorageSnapshot,
|
||||
storage: Storage = window.localStorage,
|
||||
): void {
|
||||
const currentWorkspacesRaw = storage.getItem(BUZZ_WORKSPACES_KEY);
|
||||
const shouldWriteWorkspaces = shouldWriteLegacyWorkspaces({
|
||||
currentWorkspacesRaw,
|
||||
legacyWorkspacesRaw: legacyStorage.workspaces,
|
||||
});
|
||||
|
||||
if (shouldWriteWorkspaces && legacyStorage.workspaces) {
|
||||
storage.setItem(BUZZ_WORKSPACES_KEY, legacyStorage.workspaces);
|
||||
}
|
||||
|
||||
const currentActiveWorkspaceId = storage.getItem(BUZZ_ACTIVE_WORKSPACE_KEY);
|
||||
if (
|
||||
legacyStorage.activeWorkspaceId &&
|
||||
(!currentActiveWorkspaceId || shouldWriteWorkspaces)
|
||||
) {
|
||||
storage.setItem(BUZZ_ACTIVE_WORKSPACE_KEY, legacyStorage.activeWorkspaceId);
|
||||
}
|
||||
|
||||
for (const completion of legacyStorage.onboardingCompletions) {
|
||||
const key = `${BUZZ_ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${completion.pubkey}`;
|
||||
if (storage.getItem(key) === null) {
|
||||
storage.setItem(key, completion.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Buzz localStorage from legacy Sprout WebKit localStorage before the app
|
||||
* renders providers that read workspace state. The native command reads the old
|
||||
* app identifier's WebKit SQLite database; this frontend step writes only when
|
||||
* Buzz does not already have workspace state, except for the known broken
|
||||
* Sprout→Buzz first-run handoff that created a single localhost workspace.
|
||||
*/
|
||||
export async function migrateLegacyWorkspaceStorageBeforeRender(): Promise<void> {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWorkspacesRaw = window.localStorage.getItem(BUZZ_WORKSPACES_KEY);
|
||||
const hasCurrentActiveWorkspace = window.localStorage.getItem(
|
||||
BUZZ_ACTIVE_WORKSPACE_KEY,
|
||||
);
|
||||
if (
|
||||
currentWorkspacesRaw &&
|
||||
hasCurrentActiveWorkspace &&
|
||||
!hasOnlyLocalDevWorkspace(currentWorkspacesRaw)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
applyLegacyWorkspaceStorage(
|
||||
await invokeTauri<LegacyWorkspaceStorageSnapshot>(
|
||||
"get_legacy_workspace_storage",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to read legacy Sprout workspace storage.", error);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@ type WelcomeSetupProps = {
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACE_HANDOFF_MIN_MS = 200;
|
||||
const LOCAL_DEV_RELAY_URLS = new Set([
|
||||
"ws://localhost:3000",
|
||||
"ws://127.0.0.1:3000",
|
||||
]);
|
||||
|
||||
function isLocalDevRelayUrl(relayUrl: string) {
|
||||
return LOCAL_DEV_RELAY_URLS.has(relayUrl.trim().replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -97,6 +105,12 @@ export function WelcomeSetup({
|
||||
setError("Please enter a workspace URL.");
|
||||
return;
|
||||
}
|
||||
if (!workspaceName && isLocalDevRelayUrl(trimmedUrl)) {
|
||||
setError("Enter your relay URL to join a workspace.");
|
||||
setTransitionMode("forward");
|
||||
setPage("create-workspace");
|
||||
return;
|
||||
}
|
||||
|
||||
const handoffStartedAt = performance.now();
|
||||
flushSync(() => {
|
||||
@@ -220,20 +234,22 @@ export function WelcomeSetup({
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex w-full flex-col gap-3">
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
aria-disabled={isConnecting}
|
||||
onClick={() => {
|
||||
if (isConnecting) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
void handleConnect(defaultRelayUrl);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Continue with Block Inc. workspace
|
||||
</Button>
|
||||
{isLocalDevRelayUrl(defaultRelayUrl) ? null : (
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
aria-disabled={isConnecting}
|
||||
onClick={() => {
|
||||
if (isConnecting) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
void handleConnect(defaultRelayUrl);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Continue with Block Inc. workspace
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
||||
import { App } from "@/app/App";
|
||||
import "@/shared/styles/globals.css";
|
||||
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
||||
import { migrateLegacyWorkspaceStorageBeforeRender } from "@/features/workspaces/legacyWorkspaceStorage";
|
||||
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
|
||||
@@ -78,6 +79,7 @@ async function installE2eBridgeIfConfigured() {
|
||||
async function bootstrap() {
|
||||
configureDevE2eBridgeFromUrl();
|
||||
await installE2eBridgeIfConfigured();
|
||||
await migrateLegacyWorkspaceStorageBeforeRender();
|
||||
renderApp();
|
||||
}
|
||||
|
||||
|
||||
@@ -6149,6 +6149,12 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_default_relay_url":
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_legacy_workspace_storage":
|
||||
return {
|
||||
workspaces: null,
|
||||
activeWorkspaceId: null,
|
||||
onboardingCompletions: [],
|
||||
};
|
||||
case "get_relay_http_url":
|
||||
return getRelayHttpUrl(activeConfig);
|
||||
case "discover_acp_providers":
|
||||
|
||||
@@ -582,7 +582,11 @@ test("first-run default workspace handoff gives immediate stepper feedback", asy
|
||||
{
|
||||
profileReadDelayMs: 2_000,
|
||||
},
|
||||
{ skipOnboardingSeed: true, skipWorkspaceSeed: true },
|
||||
{
|
||||
relayWsUrl: "wss://default.example.com",
|
||||
skipOnboardingSeed: true,
|
||||
skipWorkspaceSeed: true,
|
||||
},
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
@@ -621,6 +625,7 @@ test("first-run default workspace handoff gives immediate stepper feedback", asy
|
||||
|
||||
test("welcome can continue using an existing Nostr key", async ({ page }) => {
|
||||
await installMockBridge(page, undefined, {
|
||||
relayWsUrl: "wss://default.example.com",
|
||||
skipOnboardingSeed: true,
|
||||
skipWorkspaceSeed: true,
|
||||
});
|
||||
@@ -662,6 +667,9 @@ test("welcome presents custom workspace setup as joining a workspace", async ({
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue with Block Inc. workspace" }),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Join a workspace" }).click();
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -446,6 +446,7 @@ export async function installMockBridge(
|
||||
page: Page,
|
||||
mock?: MockBridgeOptions,
|
||||
options?: {
|
||||
relayWsUrl?: string;
|
||||
skipOnboardingSeed?: boolean;
|
||||
skipWorkspaceSeed?: boolean;
|
||||
seedPreviewFeatures?: boolean;
|
||||
@@ -454,6 +455,7 @@ export async function installMockBridge(
|
||||
await installBridge(page, {
|
||||
mode: "mock",
|
||||
mock,
|
||||
relayWsUrl: options?.relayWsUrl,
|
||||
skipOnboardingSeed: options?.skipOnboardingSeed,
|
||||
skipWorkspaceSeed: options?.skipWorkspaceSeed,
|
||||
seedPreviewFeatures: options?.seedPreviewFeatures,
|
||||
|
||||
Reference in New Issue
Block a user