Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
dad6ac5
docs(exploration): explore a durable personal library
Sep 30, 2026
baa2704
docs(exploration): seed the library from social exports and stars
Sep 30, 2026
a323b60
docs(exploration): require complete library enrichment
Sep 30, 2026
e293dd9
fix(electron): preserve workspaces before startup writes
Sep 30, 2026
e96e3d7
feat(storage): retain verified local recovery generations
Sep 30, 2026
060b7bf
feat(electron): save and checkpoint before quit and update
Sep 30, 2026
aab0bd9
fix(social): preserve seed archive collections and memberships
Sep 30, 2026
167c5ea
feat(social): import GitHub stars with native provenance
Sep 30, 2026
012d23b
feat(electron): retain reviewed import sources for recovery
Sep 30, 2026
bbb0811
feat(electron): checkpoint active work and validate copied upgrades
Sep 30, 2026
d1f93d4
docs(storage): record desktop recovery coverage and upgrade evidence
Sep 30, 2026
83d869c
feat(electron): export and restore encrypted workspace backups
Sep 30, 2026
e8a9ceb
feat(electron): resume imports from retained source checkpoints
Sep 30, 2026
96655a0
feat(electron): persist source enrichment and offline library search
Sep 30, 2026
d30a03f
feat(electron): expose the personal library and enrichment coverage
Sep 30, 2026
7a3e037
feat(social): import garden resources with authored context
Sep 30, 2026
753bb84
feat(electron): persist link captures as private source notes
Sep 30, 2026
a45025d
fix(data): observe imports before later local edits
Sep 30, 2026
e1be81d
feat(workbench): wire library capture and editable source notes
Sep 30, 2026
185b70b
fix(electron): preserve damaged recovery points without blocking new …
Sep 30, 2026
860d5d7
feat(electron): add verified video helper setup to Library
Sep 30, 2026
00efaef
docs(api): record library source links and persisted node refresh
Sep 30, 2026
bf7d9c8
fix(ci): restore desktop and dependency verification
Sep 30, 2026
328f20f
test(comms): always alter the malformed checksum fixture
Sep 30, 2026
20629c0
feat(electron): recover desktop settings and capture drafts
Sep 30, 2026
07dc42b
fix(deps): patch grpc certificate authorization handling
Sep 30, 2026
97dd220
test(electron): provide an isolated Linux keyring for desktop gates
Sep 30, 2026
8ce9577
fix(plugins): drain workspace watches before storage shutdown
Sep 30, 2026
518bc6d
docs(exploration): record legacy desktop profile recovery gap
Sep 30, 2026
4e755cd
feat(electron): browse saved collections in Library
Sep 30, 2026
beec1a6
fix(electron): commit desktop record edits atomically
Oct 1, 2026
a927899
fix(library): unblock YouTube enrichment and preserve batch progress
Oct 2, 2026
c1995ba
fix(electron): enrich imported Instagram posts without a helper
Oct 2, 2026
b02e9c8
fix(electron): enrich imported sources and retrieve available captions
Oct 2, 2026
8115518
fix(import): preserve source text and batch desktop library reads
Oct 3, 2026
dd8ae04
fix(social): emit merged Reddit content once per import
Oct 3, 2026
55adbf1
docs(library): record full import reconciliation and caption checks
Oct 3, 2026
40f5386
feat(electron): explore Library links in a 3D graph
Oct 3, 2026
5aafe88
fix(electron): clarify graph metadata labels
Oct 3, 2026
dea353e
docs(library): align graph navigation wording
Oct 3, 2026
2eec424
feat(electron): navigate the Library graph with autocomplete and grou…
Oct 3, 2026
14fe403
fix(electron): allow large Libraries to finish startup inspection
Oct 3, 2026
def125f
fix(library): enrich large imports across independent sources
Oct 3, 2026
eaca0af
docs(library): record live enrichment coverage and queue validation
Oct 3, 2026
37d735f
fix(library): isolate thumbnail CDN throttling from source metadata
Oct 3, 2026
775146e
fix(desktop): restore editing after recovery resume failures
Oct 3, 2026
089ab2f
perf(library): keep resource browsing from blocking enrichment
Oct 3, 2026
0a76659
docs(exploration): explore durable Library content as xNet nodes
Oct 3, 2026
4790492
docs(exploration): explore the Library graph on Vision Pro
Oct 3, 2026
ef60f0e
fix(library): back off repeated provider rate limits
Oct 3, 2026
0ce3072
fix(electron): allow large libraries to finish startup
Oct 4, 2026
c3745d7
fix(electron): preserve disk blocks in recovery copies
Oct 5, 2026
8eb180f
fix(electron): allow large data stores to finish initialization
Oct 5, 2026
9e05bea
fix(electron): retry empty video helper version probes
Oct 6, 2026
c5a76e8
docs(exploration): schedule review of aged proposals
Oct 6, 2026
db68fe5
fix(library): update the native image decoder
Oct 6, 2026
7499748
docs: refresh the generated documentation export
Oct 6, 2026
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
6 changes: 6 additions & 0 deletions .changeset/acknowledged-desktop-document-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@xnetjs/react': patch
'@xnetjs/sqlite': patch
---

