diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index f21338c1..1eb3dd8f 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -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",
diff --git a/apps/desktop/src/main/cloud-publishing-network.test.ts b/apps/desktop/src/main/cloud-publishing-network.test.ts
new file mode 100644
index 00000000..abd60ff1
--- /dev/null
+++ b/apps/desktop/src/main/cloud-publishing-network.test.ts
@@ -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 ',
+ 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 ' })
+ expect(updated.url).toBe(created.url)
+ expect(stored.map((note) => note.markdown)).toEqual([input.markdown, 'After '])
+ expect(stored.every((note) => note.attachment.equals(attachment))).toBe(true)
+ } finally {
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ }
+}, 45_000)
diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts
index f4cfc47f..cb2d64cf 100644
--- a/apps/desktop/src/main/cloud-sync-service.test.ts
+++ b/apps/desktop/src/main/cloud-sync-service.test.ts
@@ -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((resolve) => { prepared = resolve })
diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts
index f90dfaf5..d1d4e395 100644
--- a/apps/desktop/src/main/cloud-sync-service.ts
+++ b/apps/desktop/src/main/cloud-sync-service.ts
@@ -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,
@@ -291,6 +291,27 @@ export class DesktopCloudSyncService {
return { restore, sync }
}
+ async hasRemoteChanges(localRoot: string): Promise {
+ 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 {
const runKey = path.resolve(localRoot)
const existing = this.runs.get(runKey)
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 8787fd7d..8306c90e 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -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;
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index bb943713..13643caa 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -258,6 +258,7 @@ const api: ZenBridge = {
unlinkCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_DELETE),
deleteCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_DELETE),
syncCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_SYNC),
+ hasCloudVaultChanges: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_HAS_CHANGES),
onCloudSyncWindow: (handlers: CloudSyncWindowHandlers): (() => void) => {
const active = new Set()
const listener = (_event: Electron.IpcRendererEvent, event: CloudSyncWindowEvent): void => {
diff --git a/apps/server/package.json b/apps/server/package.json
index ed000aa7..b897e3b7 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -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",
diff --git a/apps/web/package.json b/apps/web/package.json
index 01558b43..b2825986 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/package-lock.json b/package-lock.json
index c06a6afe..73313a0d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "zennotes-monorepo",
- "version": "2.49.0",
+ "version": "2.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zennotes-monorepo",
- "version": "2.49.0",
+ "version": "2.50.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -23,7 +23,7 @@
},
"apps/desktop": {
"name": "@zennotes/desktop",
- "version": "2.49.0",
+ "version": "2.50.0",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
@@ -874,11 +874,11 @@
},
"apps/server": {
"name": "@zennotes/server",
- "version": "2.49.0"
+ "version": "2.50.0"
},
"apps/web": {
"name": "@zennotes/web",
- "version": "2.49.0",
+ "version": "2.50.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -16286,7 +16286,7 @@
},
"packages/app-core": {
"name": "@zennotes/app-core",
- "version": "2.49.0",
+ "version": "2.50.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -16363,11 +16363,11 @@
},
"packages/bridge-contract": {
"name": "@zennotes/bridge-contract",
- "version": "2.49.0"
+ "version": "2.50.0"
},
"packages/shared-domain": {
"name": "@zennotes/shared-domain",
- "version": "2.49.0",
+ "version": "2.50.0",
"dependencies": {
"@zennotes/bridge-contract": "*",
"lz-string": "^1.5.0"
@@ -16378,7 +16378,7 @@
},
"packages/shared-ui": {
"name": "@zennotes/shared-ui",
- "version": "2.49.0"
+ "version": "2.50.0"
}
}
}
diff --git a/package.json b/package.json
index dd002b4f..37049184 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/packages/app-core/package.json b/packages/app-core/package.json
index ef2138cc..7fc48020 100644
--- a/packages/app-core/package.json
+++ b/packages/app-core/package.json
@@ -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"
diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts
index 0e201505..037aa2b8 100644
--- a/packages/app-core/src/components/CloudSettings.test.ts
+++ b/packages/app-core/src/components/CloudSettings.test.ts
@@ -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";
@@ -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(),
@@ -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);
@@ -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);
diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx
index 3320bf73..0294b3f8 100644
--- a/packages/app-core/src/components/CloudSettings.tsx
+++ b/packages/app-core/src/components/CloudSettings.tsx
@@ -26,6 +26,7 @@ import {
} from "../lib/cloud-auto-sync";
import { useToastStore } from "../lib/toast";
import { notifyPublishedNoteChanged } from "../lib/published-note-events";
+import { requestPublishNote } from "../lib/publish-note-requests";
import { Button } from "./ui/Button";
import { useStore } from "../store";
import { CloudPendingConflictResolver } from "./CloudPendingConflictResolver";
@@ -47,6 +48,7 @@ type CloudAction =
| "backup-refresh"
| "publish-refresh"
| "publish-delete"
+ | "publish-update"
| "settings-local"
| "settings-cloud"
| null;
@@ -59,6 +61,7 @@ export function CloudSettings({
localVaultName: string;
}): JSX.Element {
const [bridge] = useState(() => getZenBridge());
+ const localNotes = useStore((state) => state.notes);
const [status, setStatus] = useState(null);
const [serviceAccount, setServiceAccount] =
useState(null);
@@ -430,6 +433,21 @@ export function CloudSettings({
const refreshPublishedNotes = (): Promise =>
runAction("publish-refresh", loadPublishedNotes);
+ const updatePublishedNote = (note: CloudPublishedNote): Promise =>
+ runAction("publish-update", async () => {
+ if (!note.note_path || !localVaultAvailable) return;
+ const store = useStore.getState();
+ const diskOrBuffer = store.noteDirty[note.note_path] && store.noteContents[note.note_path]
+ ? store.noteContents[note.note_path]
+ : await bridge.readNote(note.note_path);
+ const current = useStore.getState();
+ const local = current.noteDirty[note.note_path] && current.noteContents[note.note_path]
+ ? current.noteContents[note.note_path]
+ : diskOrBuffer;
+ store.setSettingsOpen(false);
+ requestPublishNote(local);
+ });
+
const copyPublishedLink = (note: CloudPublishedNote): void => {
bridge.clipboardWriteText(note.url);
useToastStore.getState().addToast("Public link copied.", "success");
@@ -621,6 +639,8 @@ export function CloudSettings({
onOpen={(note) => window.open(note.url, "_blank")}
onRefresh={() => void refreshPublishedNotes()}
onUnpublish={(note) => void unpublishNote(note)}
+ onUpdate={(note) => void updatePublishedNote(note)}
+ canUpdate={(note) => localVaultAvailable && localNotes.some((local) => local.path === note.note_path)}
/>
void;
onRefresh: () => void;
onUnpublish: (note: CloudPublishedNote) => void;
+ onUpdate: (note: CloudPublishedNote) => void;
+ canUpdate: (note: CloudPublishedNote) => boolean;
}): JSX.Element {
if (!publishIncluded) {
return (
@@ -706,13 +730,16 @@ function CloudPublishedNotesPanel({
? `${pluralize(usage.notes, "published note")} · ${pluralize(usage.assets, "asset")} using ${formatBytes(publishedBytes ?? 0)}.`
: "Anyone with a link can view a published note until you unpublish it."}
+
+ Edits stay private until you choose Update note. Refresh list checks published status.
+
@@ -744,6 +771,14 @@ function CloudPublishedNotesPanel({
+