feat(desktop): add section workspace migration

Signed-off-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
This commit is contained in:
Other Brother Darryl
2026-08-13 18:01:26 -04:00
parent cca7b6178b
commit fd95520400
11 changed files with 1589 additions and 5 deletions
+1
View File
@@ -1083,6 +1083,7 @@ dependencies = [
name = "buzz-desktop"
version = "0.5.11"
dependencies = [
"aes-gcm",
"anyhow",
"arboard",
"atomic-write-file",
+1
View File
@@ -69,6 +69,7 @@ user-idle = { version = "0.6", default-features = false }
[dependencies]
atomic-write-file = "0.3"
aes-gcm = "0.10"
anyhow = "1"
dirs = "6"
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
+2
View File
@@ -66,6 +66,7 @@ mod window_chrome;
mod window_vibrancy;
mod workflows;
mod workspace;
mod workspace_crypto;
pub use agent_access::*;
pub use agent_auth::*;
@@ -119,3 +120,4 @@ pub use window_chrome::*;
pub use window_vibrancy::*;
pub use workflows::*;
pub use workspace::*;
pub use workspace_crypto::*;
@@ -0,0 +1,174 @@
//! NIP-SW AES-256-GCM metadata primitives.
//!
//! Keys and plaintext cross this boundary only for the duration of the
//! operation. The wire envelope is the frozen `aes256gcm:<nonce>:<ciphertext>`
//! encoding from NIP-SW.
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use getrandom::getrandom;
const KEY_BYTES: usize = 32;
const NONCE_BYTES: usize = 12;
const TAG_BYTES: usize = 16;
const PREFIX: &str = "aes256gcm";
fn decode_key(key_hex: &str) -> Result<[u8; KEY_BYTES], String> {
let bytes =
hex::decode(key_hex).map_err(|_| "workspace key must be lowercase hex".to_string())?;
if bytes.len() != KEY_BYTES
|| key_hex.len() != KEY_BYTES * 2
|| key_hex
.chars()
.any(|c| !c.is_ascii_hexdigit() || c.is_ascii_uppercase())
{
return Err("workspace key must be exactly 32 bytes of lowercase hex".into());
}
let mut key = [0u8; KEY_BYTES];
key.copy_from_slice(&bytes);
Ok(key)
}
fn cipher(key_hex: &str) -> Result<Aes256Gcm, String> {
let key = decode_key(key_hex)?;
Aes256Gcm::new_from_slice(&key).map_err(|_| "invalid workspace key".into())
}
fn encrypt_with_nonce(
key_hex: &str,
plaintext: &[u8],
aad: &[u8],
nonce: &[u8; NONCE_BYTES],
) -> Result<String, String> {
let cipher = cipher(key_hex)?;
let encrypted = cipher
.encrypt(
Nonce::from_slice(nonce),
aes_gcm::aead::Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| "workspace metadata encryption failed".to_string())?;
Ok(format!(
"{PREFIX}:{}:{}",
URL_SAFE_NO_PAD.encode(nonce),
URL_SAFE_NO_PAD.encode(encrypted),
))
}
fn parse_envelope(envelope: &str) -> Result<([u8; NONCE_BYTES], Vec<u8>), String> {
let parts = envelope.split(':').collect::<Vec<_>>();
if parts.len() != 3 || parts[0] != PREFIX {
return Err("invalid workspace metadata envelope".into());
}
let nonce_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|_| "invalid workspace metadata nonce".to_string())?;
if nonce_bytes.len() != NONCE_BYTES {
return Err("workspace metadata nonce must be exactly 12 bytes".into());
}
let ciphertext = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|_| "invalid workspace metadata ciphertext".to_string())?;
if ciphertext.len() < TAG_BYTES {
return Err("workspace metadata ciphertext is too short".into());
}
let mut nonce = [0u8; NONCE_BYTES];
nonce.copy_from_slice(&nonce_bytes);
Ok((nonce, ciphertext))
}
fn decrypt_inner(key_hex: &str, envelope: &str, aad: &[u8]) -> Result<String, String> {
let (nonce, ciphertext) = parse_envelope(envelope)?;
let cipher = cipher(key_hex)?;
let plaintext = cipher
.decrypt(
Nonce::from_slice(&nonce),
aes_gcm::aead::Payload {
msg: &ciphertext,
aad,
},
)
.map_err(|_| "workspace metadata authentication failed".to_string())?;
String::from_utf8(plaintext).map_err(|_| "workspace metadata is not valid UTF-8".into())
}
pub(crate) fn generate_key_hex() -> Result<String, String> {
let mut key = [0u8; KEY_BYTES];
getrandom(&mut key).map_err(|_| "workspace key generation failed".to_string())?;
Ok(hex::encode(key))
}
#[tauri::command]
pub fn generate_workspace_key() -> Result<String, String> {
generate_key_hex()
}
#[tauri::command]
pub fn encrypt_workspace_metadata(
key_hex: String,
plaintext: String,
aad: String,
) -> Result<String, String> {
let mut nonce = [0u8; NONCE_BYTES];
getrandom(&mut nonce).map_err(|_| "workspace metadata nonce generation failed".to_string())?;
encrypt_with_nonce(&key_hex, plaintext.as_bytes(), aad.as_bytes(), &nonce)
}
#[tauri::command]
pub fn decrypt_workspace_metadata(
key_hex: String,
envelope: String,
aad: String,
) -> Result<String, String> {
decrypt_inner(&key_hex, &envelope, aad.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
const AAD: &[u8] = b"{\"version\":1}";
#[test]
fn round_trip_matches_frozen_encoding() {
let nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
let aad = b"{\"community\":\"relay.example\",\"key_epoch\":1,\"owner_pubkey\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"purpose\":\"label\",\"section_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"version\":1}";
let envelope = encrypt_with_nonce(KEY, b"Alpha", aad, &nonce).unwrap();
assert_eq!(
envelope,
"aes256gcm:AAECAwQFBgcICQoL:Bm6mc6SHvhxcEB6Q1Lc56VuJZjD7"
);
assert_eq!(decrypt_inner(KEY, &envelope, aad).unwrap(), "Alpha");
}
#[test]
fn wrong_aad_and_tampering_fail_authentication() {
let nonce = [0u8; NONCE_BYTES];
let envelope = encrypt_with_nonce(KEY, b"Alpha", AAD, &nonce).unwrap();
assert_eq!(
decrypt_inner(KEY, &envelope, b"wrong"),
Err("workspace metadata authentication failed".into())
);
let mut tampered = envelope.into_bytes();
let last = tampered.len() - 1;
tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' };
assert_eq!(
decrypt_inner(KEY, std::str::from_utf8(&tampered).unwrap(), AAD),
Err("workspace metadata authentication failed".into())
);
}
#[test]
fn malformed_key_nonce_and_envelope_fail_closed() {
assert!(decode_key("00").is_err());
assert!(parse_envelope("aes256gcm:AAECAwQFBgcICQoL").is_err());
assert!(parse_envelope("aes256gcm:AAECAwQFBgcICQoL:not-base64!").is_err());
assert!(parse_envelope("aes256gcm:AAECAwQFBgcICQo:AA==").is_err());
}
}
+3
View File
@@ -688,6 +688,9 @@ pub fn run() {
create_auth_event,
nip44_encrypt_to_self,
nip44_decrypt_from_self,
generate_workspace_key,
encrypt_workspace_metadata,
decrypt_workspace_metadata,
get_channels,
create_channel,
ensure_starter_channels,
@@ -28,6 +28,12 @@ export type RemoteSections = {
eventId: string;
};
export type LegacySectionSource = {
event: RelayEvent;
plaintext: string;
store: ChannelSectionStore;
};
async function decryptAndParse(
event: RelayEvent,
): Promise<RemoteSections | null> {
@@ -58,6 +64,26 @@ export class ChannelSectionSyncManager {
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
}
async fetchLegacySource(): Promise<LegacySectionSource | null> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_SECTIONS],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return null;
const event = events[0];
this.recordRemoteHead(event.created_at);
const plaintext = await nip44DecryptFromSelf(event.content);
const store = parseChannelSectionPayload(JSON.parse(plaintext));
if (!store) return null;
return { event, plaintext, store };
} catch {
return null;
}
}
async fetchRemoteSections(): Promise<FetchResult<RemoteSections>> {
try {
const events = await relayClient.fetchEvents({
@@ -0,0 +1,276 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { finalizeEvent } from "nostr-tools/pure";
import { relayClient } from "@/shared/api/relayClient";
import {
canonicalJson,
parseSectionWorkspaceProjection,
parseStrictJson,
projectionRevisionAction,
SectionWorkspaceSyncManager,
} from "./sectionWorkspace.ts";
const fixture = JSON.parse(
readFileSync(
new URL("../../../../../docs/nips/NIP-SW.fixtures.json", import.meta.url),
"utf8",
),
);
const OWNER_BYTES = new Uint8Array(32).fill(1);
const OWNER =
"1111111111111111111111111111111111111111111111111111111111111111";
const RELAY = "wss://Relay.Example/";
const PROJECTION_EVENT = {
id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
pubkey: "9999999999999999999999999999999999999999999999999999999999999999",
created_at: 1,
kind: 30623,
tags: [
["d", OWNER],
["p", OWNER],
],
content: "",
sig: "signature",
};
function memoryStorage() {
const values = new Map();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
clear: () => values.clear(),
};
}
function installWindow(storage, invoke) {
const previous = globalThis.window;
globalThis.window = {
localStorage: storage,
__TAURI_INTERNALS__: { invoke },
setTimeout,
clearTimeout,
};
return () => {
globalThis.window = previous;
};
}
function installRelayStubs({ fetchEvents, publishEvent, subscribeLive }) {
const originals = {
fetchEvents: relayClient.fetchEvents,
publishEvent: relayClient.publishEvent,
subscribeLive: relayClient.subscribeLive,
};
relayClient.fetchEvents = fetchEvents;
relayClient.publishEvent = publishEvent;
relayClient.subscribeLive = subscribeLive;
return () => Object.assign(relayClient, originals);
}
function makeProjectionEvent(
projection = fixture.projection_cases[0].projection,
) {
const template = {
...PROJECTION_EVENT,
pubkey: OWNER,
tags: [
["d", OWNER],
["p", OWNER],
],
content: JSON.stringify(projection),
};
const signed = finalizeEvent(template, OWNER_BYTES);
return {
...template,
...signed,
tags: template.tags,
content: template.content,
};
}
test("section workspace consumes every shared projection fixture revision case", () => {
const accepted = parseSectionWorkspaceProjection(
fixture.projection_cases[0].projection,
);
assert.equal(accepted.sections.length, 2);
assert.equal(accepted.assignments.length, 1);
for (const item of fixture.projection_cases) {
assert.equal(
projectionRevisionAction(
item.previous_revision ?? null,
item.projection.revision,
),
item.expect,
item.name,
);
}
});
test("section workspace rejects unknown fields, unsupported versions, duplicate IDs, and malformed JSON", () => {
const valid = structuredClone(fixture.projection_cases[0].projection);
assert.throws(() =>
parseSectionWorkspaceProjection({ ...valid, extra: true }),
);
assert.throws(() =>
parseSectionWorkspaceProjection({ ...valid, version: 2 }),
);
const duplicate = structuredClone(valid);
duplicate.sections[1].id = duplicate.sections[0].id;
assert.throws(() => parseSectionWorkspaceProjection(duplicate));
assert.throws(() => parseStrictJson('{"version":1,"version":1}'));
});
test("section workspace canonicalization matches the frozen legacy hash vector", () => {
const vector = fixture.canonicalization.legacy_plaintext;
const canonical = canonicalJson(vector.input);
assert.equal(canonical, vector.canonical);
assert.equal(
createHash("sha256").update(canonical).digest("hex"),
vector.sha256,
);
});
test("projection is decrypted, cached by normalized relay, and rendered during outage", async () => {
const storage = memoryStorage();
let fetchCount = 0;
const restoreRelay = installRelayStubs({
fetchEvents: async () => {
fetchCount += 1;
return fetchCount === 1
? [makeProjectionEvent()]
: Promise.reject(new Error("offline"));
},
publishEvent: async () => {},
subscribeLive: async () => async () => {},
});
const restoreWindow = installWindow(storage, async (command, args) => {
if (command === "nip44_decrypt_from_self") return "workspace-key";
if (command === "decrypt_workspace_metadata") {
if (args.envelope === "ciphertext-alpha") return "Alpha";
if (args.envelope === "ciphertext-beta") return "Beta";
if (args.envelope === "ciphertext-icon") return "folder";
}
throw new Error(`unexpected command ${command}`);
});
try {
const manager = new SectionWorkspaceSyncManager(OWNER, RELAY);
const first = await manager.bootstrap(async () => null);
assert.deepEqual(first, {
version: 1,
sections: [
{ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "Alpha", order: 0 },
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
name: "Beta",
icon: "folder",
order: 1,
},
],
assignments: {
"cccccccc-cccc-4ccc-8ccc-cccccccccccc":
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
},
});
assert.ok(manager.isCanonical());
const cached = new SectionWorkspaceSyncManager(OWNER, RELAY);
assert.deepEqual(cached.getCachedStore(), first);
const offline = await cached.bootstrap(async () => null);
assert.deepEqual(offline, first);
} finally {
restoreWindow();
restoreRelay();
}
});
test("migration stores one canonical command and replays exact bytes after a failed publish", async () => {
const storage = memoryStorage();
const legacyStore = {
version: 1,
sections: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
name: "Alpha",
icon: "folder",
order: 0,
},
],
assignments: {
"cccccccc-cccc-4ccc-8ccc-cccccccccccc":
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
},
};
const legacyPlaintext = JSON.stringify({
version: 1,
sections: legacyStore.sections,
assignments: legacyStore.assignments,
});
const legacyEvent = {
...PROJECTION_EVENT,
id: "2222222222222222222222222222222222222222222222222222222222222222",
pubkey: OWNER,
kind: 30078,
content: "legacy-ciphertext",
tags: [["d", "channel-sections"]],
};
const published = [];
let shouldFail = true;
const restoreRelay = installRelayStubs({
fetchEvents: async () => [],
publishEvent: async (event) => {
published.push(event);
if (shouldFail) {
shouldFail = false;
throw new Error("offline");
}
return event;
},
subscribeLive: async () => async () => {},
});
const restoreWindow = installWindow(storage, async (command, args) => {
if (command === "generate_workspace_key")
return "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
if (command === "encrypt_workspace_metadata")
return `aes256gcm:${args.aad}:ciphertext`;
if (command === "nip44_encrypt_to_self") return "nip44-owner-envelope";
if (command === "sign_event")
return JSON.stringify({
...legacyEvent,
id: `event-${published.length}`,
kind: args.kind,
content: args.content,
tags: args.tags,
});
throw new Error(`unexpected command ${command}`);
});
try {
const manager = new SectionWorkspaceSyncManager(OWNER, RELAY);
await manager.bootstrap(async () => ({
event: legacyEvent,
plaintext: legacyPlaintext,
store: legacyStore,
}));
assert.equal(published.length, 1);
assert.ok(manager.isCanonical());
const firstCommand = published[0].content;
const firstTags = published[0].tags;
const retryManager = new SectionWorkspaceSyncManager(OWNER, RELAY);
await retryManager.bootstrap(async () => {
throw new Error(
"legacy retrieval must not be used after migration starts",
);
});
assert.equal(published.length, 2);
assert.equal(published[1].content, firstCommand);
assert.deepEqual(published[1].tags, firstTags);
} finally {
restoreWindow();
restoreRelay();
}
});
@@ -0,0 +1,998 @@
import { bytesToHex } from "@noble/hashes/utils.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { verifyEvent } from "nostr-tools/pure";
import { relayClient } from "@/shared/api/relayClient";
import {
nip44DecryptFromSelf,
nip44EncryptToSelf,
signRelayEvent,
} from "@/shared/api/tauri";
import {
generateWorkspaceKey,
encryptWorkspaceMetadata,
decryptWorkspaceMetadata,
} from "@/shared/api/workspaceCrypto";
import type { RelayEvent } from "@/shared/api/types";
import type { LegacySectionSource } from "./channelSectionsSync";
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
import {
KIND_SECTION_WORKSPACE_IMPORT,
KIND_SECTION_WORKSPACE_PROJECTION,
} from "./sectionWorkspaceKinds";
import type {
ChannelSectionStore,
ChannelSection,
} from "./channelSectionsStorage";
import {
boundChannelSectionsStore,
parseChannelSectionPayload,
} from "./channelSectionsStorage";
export const SECTION_WORKSPACE_VERSION = 1;
const MAX_SECTIONS = 100;
const MAX_ASSIGNMENTS = 1_000;
const MAX_ENCRYPTED_METADATA_BYTES = 65_535;
const MAX_KEY_ENVELOPE_BYTES = 4_096;
const CACHE_PREFIX = "buzz-section-workspace.v1";
const IMPORT_ACTION_PREFIX = `${CACHE_PREFIX}:import-action`;
const IMPORT_COMMAND_PREFIX = `${CACHE_PREFIX}:import-command`;
type ProjectionSection = {
id: string;
rank: number;
encrypted_label: string;
encrypted_icon: string | null;
};
type ProjectionAssignment = {
channel_id: string;
section_id: string;
revision: number;
};
export type SectionWorkspaceProjection = {
version: 1;
owner_pubkey: string;
revision: number;
layout_revision: number;
key_epoch: number;
migration: { source_event_id: string; source_hash: string };
reader_key_envelope: string;
sections: ProjectionSection[];
assignments: ProjectionAssignment[];
};
export type WorkspaceCache = {
projection: SectionWorkspaceProjection;
store: ChannelSectionStore;
revision: number;
keyEpoch: number;
};
type ImportSection = {
id: string;
rank: number;
encrypted_label: string;
encrypted_icon: string | null;
};
type ImportAssignment = { channel_id: string; section_id: string };
type WorkspaceImport = {
version: 1;
action_id: string;
source_event_id: string;
source_hash: string;
key_epoch: 1;
owner_key_envelope: string;
sections: ImportSection[];
assignments: ImportAssignment[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function rejectDuplicateJsonKeys(input: string): void {
let index = 0;
const skipWhitespace = () => {
while (/\s/.test(input[index] ?? "")) index += 1;
};
const parseString = (): string => {
const start = index;
if (input[index] !== '"') throw new Error("invalid JSON");
index += 1;
while (index < input.length) {
const character = input[index++];
if (character === "\\") {
index += 1;
} else if (character === '"') {
return JSON.parse(input.slice(start, index)) as string;
}
}
throw new Error("invalid JSON string");
};
const parseValue = (): void => {
skipWhitespace();
const character = input[index];
if (character === "{") {
index += 1;
skipWhitespace();
const keys = new Set<string>();
if (input[index] === "}") {
index += 1;
return;
}
while (index < input.length) {
skipWhitespace();
const key = parseString();
if (keys.has(key)) throw new Error("duplicate JSON key");
keys.add(key);
skipWhitespace();
if (input[index++] !== ":") throw new Error("invalid JSON object");
parseValue();
skipWhitespace();
if (input[index] === "}") {
index += 1;
return;
}
if (input[index++] !== ",") throw new Error("invalid JSON object");
}
throw new Error("invalid JSON object");
}
if (character === "[") {
index += 1;
skipWhitespace();
if (input[index] === "]") {
index += 1;
return;
}
while (index < input.length) {
parseValue();
skipWhitespace();
if (input[index] === "]") {
index += 1;
return;
}
if (input[index++] !== ",") throw new Error("invalid JSON array");
}
throw new Error("invalid JSON array");
}
if (character === '"') {
parseString();
return;
}
const start = index;
while (index < input.length && !/[\s,\]}]/.test(input[index] ?? ""))
index += 1;
if (start === index) throw new Error("invalid JSON value");
};
parseValue();
skipWhitespace();
if (index !== input.length) throw new Error("invalid JSON trailing data");
}
export function parseStrictJson(input: string): unknown {
rejectDuplicateJsonKeys(input);
return JSON.parse(input) as unknown;
}
function exactKeys(
value: Record<string, unknown>,
keys: readonly string[],
): boolean {
const expected = new Set(keys);
return (
Object.keys(value).length === expected.size &&
Object.keys(value).every((key) => expected.has(key))
);
}
function integerField(value: Record<string, unknown>, key: string): number {
const result = value[key];
if (typeof result !== "number" || !Number.isSafeInteger(result) || result < 0)
throw new Error(`invalid ${key}`);
return result;
}
function uuid(value: unknown, key: string): string {
if (
typeof value !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
value,
)
)
throw new Error(`invalid ${key}`);
return value;
}
function lowercaseHex64(value: unknown, key: string): string {
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))
throw new Error(`invalid ${key}`);
return value;
}
function positiveIntegerField(
value: Record<string, unknown>,
key: string,
): number {
const result = integerField(value, key);
if (result < 1) throw new Error(`invalid ${key}`);
return result;
}
function boundedUtf8String(
value: unknown,
key: string,
maxBytes: number,
): string {
if (typeof value !== "string" || value.length === 0)
throw new Error(`invalid ${key}`);
if (new TextEncoder().encode(value).byteLength > maxBytes)
throw new Error(`${key} exceeds size limit`);
return value;
}
export function parseSectionWorkspaceProjection(
input: unknown,
): SectionWorkspaceProjection {
if (
!isRecord(input) ||
!exactKeys(input, [
"version",
"owner_pubkey",
"revision",
"layout_revision",
"key_epoch",
"migration",
"reader_key_envelope",
"sections",
"assignments",
])
)
throw new Error("invalid projection fields");
if (input.version !== SECTION_WORKSPACE_VERSION)
throw new Error("unsupported projection version");
const migration = input.migration;
if (
!isRecord(migration) ||
!exactKeys(migration, ["source_event_id", "source_hash"])
)
throw new Error("invalid migration marker");
const sectionsInput = input.sections;
const assignmentsInput = input.assignments;
if (
!Array.isArray(sectionsInput) ||
sectionsInput.length > MAX_SECTIONS ||
!Array.isArray(assignmentsInput) ||
assignmentsInput.length > MAX_ASSIGNMENTS
)
throw new Error("projection limits exceeded");
const sections = sectionsInput.map((raw, index) => {
if (
!isRecord(raw) ||
!exactKeys(raw, ["id", "rank", "encrypted_label", "encrypted_icon"])
)
throw new Error(`invalid section ${index}`);
const icon = raw.encrypted_icon;
if (icon !== null && typeof icon !== "string")
throw new Error("invalid encrypted_icon");
return {
id: uuid(raw.id, "section id"),
rank: integerField(raw, "rank"),
encrypted_label: boundedUtf8String(
raw.encrypted_label,
"encrypted_label",
MAX_ENCRYPTED_METADATA_BYTES,
),
encrypted_icon:
icon === null
? null
: boundedUtf8String(
icon,
"encrypted_icon",
MAX_ENCRYPTED_METADATA_BYTES,
),
};
});
const ids = new Set(sections.map((section) => section.id));
if (ids.size !== sections.length) throw new Error("duplicate section id");
const ranks = new Set(sections.map((section) => section.rank));
if (
ranks.size !== sections.length ||
sections.some((section) => section.rank >= sections.length)
)
throw new Error("invalid section ranks");
const assignments = assignmentsInput.map((raw, index) => {
if (
!isRecord(raw) ||
!exactKeys(raw, ["channel_id", "section_id", "revision"])
)
throw new Error(`invalid assignment ${index}`);
const assignment = {
channel_id: uuid(raw.channel_id, "channel id"),
section_id: uuid(raw.section_id, "section id"),
revision: integerField(raw, "revision"),
};
if (!ids.has(assignment.section_id))
throw new Error("assignment references unknown section");
return assignment;
});
if (
new Set(assignments.map((assignment) => assignment.channel_id)).size !==
assignments.length
)
throw new Error("duplicate channel assignment");
return {
version: 1,
owner_pubkey: lowercaseHex64(input.owner_pubkey, "owner_pubkey"),
revision: integerField(input, "revision"),
layout_revision: integerField(input, "layout_revision"),
key_epoch: positiveIntegerField(input, "key_epoch"),
migration: {
source_event_id: lowercaseHex64(
migration.source_event_id,
"source_event_id",
),
source_hash: lowercaseHex64(migration.source_hash, "source_hash"),
},
reader_key_envelope: boundedUtf8String(
input.reader_key_envelope,
"reader_key_envelope",
MAX_KEY_ENVELOPE_BYTES,
),
sections,
assignments,
};
}
function parseWorkspaceImport(input: unknown): WorkspaceImport {
if (
!isRecord(input) ||
!exactKeys(input, [
"version",
"action_id",
"source_event_id",
"source_hash",
"key_epoch",
"owner_key_envelope",
"sections",
"assignments",
]) ||
input.version !== SECTION_WORKSPACE_VERSION ||
input.key_epoch !== 1
)
throw new Error("invalid workspace import fields");
const sectionsInput = input.sections;
const assignmentsInput = input.assignments;
if (
!Array.isArray(sectionsInput) ||
sectionsInput.length > MAX_SECTIONS ||
!Array.isArray(assignmentsInput) ||
assignmentsInput.length > MAX_ASSIGNMENTS
)
throw new Error("workspace import limits exceeded");
const sections = sectionsInput.map((raw, index) => {
if (
!isRecord(raw) ||
!exactKeys(raw, ["id", "rank", "encrypted_label", "encrypted_icon"])
)
throw new Error(`invalid import section ${index}`);
const icon = raw.encrypted_icon;
if (icon !== null && typeof icon !== "string")
throw new Error("invalid import encrypted_icon");
return {
id: uuid(raw.id, "section id"),
rank: integerField(raw, "rank"),
encrypted_label: boundedUtf8String(
raw.encrypted_label,
"encrypted_label",
MAX_ENCRYPTED_METADATA_BYTES,
),
encrypted_icon:
icon === null
? null
: boundedUtf8String(
icon,
"encrypted_icon",
MAX_ENCRYPTED_METADATA_BYTES,
),
};
});
const ids = new Set(sections.map((section) => section.id));
const ranks = new Set(sections.map((section) => section.rank));
if (
ids.size !== sections.length ||
ranks.size !== sections.length ||
sections.some((section) => section.rank >= sections.length)
)
throw new Error("invalid import section shape");
const assignments = assignmentsInput.map((raw, index) => {
if (!isRecord(raw) || !exactKeys(raw, ["channel_id", "section_id"]))
throw new Error(`invalid import assignment ${index}`);
const assignment = {
channel_id: uuid(raw.channel_id, "channel id"),
section_id: uuid(raw.section_id, "section id"),
};
if (!ids.has(assignment.section_id))
throw new Error("assignment references unknown section");
return assignment;
});
if (
new Set(assignments.map((assignment) => assignment.channel_id)).size !==
assignments.length
)
throw new Error("duplicate import channel assignment");
return {
version: 1,
action_id: uuid(input.action_id, "action_id"),
source_event_id: lowercaseHex64(input.source_event_id, "source_event_id"),
source_hash: lowercaseHex64(input.source_hash, "source_hash"),
key_epoch: 1,
owner_key_envelope: boundedUtf8String(
input.owner_key_envelope,
"owner_key_envelope",
MAX_KEY_ENVELOPE_BYTES,
),
sections,
assignments,
};
}
export function projectionRevisionAction(
previous: number | null,
incoming: number,
): "accept" | "refetch" | "ignore" {
if (previous === null || incoming === previous + 1) return "accept";
if (incoming <= (previous ?? -1)) return "ignore";
return "refetch";
}
function compareCodePoints(left: string, right: string): number {
const leftPoints = Array.from(
left,
(character) => character.codePointAt(0) ?? 0,
);
const rightPoints = Array.from(
right,
(character) => character.codePointAt(0) ?? 0,
);
for (
let index = 0;
index < Math.min(leftPoints.length, rightPoints.length);
index++
) {
if (leftPoints[index] !== rightPoints[index])
return leftPoints[index] - rightPoints[index];
}
return leftPoints.length - rightPoints.length;
}
export function canonicalJson(input: unknown): string {
if (input === null || typeof input === "boolean" || typeof input === "string")
return JSON.stringify(input);
if (typeof input === "number") {
if (!Number.isSafeInteger(input))
throw new Error("canonical JSON only permits integer numbers");
return String(input);
}
if (Array.isArray(input)) return `[${input.map(canonicalJson).join(",")}]`;
if (!isRecord(input)) throw new Error("invalid JSON value");
return `{${Object.keys(input)
.sort(compareCodePoints)
.map((key) => `${JSON.stringify(key)}:${canonicalJson(input[key])}`)
.join(",")}}`;
}
function cacheKey(pubkey: string, relayUrl: string): string {
return `${CACHE_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
function importActionKey(pubkey: string, relayUrl: string): string {
return `${IMPORT_ACTION_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
function importCommandKey(pubkey: string, relayUrl: string): string {
return `${IMPORT_COMMAND_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
function readStoredValue(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function readStoredImportCommand(
pubkey: string,
relayUrl: string,
): string | null {
return readStoredValue(importCommandKey(pubkey, relayUrl));
}
function hasImportMarker(pubkey: string, relayUrl: string): boolean {
return (
readStoredValue(importActionKey(pubkey, relayUrl)) !== null ||
readStoredImportCommand(pubkey, relayUrl) !== null
);
}
function markImportStarted(
pubkey: string,
relayUrl: string,
actionId: string,
): void {
try {
window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId);
} catch {}
}
function writeImportState(
pubkey: string,
relayUrl: string,
actionId: string,
command: string,
): void {
try {
window.localStorage.setItem(importCommandKey(pubkey, relayUrl), command);
window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId);
} catch {}
}
function readCache(pubkey: string, relayUrl: string): WorkspaceCache | null {
try {
const raw = window.localStorage.getItem(cacheKey(pubkey, relayUrl));
if (!raw) return null;
const parsed = parseStrictJson(raw) as {
projection: unknown;
store: unknown;
revision: number;
keyEpoch: number;
};
const projection = parseSectionWorkspaceProjection(parsed.projection);
if (
!projection ||
!isRecord(parsed.store) ||
parsed.revision !== projection.revision ||
parsed.keyEpoch !== projection.key_epoch
)
return null;
const store = parseChannelSectionPayload(parsed.store);
if (!store) return null;
return {
projection,
store,
revision: parsed.revision,
keyEpoch: parsed.keyEpoch,
};
} catch {
return null;
}
}
function writeCache(
pubkey: string,
relayUrl: string,
cache: WorkspaceCache,
store: ChannelSectionStore,
): void {
try {
window.localStorage.setItem(
cacheKey(pubkey, relayUrl),
JSON.stringify({ ...cache, store }),
);
} catch {}
}
function aad(
community: string,
owner: string,
sectionId: string,
epoch: number,
purpose: "label" | "icon",
): string {
return canonicalJson({
version: 1,
community,
owner_pubkey: owner,
section_id: sectionId,
key_epoch: epoch,
purpose,
});
}
function outputWithinLimit(
value: string,
key: string,
maxBytes: number,
): string {
if (new TextEncoder().encode(value).byteLength > maxBytes)
throw new Error(`${key} exceeds size limit`);
return value;
}
async function encryptMetadata(
key: string,
community: string,
owner: string,
section: ChannelSection,
purpose: "label" | "icon",
value: string,
): Promise<string> {
return outputWithinLimit(
await encryptWorkspaceMetadata({
keyHex: key,
plaintext: value,
aad: aad(community, owner, section.id, 1, purpose),
}),
"encrypted_metadata",
MAX_ENCRYPTED_METADATA_BYTES,
);
}
async function decryptMetadata(
key: string,
community: string,
owner: string,
sectionId: string,
epoch: number,
purpose: "label" | "icon",
value: string,
): Promise<string> {
return decryptWorkspaceMetadata({
keyHex: key,
envelope: value,
aad: aad(community, owner, sectionId, epoch, purpose),
});
}
async function projectionStore(
projection: SectionWorkspaceProjection,
key: string,
community: string,
): Promise<ChannelSectionStore> {
const sections: ChannelSection[] = [];
for (const projected of projection.sections
.slice()
.sort((left, right) => left.rank - right.rank)) {
const name = await decryptMetadata(
key,
community,
projection.owner_pubkey,
projected.id,
projection.key_epoch,
"label",
projected.encrypted_label,
);
const icon = projected.encrypted_icon
? await decryptMetadata(
key,
community,
projection.owner_pubkey,
projected.id,
projection.key_epoch,
"icon",
projected.encrypted_icon,
)
: undefined;
sections.push({
id: projected.id,
name,
order: projected.rank,
...(icon ? { icon } : {}),
});
}
return boundChannelSectionsStore({
version: 1,
sections,
assignments: Object.fromEntries(
projection.assignments.map((assignment) => [
assignment.channel_id,
assignment.section_id,
]),
),
});
}
function validateEventRouting(event: RelayEvent, owner: string): void {
const ownerTags = event.tags.filter(
(tag) => tag.length === 2 && tag[0] === "p" && tag[1] === owner,
);
const addressTags = event.tags.filter(
(tag) => tag.length === 2 && tag[0] === "d" && tag[1] === owner,
);
if (event.kind !== KIND_SECTION_WORKSPACE_PROJECTION) {
throw new Error("invalid projection kind");
}
if (ownerTags.length < 1 || addressTags.length !== 1)
throw new Error("projection routing mismatch");
}
function projectionFromEvent(
event: RelayEvent,
owner: string,
): SectionWorkspaceProjection {
if (
event.id.length !== 64 ||
!/^[0-9a-f]{64}$/.test(event.id) ||
event.pubkey.length !== 64 ||
!/^[0-9a-f]{64}$/.test(event.pubkey) ||
!verifyEvent({
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
kind: event.kind,
tags: event.tags,
content: event.content,
sig: event.sig,
})
)
throw new Error("invalid projection event signature");
const projection = parseSectionWorkspaceProjection(
parseStrictJson(event.content),
);
if (projection.owner_pubkey !== owner)
throw new Error("invalid projection owner");
validateEventRouting(event, owner);
return projection;
}
async function fetchProjection(
pubkey: string,
): Promise<SectionWorkspaceProjection | null> {
const events = await relayClient.fetchEvents({
kinds: [KIND_SECTION_WORKSPACE_PROJECTION],
"#d": [pubkey],
"#p": [pubkey],
limit: 1,
});
if (events.length === 0) return null;
const event = events[0];
return projectionFromEvent(event, pubkey);
}
async function publishImportCommand(
pubkey: string,
actionId: string,
command: string,
): Promise<void> {
const event = await signRelayEvent({
kind: KIND_SECTION_WORKSPACE_IMPORT,
content: command,
tags: [
["p", pubkey],
["action", actionId],
],
});
await relayClient.publishEvent(
event,
"Timed out importing channel sections.",
"Failed to import channel sections.",
);
}
async function replayStoredImport(
pubkey: string,
relayUrl: string,
): Promise<boolean> {
const command = readStoredImportCommand(pubkey, normalizeRelayUrl(relayUrl));
if (!command) return false;
const parsed = parseStrictJson(command) as { action_id?: unknown };
if (typeof parsed.action_id !== "string")
throw new Error("stored import command has no action id");
parseWorkspaceImport(parsed);
await publishImportCommand(pubkey, parsed.action_id, command);
return true;
}
async function submitImport(
pubkey: string,
relayUrl: string,
legacy: RelayEvent,
plaintext: string,
store: ChannelSectionStore,
): Promise<void> {
if (legacy.id.length !== 64 || !/^[0-9a-f]{64}$/.test(legacy.id))
throw new Error("invalid legacy source event");
if (legacy.pubkey !== pubkey) throw new Error("invalid legacy source signer");
const normalizedRelay = normalizeRelayUrl(relayUrl);
const canonicalPlaintext = canonicalJson(parseStrictJson(plaintext));
const sourceHash = bytesToHex(
sha256(new TextEncoder().encode(canonicalPlaintext)),
);
let actionId: string;
let command: string | null;
try {
command = window.localStorage.getItem(
importCommandKey(pubkey, normalizedRelay),
);
} catch {
command = null;
}
if (command) {
const parsed = parseStrictJson(command);
const imported = parseWorkspaceImport(parsed);
actionId = imported.action_id;
} else {
actionId =
window.localStorage.getItem(importActionKey(pubkey, normalizedRelay)) ??
crypto.randomUUID();
markImportStarted(pubkey, normalizedRelay, actionId);
const key = await generateWorkspaceKey();
const sections: ImportSection[] = [];
const sectionIds = new Set<string>();
const sectionIdByLegacyId = new Map<string, string>();
for (const [index, section] of store.sections
.slice()
.sort((left, right) => left.order - right.order)
.entries()) {
const sectionId = uuid(section.id, "section id");
if (sectionIds.has(sectionId)) throw new Error("duplicate section id");
sectionIds.add(sectionId);
sectionIdByLegacyId.set(section.id, sectionId);
sections.push({
id: sectionId,
rank: index,
encrypted_label: await encryptMetadata(
key,
normalizedRelay,
pubkey,
section,
"label",
section.name,
),
encrypted_icon: section.icon
? await encryptMetadata(
key,
normalizedRelay,
pubkey,
section,
"icon",
section.icon,
)
: null,
});
}
const assignments = Object.entries(store.assignments).map(
([channel_id, section_id]) => ({
channel_id: uuid(channel_id, "channel id"),
section_id:
sectionIdByLegacyId.get(uuid(section_id, "section id")) ??
(() => {
throw new Error("assignment references unknown section");
})(),
}),
);
const imported: WorkspaceImport = {
version: 1,
action_id: actionId,
source_event_id: legacy.id,
source_hash: sourceHash,
key_epoch: 1,
owner_key_envelope: await nip44EncryptToSelf(key),
sections,
assignments,
};
command = canonicalJson(imported);
parseWorkspaceImport(parseStrictJson(command));
writeImportState(pubkey, normalizedRelay, actionId, command);
}
await publishImportCommand(pubkey, actionId, command);
}
export class SectionWorkspaceSyncManager {
private destroyed = false;
private migrationStarted: boolean;
private projection: SectionWorkspaceProjection | null;
private cached: WorkspaceCache | null;
private readonly pubkey: string;
private workspaceProbeBlocked = false;
private readonly relayUrl: string;
constructor(pubkey: string, relayUrl: string) {
this.pubkey = pubkey;
this.relayUrl = relayUrl;
this.cached = readCache(pubkey, relayUrl);
this.projection = this.cached?.projection ?? null;
this.migrationStarted = hasImportMarker(pubkey, relayUrl);
}
getCachedStore(): ChannelSectionStore | null {
return this.cached?.store ?? null;
}
isCanonical(): boolean {
return (
this.projection !== null ||
this.migrationStarted ||
this.workspaceProbeBlocked
);
}
cancelLegacyPublish(): void {
// The legacy manager is owned by the hook; this marker lets the hook stop
// a debounced whole-blob write once the relay projection is authoritative.
this.migrationStarted = true;
}
private async acceptProjection(
projection: SectionWorkspaceProjection,
): Promise<ChannelSectionStore> {
this.projection = projection;
const key = await nip44DecryptFromSelf(projection.reader_key_envelope);
const store = await projectionStore(
projection,
key,
normalizeRelayUrl(this.relayUrl),
);
this.cached = {
projection,
store,
revision: projection.revision,
keyEpoch: projection.key_epoch,
};
writeCache(this.pubkey, this.relayUrl, this.cached, store);
return store;
}
async bootstrap(
legacyFetch: () => Promise<LegacySectionSource | null>,
): Promise<ChannelSectionStore | null> {
try {
const projection = await fetchProjection(this.pubkey);
if (projection) {
const action = projectionRevisionAction(
this.projection?.revision ?? null,
projection.revision,
);
if (action === "ignore") return this.getCachedStore();
if (action === "refetch") {
const refreshed = await fetchProjection(this.pubkey);
if (
!refreshed ||
refreshed.revision <= (this.projection?.revision ?? -1)
)
return this.getCachedStore();
return this.acceptProjection(refreshed);
}
return this.acceptProjection(projection);
}
if (this.migrationStarted) {
const replayed = await replayStoredImport(this.pubkey, this.relayUrl);
if (replayed) return this.getCachedStore();
if (readStoredValue(importActionKey(this.pubkey, this.relayUrl))) {
return this.getCachedStore();
}
this.migrationStarted = false;
}
if (this.pubkey && !this.destroyed) {
const legacy = await legacyFetch();
if (legacy) {
this.migrationStarted = true;
await submitImport(
this.pubkey,
this.relayUrl,
legacy.event,
legacy.plaintext,
legacy.store,
);
}
}
} catch {
this.workspaceProbeBlocked = true;
}
return this.cached ? this.getCachedStore() : null;
}
async subscribe(
onStore: (store: ChannelSectionStore) => void,
): Promise<() => Promise<void>> {
return relayClient.subscribeLive(
{
kinds: [KIND_SECTION_WORKSPACE_PROJECTION],
"#d": [this.pubkey],
"#p": [this.pubkey],
limit: 0,
},
(event) => {
if (this.destroyed) return;
void (async () => {
try {
let remote = projectionFromEvent(event, this.pubkey);
let action = projectionRevisionAction(
this.projection?.revision ?? null,
remote.revision,
);
if (action === "refetch") {
const fetched = await fetchProjection(this.pubkey);
if (
!fetched ||
fetched.revision <= (this.projection?.revision ?? -1)
)
return;
remote = fetched;
action = "accept";
}
if (action !== "accept") return;
const store = await this.acceptProjection(remote);
onStore(store);
} catch {
this.workspaceProbeBlocked = true;
}
})();
},
);
}
destroy(): void {
this.destroyed = true;
}
}
@@ -0,0 +1,2 @@
export const KIND_SECTION_WORKSPACE_IMPORT = 9050;
export const KIND_SECTION_WORKSPACE_PROJECTION = 30623;
@@ -12,6 +12,8 @@ import { ChannelSectionSyncManager } from "./channelSectionsSync";
import type { RemoteSections } from "./channelSectionsSync";
import { swapSectionOrder } from "./channelSectionsHelpers";
import { SectionWorkspaceSyncManager } from "./sectionWorkspace";
export type { ChannelSection } from "./channelSectionsStorage";
import type {
@@ -42,6 +44,9 @@ export function useChannelSections(
});
const managerRef = React.useRef<ChannelSectionSyncManager | null>(null);
const workspaceManagerRef = React.useRef<SectionWorkspaceSyncManager | null>(
null,
);
const lastAppliedRemoteTs = React.useRef(0);
const lastAppliedEventId = React.useRef("");
@@ -56,9 +61,15 @@ export function useChannelSections(
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl);
workspaceManagerRef.current = new SectionWorkspaceSyncManager(
pubkey,
relayUrl,
);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
workspaceManagerRef.current?.destroy();
workspaceManagerRef.current = null;
};
}, [pubkey, relayUrl]);
@@ -105,20 +116,57 @@ export function useChannelSections(
React.useEffect(() => {
if (!pubkey || !relayUrl) return;
let cancelled = false;
const local = readChannelSectionsStore(pubkey, relayUrl);
void managerRef.current?.bootstrap(local).then((result) => {
void (async () => {
const workspaceManager = workspaceManagerRef.current;
const legacyManager = managerRef.current;
if (!workspaceManager || !legacyManager) return;
const workspaceStore = await workspaceManager.bootstrap(async () => {
const legacy = await legacyManager.fetchLegacySource();
return legacy;
});
if (cancelled) return;
if (workspaceStore) setStore(workspaceStore);
if (workspaceManager.isCanonical()) {
legacyManager.cancelPendingPublish();
return;
}
const result = await legacyManager.bootstrap(
readChannelSectionsStore(pubkey, relayUrl),
);
if (cancelled) return;
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
}
// "hold": seed already performed by bootstrap (if first-sync), or
// blocked (failed fetch / prior watermark). Hook does nothing.
});
})();
return () => {
cancelled = true;
};
}, [pubkey, relayUrl, applyRemote]);
React.useEffect(() => {
if (!pubkey || !relayUrl) return;
let unsub: (() => Promise<void>) | null = null;
let cancelled = false;
void workspaceManagerRef.current
?.subscribe((workspaceStore) => {
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
}
if (!cancelled) setStore(workspaceStore);
})
.then((dispose) => {
if (cancelled) {
void dispose();
} else {
unsub = dispose;
}
});
return () => {
cancelled = true;
if (unsub) void unsub();
};
}, [pubkey, relayUrl]);
React.useEffect(() => {
if (!pubkey) return;
let unsub: (() => Promise<void>) | null = null;
@@ -187,6 +235,10 @@ export function useChannelSections(
sections: [...current.sections, section],
});
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return current;
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return next;
}
managerRef.current?.publishSections(next);
return next;
});
@@ -217,6 +269,10 @@ export function useChannelSections(
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return next;
}
managerRef.current?.publishSections(next);
return next;
});
@@ -244,6 +300,10 @@ export function useChannelSections(
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return next;
}
managerRef.current?.publishSections(next);
return next;
});
@@ -258,6 +318,10 @@ export function useChannelSections(
const next = swapSectionOrder(prev, sectionId, "up");
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
return prev;
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return next;
}
managerRef.current?.publishSections(next);
return next;
});
@@ -272,6 +336,10 @@ export function useChannelSections(
const next = swapSectionOrder(prev, sectionId, "down");
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
return prev;
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return next;
}
managerRef.current?.publishSections(next);
return next;
});
@@ -289,6 +357,10 @@ export function useChannelSections(
});
const next: ChannelSectionStore = { ...prev, sections };
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev;
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return next;
}
managerRef.current?.publishSections(next);
return next;
});
@@ -312,6 +384,10 @@ export function useChannelSections(
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return next;
}
managerRef.current?.publishSections(next);
return next;
});
@@ -331,6 +407,10 @@ export function useChannelSections(
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return next;
}
managerRef.current?.publishSections(next);
return next;
});
+21
View File
@@ -0,0 +1,21 @@
import { invokeTauri } from "./tauri";
export async function generateWorkspaceKey(): Promise<string> {
return invokeTauri<string>("generate_workspace_key");
}
export async function encryptWorkspaceMetadata(input: {
keyHex: string;
plaintext: string;
aad: string;
}): Promise<string> {
return invokeTauri<string>("encrypt_workspace_metadata", input);
}
export async function decryptWorkspaceMetadata(input: {
keyHex: string;
envelope: string;
aad: string;
}): Promise<string> {
return invokeTauri<string>("decrypt_workspace_metadata", input);
}