Serialize document saves and retain failed writes for retry when closing a desktop workspace. Keep newer edits dirty until their write finishes. Electron SQLite now syncs the WAL on commit before acknowledging writes.
9 changes: 9 additions & 0 deletions .changeset/drain-workspace-watcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@xnetjs/plugins': minor
'@xnetjs/cli': patch
---

Add `AiWorkspaceWatcher.waitForIdle()` to finish active scans and asynchronous
callbacks after closing watch handles. Background failures stop the watch and
reject the drain. The agent daemon now drains before disposing storage on Ctrl-C,
so pending review writes and auto-applied edits cannot race database shutdown.
5 changes: 5 additions & 0 deletions .changeset/observe-shared-storage-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@xnetjs/data': minor
---

Local writes now advance from the persisted change clock, so editing a node after another local store imports it does not silently retain the earlier value. `NodeStore.refreshPersistedNodes` refreshes subscribers from already-committed shared storage without applying or broadcasting those changes again.
5 changes: 5 additions & 0 deletions .changeset/private-library-source-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@xnetjs/data': minor
---

Pages can link to source resources through the optional `sourceResources` relation. Personal notes and guides can cite imported material while retaining their own text and visibility.
5 changes: 5 additions & 0 deletions .changeset/safe-electron-schema-inspection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@xnetjs/sqlite': patch
---

Report Electron schema inspection failures instead of treating unreadable version records as a new database.
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,8 @@ jobs:
- name: Rebuild native modules for Electron (unpacked app)
run: pnpm --filter xnet-desktop run deps:electron

- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Install desktop test services
run: sudo apt-get update && sudo apt-get install -y xvfb dbus-x11 gnome-keyring libsecret-1-0

- name: Cache Playwright browser
id: playwright-cache
Expand Down Expand Up @@ -437,7 +437,8 @@ jobs:

- name: Run convergence matrix + Electron app smoke (xvfb)
run: |
xvfb-run --auto-servernum pnpm --filter @xnetjs/e2e-tests exec \
bash scripts/with-linux-test-keyring.sh \
xvfb-run --auto-servernum pnpm --filter @xnetjs/e2e-tests exec \
playwright test src/sync-matrix.spec.ts src/electron-smoke.spec.ts \
--project=chromium --project=electron --fail-on-flaky-tests
env:
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/electron-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ jobs:
- name: Smoke-test packaged app
if: matrix.arch == 'x64'
run: |
sudo apt-get update && sudo apt-get install -y xvfb
sudo apt-get update && sudo apt-get install -y xvfb dbus-x11 gnome-keyring libsecret-1-0
BIN=$(find "$PWD/apps/electron/dist/linux-unpacked" -maxdepth 1 -type f -executable \
! -name 'chrome*' ! -name '*.so*' ! -name '*.bin' | head -1)
if [ -z "$BIN" ]; then echo "no Electron binary found"; ls -la apps/electron/dist/linux-unpacked; exit 1; fi
Expand All @@ -385,6 +385,7 @@ jobs:
rm -f "$XNET_BOOT_TRACE"
set +e
XNET_PACKAGED_BINARY="$BIN" XNET_ELECTRON_NO_SANDBOX=1 XNET_DEBUG=1 E2E_DEBUG=1 \
bash scripts/with-linux-test-keyring.sh \
xvfb-run --auto-servernum pnpm --filter @xnetjs/e2e-tests exec \
playwright test src/packaged-smoke.spec.ts --project=electron --fail-on-flaky-tests
rc=$?
Expand Down
6 changes: 6 additions & 0 deletions apps/electron/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Electron desktop app for macOS, Windows, and Linux -- the primary development ta

## Development

Source launches use a `dev-` profile namespace, including the main checkout and
direct Electron launches. For example, the requested `default` profile stores
data in `xnet-desktop-dev-default`. Packaged apps keep their existing data path.
Existing folders are preserved; development no longer opens a daily workspace
implicitly. The app's runtime profile and window title show the resolved name.

