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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.49.0",
"version": "2.50.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/main/cloud-publishing-network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createServer } from 'node:http'
import { once } from 'node:events'
import { expect, it } from 'vitest'
import { createCloudSyncClient } from './cloud-sync-client'

it('publishes an attachment when the committed response takes more than 30 seconds, then updates the same link', async () => {
const attachment = Buffer.alloc(1_100_000, 73)
const stored: { markdown: string; attachment: Buffer }[] = []
const server = createServer(async (request, response) => {
try {
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(Buffer.from(chunk))
const form = await new Request('http://localhost/shares', {
method: 'POST',
headers: { 'Content-Type': request.headers['content-type']! },
body: Buffer.concat(chunks)
}).formData()
const payload = JSON.parse(form.get('payload') as string)
const file = form.get('assets[]') as File
stored.push({ markdown: payload.markdown, attachment: Buffer.from(await file.arrayBuffer()) })
if (request.method === 'POST') await new Promise((resolve) => setTimeout(resolve, 31_000))
response.writeHead(request.method === 'POST' ? 201 : 200, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ id: 1, slug: 'test', url: 'http://localhost/s/test' }))
} catch (error) {
response.writeHead(500)
response.end(String(error))
}
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address() as import('node:net').AddressInfo
const client = createCloudSyncClient(`http://127.0.0.1:${address.port}`, 'test-only')
const input = {
note_path: 'Cloud test.md', title: 'Cloud test', markdown: 'Before ![](image.jpg)',
assets: [{ ref: 'image.jpg', name: 'image.jpg', mime: 'image/jpeg', base64: attachment.toString('base64') }]
}
try {
const created = await client.publishNote(input)
const updated = await client.updatePublishedNote(created.id, { ...input, markdown: 'After ![](image.jpg)' })
expect(updated.url).toBe(created.url)
expect(stored.map((note) => note.markdown)).toEqual([input.markdown, 'After ![](image.jpg)'])
expect(stored.every((note) => note.attachment.equals(attachment))).toBe(true)
} finally {
server.closeAllConnections()
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}, 45_000)
18 changes: 18 additions & 0 deletions apps/desktop/src/main/cloud-sync-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,24 @@ async function setup(
}

describe('DesktopCloudSyncService', () => {
it('probes incoming changes without locking windows or advancing the saved cursor', async () => {
const prepare = vi.fn(async (_root, run) => run())
const vault = { id: 'vault-1', name: 'Notes', cursor: 0, created_at: '2026-09-14T12:00:00Z', updated_at: '2026-09-14T12:00:00Z' }
const { service, client, localRoot } = await setup([vault], undefined, undefined, prepare)
await service.link(localRoot, vault.id)
expect(await service.hasRemoteChanges(localRoot)).toBe(true)
await service.sync(localRoot)
prepare.mockClear()
expect(await service.hasRemoteChanges(localRoot)).toBe(false)
client.manifest.mockResolvedValue({ data: [], cursor: 1, next_page: null })
expect(await service.hasRemoteChanges(localRoot)).toBe(true)
expect(await service.hasRemoteChanges(localRoot)).toBe(true)
expect(client.manifest).toHaveBeenLastCalledWith(vault.id, { includeContent: false, perPage: 1 })
expect(prepare).not.toHaveBeenCalled()
await service.unlink(localRoot)
expect(await service.hasRemoteChanges(localRoot)).toBe(false)
})

it('prepares windows before taking the vault lock and coalesces preparation', async () => {
let prepared!: () => void
const gate = new Promise<void>((resolve) => { prepared = resolve })
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/main/cloud-sync-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
import { setVaultSettings } from './vault'
import type { CloudSyncApiClient } from '@zennotes/shared-domain/cloud-sync-api'
import { CloudServiceRequestError } from './cloud-sync-client'
import { createDesktopCloudSyncCoordinator } from './cloud-sync-filesystem'
import { createDesktopCloudSyncCoordinator, DesktopCloudSyncStateStore } from './cloud-sync-filesystem'

type SyncClient = Pick<
CloudSyncApiClient,
Expand Down Expand Up @@ -291,6 +291,27 @@ export class DesktopCloudSyncService {
return { restore, sync }
}

async hasRemoteChanges(localRoot: string): Promise<boolean> {
if (this.runs.has(path.resolve(localRoot))) return false
const link = await this.readLink(localRoot)
if (!link) return false
const connection = await this.optionalConnection()
if (!connection || link.base_url !== connection.account.base_url) return false
const states = new DesktopCloudSyncStateStore(path.join(
this.dependencies.storageDirectory, 'states', rootFingerprint(localRoot),
fingerprint(connection.account.base_url)
))
const state = await states.load(link.vault_id)
if (!state) return true
// The changes endpoint includes file contents. A one-item manifest exposes
// the vault cursor without downloading an attachment just to detect it.
const manifest = await connection.client.manifest(link.vault_id, {
includeContent: false,
perPage: 1
})
return manifest.cursor !== state.cursor
}

sync(localRoot: string): Promise<CloudSyncRunSummary> {
const runKey = path.resolve(localRoot)
const existing = this.runs.get(runKey)
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2926,6 +2926,9 @@ function registerIpc(): void {
handle(IPC.CLOUD_VAULT_SYNC, () =>
getCloudSyncService().sync(requireLocalCloudVaultRoot()),
);
handle(IPC.CLOUD_VAULT_HAS_CHANGES, () =>
getCloudSyncService().hasRemoteChanges(requireLocalCloudVaultRoot()),
);
on(IPC.CLOUD_VAULT_SYNC_WINDOW_ACK, (event, requestId: unknown, error: unknown) => {
if (typeof requestId !== "string" || requestId.length > 100 ||
(error !== null && (typeof error !== "string" || error.length > 2000))) return;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ const api: ZenBridge = {
unlinkCloudVault: (): Promise<void> => ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_DELETE),
deleteCloudVault: (): Promise<void> => ipcRenderer.invoke(IPC.CLOUD_VAULT_DELETE),
syncCloudVault: (): Promise<CloudSyncRunSummary> => ipcRenderer.invoke(IPC.CLOUD_VAULT_SYNC),
hasCloudVaultChanges: (): Promise<boolean> => ipcRenderer.invoke(IPC.CLOUD_VAULT_HAS_CHANGES),
onCloudSyncWindow: (handlers: CloudSyncWindowHandlers): (() => void) => {
const active = new Set<string>()
const listener = (_event: Electron.IpcRendererEvent, event: CloudSyncWindowEvent): void => {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/server",
"private": true,
"version": "2.49.0",
"version": "2.50.0",
"scripts": {
"dev": "node ../../tooling/scripts/run-go-server-dev.mjs",
"prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.49.0",
"version": "2.50.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-monorepo",
"private": true,
"version": "2.49.0",
"version": "2.50.0",
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
"packageManager": "npm@10.9.2",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/app-core",
"private": true,
"version": "2.49.0",
"version": "2.50.0",
"type": "module",
"exports": {
"./main": "./src/main.tsx"
Expand Down
105 changes: 104 additions & 1 deletion packages/app-core/src/components/CloudSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
CloudServiceAccount,
CloudSyncRunSummary,
} from "@zennotes/bridge-contract/cloud-sync";
import { useStore } from "../store";
import { getPublishNoteRequest, dismissPublishNoteRequest } from "../lib/publish-note-requests";
import { CloudSettings } from "./CloudSettings";
import { subscribePublishedNoteChanges } from "../lib/published-note-events";
import { clearCloudSyncStatus, useCloudSyncStatusStore } from "../lib/cloud-auto-sync";
Expand All @@ -20,6 +22,7 @@ const mocks = vi.hoisted(() => ({
getCloudServiceAccount: vi.fn(),
listCloudPublishedNotes: vi.fn(),
unpublishCloudNote: vi.fn(),
readNote: vi.fn(),
clipboardWriteText: vi.fn(),
listCloudVaults: vi.fn(),
getCloudVaultLink: vi.fn(),
Expand Down Expand Up @@ -222,6 +225,106 @@ describe("CloudSettings", () => {
expect(host.textContent).not.toContain("views");
});

it("opens an explicit update with the latest local draft from Published notes", async () => {
mocks.getCloudAccountStatus.mockResolvedValue(connected);
mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount);
mocks.getCloudVaultLink.mockResolvedValue(null);
mocks.listCloudVaults.mockResolvedValue([]);
mocks.listCloudPublishedNotes.mockResolvedValue([{
id: 42, slug: "launch", url: "https://zennotes.org/s/launch",
title: "Launch notes", note_path: "Notes/Launch.md", created_at: null, updated_at: null,
}]);
const draft = { path: "Notes/Launch.md", title: "Launch notes", body: "Newest unsaved draft", assetEmbeds: [] };
useStore.setState({ noteContents: { [draft.path]: draft as never }, noteDirty: { [draft.path]: true }, notes: [draft as never] });
await act(async () => root.render(createElement(CloudSettings, {
localVaultAvailable: true, localVaultName: "Notes",
})));
expect(host.textContent).toContain("Refresh list");
expect(host.textContent).toContain("Edits stay private until you choose Update note");
const update = [...host.querySelectorAll("button")].find(b => b.textContent?.trim() === "Update note");
expect(update).toBeTruthy();
await act(async () => update!.click());
expect(getPublishNoteRequest()?.note.body).toBe(draft.body);
expect(mocks.readNote).not.toHaveBeenCalled();
const request = getPublishNoteRequest();
if (request) dismissPublishNoteRequest(request);
act(() => useStore.setState({ noteContents: {}, noteDirty: {}, notes: [] }));
});

it("reads the current file instead of a stale clean cache when updating a public note", async () => {
mocks.getCloudAccountStatus.mockResolvedValue(connected);
mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount);
mocks.getCloudVaultLink.mockResolvedValue(null);
mocks.listCloudVaults.mockResolvedValue([]);
const cached = { path: "Notes/Old.md", title: "Old", body: "Old cache", assetEmbeds: [] };
mocks.listCloudPublishedNotes.mockResolvedValue([{
id: 43, slug: "old", url: "https://zennotes.org/s/old", title: cached.title,
note_path: cached.path, created_at: null, updated_at: null,
}]);
mocks.readNote.mockResolvedValueOnce({ ...cached, body: "Latest from Cloud" });
useStore.setState({ noteContents: { [cached.path]: cached as never }, noteDirty: {}, notes: [cached as never] });
await act(async () => root.render(createElement(CloudSettings, {
localVaultAvailable: true, localVaultName: "Notes",
})));
const update = [...host.querySelectorAll("button")].find(b => b.textContent?.trim() === "Update note");
await act(async () => update!.click());
const request = getPublishNoteRequest();
expect(request?.note.body).toBe("Latest from Cloud");
if (request) dismissPublishNoteRequest(request);
act(() => useStore.setState({ noteContents: {}, noteDirty: {}, notes: [] }));
});

it("prefers a draft edited while the Update note disk read is pending", async () => {
mocks.getCloudAccountStatus.mockResolvedValue(connected);
mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount);
mocks.getCloudVaultLink.mockResolvedValue(null);
mocks.listCloudVaults.mockResolvedValue([]);
const note = { path: "Notes/Race.md", title: "Race", body: "Old disk content", assetEmbeds: [] };
mocks.listCloudPublishedNotes.mockResolvedValue([{
id: 43, slug: "race", url: "https://zennotes.org/s/race", title: note.title,
note_path: note.path, created_at: null, updated_at: null,
}]);
let finish!: (value: unknown) => void;
mocks.readNote.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
useStore.setState({ noteContents: {}, notes: [note as never] });
await act(async () => root.render(createElement(CloudSettings, {
localVaultAvailable: true, localVaultName: "Notes",
})));
const update = [...host.querySelectorAll("button")].find(b => b.textContent?.trim() === "Update note");
await act(async () => update!.click());
await act(async () => {
useStore.setState({ noteContents: { [note.path]: { ...note, body: "New draft" } as never }, noteDirty: { [note.path]: true } });
finish(note);
});
const request = getPublishNoteRequest();
expect(request?.note.body).toBe("New draft");
if (request) dismissPublishNoteRequest(request);
act(() => useStore.setState({ noteContents: {}, noteDirty: {}, notes: [] }));
});

it("asks for a smaller file instead of promising automatic recovery from an oversized upload", async () => {
mocks.getCloudAccountStatus.mockResolvedValue(connected);
mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount);
mocks.listCloudVaults.mockResolvedValue([]);
mocks.getCloudVaultLink.mockResolvedValue({ base_url: connected.account!.base_url,
vault_id: "vault-1", vault_name: "Notes", linked_at: "2026-09-14T12:00:00Z" });
mocks.syncCloudVault.mockResolvedValue({ cursor: 1, pulled: 0, pushed: 0,
bootstrap_conflicts: [], local_conflicts: [], conflicts: [{
operation_id: "large", item_id: "large", code: "FILE_SIZE_LIMIT_EXCEEDED",
current_revision: null, current_path: null,
capacity: { dimension: "sync_max_file_bytes", limit: 10_000_000, used: 0,
reserved: 0, projected: 12_600_000, can_retry_after_reduction: true },
}] });
await act(async () => root.render(createElement(CloudSettings, {
localVaultAvailable: true, localVaultName: "Notes",
})));
const sync = [...host.querySelectorAll("button")].find(b => b.textContent?.trim() === "Sync now");
await act(async () => sync!.click());
expect(host.textContent).toContain("10 MB Cloud file-size limit");
expect(host.textContent).toContain("Reduce or remove the oversized file");
expect(host.textContent).not.toContain("will retry automatically");
});

it("lists, copies, and unpublishes public notes", async () => {
const publishedNoteChanged = vi.fn();
const unsubscribe = subscribePublishedNoteChanges(publishedNoteChanged);
Expand Down Expand Up @@ -733,7 +836,7 @@ describe("CloudSettings", () => {
await act(async () => {
[...host.querySelectorAll("button")]
.find((button) => button.textContent?.trim() === (
operation === "publishing" ? "Refresh" : "Create backup"
operation === "publishing" ? "Refresh list" : "Create backup"
))!.click();
});
expect(host.textContent).toContain(actionError);
Expand Down
Loading
Loading