```bash
pnpm dev # Start hub + app concurrently
pnpm dev:both # Two instances for sync testing
Expand Down
9 changes: 7 additions & 2 deletions apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@
"@xnetjs/core": "workspace:*",
"@xnetjs/data": "workspace:*",
"@xnetjs/devkit": "workspace:*",
"@xnetjs/dictation": "workspace:*",
"@xnetjs/meetings": "workspace:*",
"@xnetjs/devtools": "workspace:*",
"@xnetjs/dictation": "workspace:*",
"@xnetjs/editor": "workspace:*",
"@xnetjs/identity": "workspace:*",
"@xnetjs/meetings": "workspace:*",
"@xnetjs/network": "workspace:*",
"@xnetjs/plugins": "workspace:*",
"@xnetjs/react": "workspace:*",
Expand All @@ -52,9 +52,13 @@
"@xnetjs/views": "workspace:*",
"@xnetjs/workbench": "workspace:*",
"better-sqlite3": "^11.0.0",
"d3-force-3d": "3.0.6",
"electron-updater": "^6.3.0",
"lucide-react": "^0.400.0",
"mermaid": "^11.4.0",
"parse5": "7.3.0",
"sharp": "^0.35.5",
"three": "0.186.1",
"ws": "^8.18.0",
"y-protocols": "^1.0.6",
"yjs": "^13.6.24"
Expand All @@ -66,6 +70,7 @@
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/three": "0.186.0",
"@types/ws": "^8.5.10",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
Expand Down
4 changes: 3 additions & 1 deletion apps/electron/scripts/dev-launch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
import { spawn } from 'node:child_process'
import { resolveDevScope, scopeEnv } from './dev-scope.mjs'

const PROBE_TIMEOUT_MS = Number(process.env.XNET_DEV_PROBE_TIMEOUT_MS || 90_000)
// Large libraries need several minutes for storage inspection before data-process
// initialization. Keep the launcher from terminating those checks mid-startup.
const PROBE_TIMEOUT_MS = Number(process.env.XNET_DEV_PROBE_TIMEOUT_MS || 900_000)
const PROBE_INTERVAL_MS = 500

const argv = process.argv.slice(2)
Expand Down
191 changes: 191 additions & 0 deletions apps/electron/src/__tests__/ipc-node-batch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { NodeStore, PageSchema, SQLiteNodeStorageAdapter } from '@xnetjs/data'
import { identityFromPrivateKey } from '@xnetjs/identity'
import Database from 'better-sqlite3'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createDataService, type DataService } from '../data-process/data-service'
import { IPCNodeStorageAdapter } from '../renderer/lib/ipc-node-storage'

const { sendEvent } = vi.hoisted(() => ({ sendEvent: vi.fn() }))
vi.mock('../data-process/events', () => ({ sendEvent }))

let root: string
let service: DataService
let database: Database.Database
let store: NodeStore
const signingKey = new Uint8Array(32).fill(73)
const authorDID = identityFromPrivateKey(signingKey).did

async function open(): Promise<void> {
service = createDataService({ dbPath: join(root, 'data.db') })
await service.initialize()
const api = {
applyNodeBatch: vi.fn(service.applyNodeBatch.bind(service)),
getNode: service.getNode.bind(service),
getLastChange: service.getLastChange.bind(service),
getChanges: service.getChanges.bind(service),
getLastLamportTime: service.getLastLamportTime.bind(service)
}
vi.stubGlobal('window', { xnetNodes: api })
store = new NodeStore({ storage: new IPCNodeStorageAdapter(), authorDID, signingKey })
await store.initialize()
database = new Database(join(root, 'data.db'))
}

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'xnet-ipc-batch-'))
await open()
sendEvent.mockClear()
})
afterEach(async () => {
database?.close()
await service?.shutdown()
vi.unstubAllGlobals()
await rm(root, { recursive: true, force: true })
})

it('commits desktop edits atomically and reads their signed history after reopening', async () => {
const node = await store.create({
id: 'daily-note',
schemaId: PageSchema._schemaId,
properties: { title: 'Before' }
})
await service.setDocumentContent(node.id, [0, 127, 255])
await store.update(node.id, { properties: { title: 'After' } })
const changes = await service.getChanges(node.id)
expect(changes).toHaveLength(2)
expect(changes[1].parentHash).toBe(changes[0].hash)
expect(changes[1].id).not.toBe(changes[1].hash)
expect(changes[1].payload.properties.title).toBe('After')
expect(await service.getChangeByHash(changes[1].hash)).toEqual(changes[1])
expect(await service.getChangesSince(changes[0].lamport)).toEqual([changes[1]])
expect(window.xnetNodes.applyNodeBatch).toHaveBeenCalledTimes(2)
expect(sendEvent).toHaveBeenCalledTimes(2)
expect(await service.getLastLamportTime()).toBe(changes[1].lamport)

database.close()
await service.shutdown()
await open()
expect((await store.get(node.id))?.properties.title).toBe('After')
expect(await service.getChanges(node.id)).toEqual(changes)
expect(await service.getAllChanges()).toEqual(changes)
expect(await service.getDocumentContent(node.id)).toEqual([0, 127, 255])
})

it('rolls back nodes, history, indexes, and clock when a later write fails; then retries', async () => {
await store.create({
id: 'existing',
schemaId: PageSchema._schemaId,
properties: { title: 'Keep' }
})
const clock = await service.getLastLamportTime()
const changes = await service.getAllChanges()
database.exec(`CREATE TRIGGER reject_batch BEFORE INSERT ON changes
WHEN NEW.node_id = 'rejected'
BEGIN SELECT RAISE(ABORT, 'Injected disk failure'); END`)
sendEvent.mockClear()
const operations = [
{ type: 'update' as const, nodeId: 'existing', options: { properties: { title: 'Replace' } } },
{
type: 'create' as const,
options: { id: 'rejected', schemaId: PageSchema._schemaId, properties: { title: 'New' } }
}
]
await expect(store.transaction(operations)).rejects.toThrow('Injected disk failure')
expect((await service.getNode('existing'))?.properties.title).toBe('Keep')
expect(await service.getNode('rejected')).toBeNull()
expect(await service.getAllChanges()).toEqual(changes)
expect(await service.getLastLamportTime()).toBe(clock)
expect(
database
.prepare('SELECT COUNT(*) AS count FROM node_property_scalars WHERE node_id = ?')
.get('rejected')
).toEqual({ count: 0 })
expect(sendEvent).not.toHaveBeenCalled()

database.exec('DROP TRIGGER reject_batch')
const result = await store.transaction(operations)
expect((await service.getNode('existing'))?.properties.title).toBe('Replace')
expect((await service.getNode('rejected'))?.properties.title).toBe('New')
const saved = (await service.getAllChanges()).filter(
(change) => change.batchId === result.batchId
)
expect(saved.map((change) => [change.batchIndex, change.batchSize])).toEqual([
[0, 2],
[1, 2]
])
expect(saved.map((change) => change.id)).toEqual(result.changes.map((change) => change.id))
expect(sendEvent).toHaveBeenCalledTimes(1)
})

it('preserves a deleted record and restores it after restart', async () => {
await store.create({
id: 'deleted',
schemaId: PageSchema._schemaId,
properties: { title: 'Return' }
})
await store.delete('deleted')
const deleted = await service.getNode('deleted')
expect(deleted?.deleted).toBe(true)
expect((await service.getLastChange('deleted'))?.authorDID).toBe(authorDID)
database.close()
await service.shutdown()
await open()
await store.restore('deleted')
expect((await store.get('deleted'))?.properties.title).toBe('Return')
})

it('rejects access after shutdown instead of acknowledging an empty workspace', async () => {
await service.shutdown()
await expect(service.getNode('missing')).rejects.toThrow('Database not initialized')
await expect(service.getAllChanges()).rejects.toThrow('Database not initialized')
})

it('edits an imported record without losing its original signed change identity', async () => {
await service.importDeterministicNodes({
drafts: [
{ id: 'imported', schemaId: PageSchema._schemaId, properties: { title: 'Source title' } }
],
authorDID,
signingKey: Array.from(signingKey)
})
const original = await service.getLastChange('imported')
expect(original?.payload.nodeId).toBe('imported')
expect(original?.payload.properties.title).toBe('Source title')
await store.update('imported', { properties: { title: 'My title' } })
const changes = await service.getChanges('imported')
expect(changes).toHaveLength(2)
expect(changes[0]).toEqual(original)
expect(changes[1].parentHash).toBe(original?.hash)
expect(changes[1].lamport).toBeGreaterThan(original!.lamport)
expect((await service.getNode('imported'))?.properties.title).toBe('My title')
})

it('hydrates a query page in one batch while preserving order and document bytes', async () => {
for (const id of ['old', 'middle', 'new'])
await store.create({ id, schemaId: PageSchema._schemaId, properties: { title: id } })
const time = database.prepare('UPDATE nodes SET created_at=? WHERE id=?')
time.run(1, 'old')
time.run(2, 'middle')
time.run(3, 'new')
await service.setDocumentContent('middle', [0, 127, 255])
const expected = [await service.getNode('middle'), await service.getNode('old')]
const single = vi.spyOn(service, 'getNode')
const batch = vi.spyOn(SQLiteNodeStorageAdapter.prototype, 'getNodes')
try {
const rows = await service.listNodes({
schemaId: PageSchema._schemaId,
orderBy: { createdAt: 'desc' },
offset: 1,
limit: 2
})
expect(rows).toEqual(expected)
expect(single).not.toHaveBeenCalled()
expect(batch).toHaveBeenCalledExactlyOnceWith(['middle', 'old'])
} finally {
single.mockRestore()
batch.mockRestore()
}
})
Loading
Loading