From 1c05780e1b8e45fd004bea134d47fc3bb78ad9b1 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 9 Oct 2026 15:22:31 -0500 Subject: [PATCH 1/4] Feat(publish): a published note can carry 50 attachments, uploaded straight to storage Publishing sent a note and all its attachments in one request, and the service's PHP drops every file past the 20th, so a published note was capped at 20 attachments and 25 MB (website 6d5c638). On the way there, every attachment was read as base64 in the renderer and crossed IPC twice, so a large note sat in memory several times over. The apps now stage attachments first (website e94851c). The renderer hands each attachment to the platform by its vault-relative path (CloudPublishAssetInput.path; no bytes cross the bridge). A shared routine, publishWithStagedUploads in shared-domain, does the rest the same way on every platform: it describes each file (size and SHA-256), asks the service to start an upload, which checks the count (50), the note's total (100 MB), each file's size and type and the account's room before a byte is sent, PUTs every file to its presigned URL, four at a time, then publishes naming the upload. A service that answers 404 for uploads gets the old one-request publish, so a new app still publishes where the switch is off. A failure after the upload starts cancels it. On the desktop, main resolves each path through the vault's traversal guard (or fetches it from a remote vault), hashes it as it streams off the disk and streams it again into the PUT, with the safeguards of a sync upload: HTTPS (loopback excepted), no redirects, no bearer token, and an explicit Content-Length (object storage refuses a chunked PUT, 411) from a file that must still be the declared size. The app's own count check follows: 50, with the service's wording ("This note has 51 attached files, but a published note can have at most 50."). prepareCloudPublishLogo is gone: nothing called it, and the logo is set for the whole publication in ZenNotes Cloud. A logo change would still go the one-request way. Tests: the shared routine (staging, republish, the 404 fallback, a refusal that must not fall back, cancelling on a failed file or publish, logo and empty notes, bounded concurrency), the request bodies, the desktop client's PUT (length, type, no token, size mismatch, HTTP refused, a 403), the service wiring from a file on disk, and over real loopback HTTP the whole staged publish and republish plus the fallback. --- apps/desktop/src/main/cloud-publish-assets.ts | 68 ++++++++ .../cloud-publish-uploads-network.test.ts | 148 ++++++++++++++++ .../src/main/cloud-sync-client.test.ts | 39 +++++ apps/desktop/src/main/cloud-sync-client.ts | 66 +++++++- .../src/main/cloud-sync-service.test.ts | 65 ++++++- apps/desktop/src/main/cloud-sync-service.ts | 24 ++- apps/desktop/src/main/index.ts | 27 ++- .../app-core/src/lib/cloud-publishing.test.ts | 38 ++--- packages/app-core/src/lib/cloud-publishing.ts | 83 +++------ packages/bridge-contract/src/cloud-sync.ts | 45 ++++- .../src/cloud-publish-uploads.test.ts | 150 ++++++++++++++++ .../src/cloud-publish-uploads.ts | 160 ++++++++++++++++++ .../shared-domain/src/cloud-sync-api.test.ts | 54 ++++++ packages/shared-domain/src/cloud-sync-api.ts | 58 ++++++- 14 files changed, 923 insertions(+), 102 deletions(-) create mode 100644 apps/desktop/src/main/cloud-publish-assets.ts create mode 100644 apps/desktop/src/main/cloud-publish-uploads-network.test.ts create mode 100644 packages/shared-domain/src/cloud-publish-uploads.test.ts create mode 100644 packages/shared-domain/src/cloud-publish-uploads.ts diff --git a/apps/desktop/src/main/cloud-publish-assets.ts b/apps/desktop/src/main/cloud-publish-assets.ts new file mode 100644 index 00000000..b7c308d7 --- /dev/null +++ b/apps/desktop/src/main/cloud-publish-assets.ts @@ -0,0 +1,68 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import type { CloudPublishUploadTarget } from '@zennotes/bridge-contract/cloud-sync' +import type { CloudPublishAssetPlatform } from '@zennotes/shared-domain/cloud-publish-uploads' +import type { PublishAssetSource } from './cloud-sync-client' + +/** + * Where a published note's attachments come from: a local vault's files on + * disk (resolved through the vault's traversal guard by the caller), or a + * remote vault's, fetched from its server. + */ +export interface DesktopPublishAssetFiles { + /** The file's absolute path in a local vault, or null when the vault is remote. */ + localPath(vaultRelativePath: string): string | null + readRemote(vaultRelativePath: string): Promise +} + +export interface PublishAssetUploader { + uploadPublishAsset(target: CloudPublishUploadTarget, source: PublishAssetSource): Promise +} + +type PublishAssetHandle = Pick + +/** + * The desktop side of a staged publish: hash each attachment as it streams off + * the disk, then PUT it from disk again, so a 100 MB note is never held in + * memory or carried across IPC. A remote vault's files arrive as bytes. + */ +export function desktopPublishAssetPlatform( + uploader: PublishAssetUploader, + files: DesktopPublishAssetFiles +): CloudPublishAssetPlatform { + return { + async describe(asset) { + const file = files.localPath(asset.path) + if (file !== null) { + const info = await stat(file) + if (!info.isFile()) throw new Error(`ZenNotes could not read the attachment “${asset.ref}”.`) + return { byteLength: info.size, sha256: await hashFile(file), handle: { file } } + } + const bytes = await files.readRemote(asset.path) + return { + byteLength: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + handle: { bytes } + } + }, + async upload(target, asset) { + await uploader.uploadPublishAsset(target, { + mime: asset.mime, + byteLength: asset.byteLength, + ...asset.handle + }) + }, + async readBase64(asset) { + const file = files.localPath(asset.path) + const bytes = file !== null ? await readFile(file) : Buffer.from(await files.readRemote(asset.path)) + return bytes.toString('base64') + } + } +} + +async function hashFile(file: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(file)) hash.update(chunk as Buffer) + return hash.digest('hex') +} diff --git a/apps/desktop/src/main/cloud-publish-uploads-network.test.ts b/apps/desktop/src/main/cloud-publish-uploads-network.test.ts new file mode 100644 index 00000000..0dafc746 --- /dev/null +++ b/apps/desktop/src/main/cloud-publish-uploads-network.test.ts @@ -0,0 +1,148 @@ +import { createServer, type IncomingMessage } from 'node:http' +import { once } from 'node:events' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import type { AddressInfo } from 'node:net' +import { afterEach, expect, it } from 'vitest' +import { publishWithStagedUploads } from '@zennotes/shared-domain/cloud-publish-uploads' +import { desktopPublishAssetPlatform } from './cloud-publish-assets' +import { createCloudSyncClient } from './cloud-sync-client' + +// A fake Cloud and object store on loopback: the real desktop client stages each +// attachment with a streamed PUT, then publishes naming the upload. + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + return Buffer.concat(chunks) +} + +async function fixture(stagedUploads: boolean) { + const vault = await mkdtemp(path.join(os.tmpdir(), 'zennotes-publish-network-')) + directories.push(vault) + const files = [Buffer.alloc(700_000, 7), Buffer.alloc(1_300_000, 9)] + await writeFile(path.join(vault, 'a.png'), files[0]!) + await writeFile(path.join(vault, 'b.png'), files[1]!) + + const puts: Array<{ url: string; headers: IncomingMessage['headers']; body: Buffer }> = [] + const publishes: Array<{ method: string; contentType: string; payload: Record; files: number }> = [] + const server = createServer(async (request, response) => { + try { + const body = await readBody(request) + const url = request.url ?? '' + if (url === '/api/v1/shares/uploads' && request.method === 'POST') { + if (!stagedUploads) { + response.writeHead(404, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ message: 'Not Found' })) + return + } + const { assets } = JSON.parse(body.toString()) as { assets: Array<{ ref: string }> } + const host = request.headers.host + response.writeHead(201, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ + data: { + id: '01upload', + expires_at: '2026-10-09T21:00:00Z', + uploads: assets.map((asset) => ({ ref: asset.ref, method: 'PUT', url: `http://${host}/storage/${asset.ref}?sig=1`, headers: { Host: host } })) + } + })) + return + } + if (url.startsWith('/storage/') && request.method === 'PUT') { + puts.push({ url, headers: request.headers, body }) + response.writeHead(200) + response.end() + return + } + if (url.startsWith('/api/v1/shares')) { + const contentType = request.headers['content-type'] ?? '' + let payload: Record + let fileCount = 0 + if (contentType.startsWith('multipart/form-data')) { + const form = await new Request('http://localhost/', { method: 'POST', headers: { 'Content-Type': contentType }, body }).formData() + payload = JSON.parse(form.get('payload') as string) + fileCount = form.getAll('assets[]').length + } else { + payload = JSON.parse(JSON.parse(body.toString()).payload) + } + publishes.push({ method: request.method ?? '', contentType, payload, files: fileCount }) + response.writeHead(request.method === 'POST' ? 201 : 200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ id: 1, slug: 'trip', url: 'http://localhost/s/trip' })) + return + } + response.writeHead(404) + response.end() + } 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 AddressInfo + const client = createCloudSyncClient(`http://127.0.0.1:${address.port}`, 'test-only-token') + const platform = desktopPublishAssetPlatform(client, { + localPath: (rel) => path.join(vault, rel), + readRemote: async () => { throw new Error('not remote') } + }) + const input = { + note_path: 'Trip.md', + title: 'Trip', + markdown: '![](a.png) ![](b.png)', + assets: [ + { ref: 'a.png', name: 'a.png', mime: 'image/png', path: 'a.png' }, + { ref: 'b.png', name: 'b.png', mime: 'image/png', path: 'b.png' } + ] + } + const close = async () => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + return { client, platform, input, files, puts, publishes, close } +} + +it('stages attachments with streamed PUTs that carry their length, then publishes naming the upload', async () => { + const { client, platform, input, files, puts, publishes, close } = await fixture(true) + try { + const result = await publishWithStagedUploads(client, input, platform) + await publishWithStagedUploads(client, input, platform, { shareId: result.id }) + + expect(puts.slice(0, 2).map((put) => put.url).sort()).toEqual(['/storage/a.png?sig=1', '/storage/b.png?sig=1']) + for (const put of puts) { + const expected = put.url.startsWith('/storage/a.png') ? files[0]! : files[1]! + expect(put.headers['content-length']).toBe(String(expected.byteLength)) + expect(put.headers['transfer-encoding']).toBeUndefined() + expect(put.headers['content-type']).toBe('image/png') + expect(put.headers.authorization).toBeUndefined() + expect(put.body.equals(expected)).toBe(true) + } + expect(publishes).toEqual([ + expect.objectContaining({ method: 'POST', files: 0, payload: expect.objectContaining({ upload_id: '01upload', asset_refs: ['a.png', 'b.png'] }) }), + expect.objectContaining({ method: 'PUT', files: 0, payload: expect.objectContaining({ upload_id: '01upload', asset_refs: ['a.png', 'b.png'] }) }) + ]) + } finally { + await close() + } +}) + +it('publishes in one multipart request when the service answers 404 for staged uploads', async () => { + const { client, platform, input, puts, publishes, close } = await fixture(false) + try { + await publishWithStagedUploads(client, input, platform) + + expect(puts).toEqual([]) + expect(publishes).toEqual([ + expect.objectContaining({ method: 'POST', files: 2, payload: expect.objectContaining({ asset_refs: ['a.png', 'b.png'] }) }) + ]) + expect(publishes[0]?.payload).not.toHaveProperty('upload_id') + } finally { + await close() + } +}) diff --git a/apps/desktop/src/main/cloud-sync-client.test.ts b/apps/desktop/src/main/cloud-sync-client.test.ts index e3dd10a6..2d5c45de 100644 --- a/apps/desktop/src/main/cloud-sync-client.test.ts +++ b/apps/desktop/src/main/cloud-sync-client.test.ts @@ -203,6 +203,45 @@ describe('createCloudSyncClient', () => { expect(new Headers(options?.headers).has('Content-Type')).toBe(false) }) + it('streams a staged publish attachment to its presigned URL with an explicit length, never the token', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-publish-upload-')) + temporaryDirectories.push(directory) + const photo = path.join(directory, 'photo.png') + await writeFile(photo, Buffer.from([1, 2, 3, 4])) + const sent: Array<{ url: string; init: RequestInit; body: Buffer }> = [] + const fetchImplementation = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const chunks: Buffer[] = [] + for await (const chunk of init?.body as unknown as AsyncIterable) chunks.push(Buffer.from(chunk)) + sent.push({ url: String(input), init: init ?? {}, body: Buffer.concat(chunks) }) + return new Response(null, { status: 200 }) + }) as unknown as typeof fetch + const client = createCloudSyncClient('https://zennotes.org', 'secret-token', fetchImplementation) + const target = { ref: 'photo.png', method: 'PUT' as const, url: 'https://storage.test/shares/abc/photo.png?sig=1', headers: { Host: 'storage.test' } } + + await client.uploadPublishAsset(target, { mime: 'image/png', byteLength: 4, file: photo }) + + expect(sent).toHaveLength(1) + const headers = new Headers(sent[0]?.init.headers) + expect(sent[0]?.url).toBe(target.url) + expect(sent[0]?.init.method).toBe('PUT') + expect(sent[0]?.init.redirect).toBe('error') + expect(headers.get('content-length')).toBe('4') + expect(headers.get('content-type')).toBe('image/png') + expect(headers.get('authorization')).toBeNull() + expect([...(sent[0]?.body ?? [])]).toEqual([1, 2, 3, 4]) + + await expect(client.uploadPublishAsset(target, { mime: 'image/png', byteLength: 5, file: photo })) + .rejects.toMatchObject({ code: 'DIRECT_UPLOAD_SIZE_MISMATCH' }) + await expect(client.uploadPublishAsset({ ...target, url: 'http://storage.test/x' }, { mime: 'image/png', byteLength: 4, file: photo })) + .rejects.toThrow() + expect(sent).toHaveLength(1) + + const refused = createCloudSyncClient('https://zennotes.org', 'secret-token', (async () => + new Response('denied', { status: 403 })) as unknown as typeof fetch) + await expect(refused.uploadPublishAsset(target, { mime: 'image/png', byteLength: 4, bytes: new Uint8Array([1, 2, 3, 4]) })) + .rejects.toMatchObject({ status: 403, code: 'DIRECT_UPLOAD_FAILED' }) + }) + it('keeps files at the inline limit in the mutation request', async () => { const fetchImplementation = vi .fn() diff --git a/apps/desktop/src/main/cloud-sync-client.ts b/apps/desktop/src/main/cloud-sync-client.ts index d7066ffb..6149276c 100644 --- a/apps/desktop/src/main/cloud-sync-client.ts +++ b/apps/desktop/src/main/cloud-sync-client.ts @@ -10,6 +10,7 @@ import { createHash } from 'node:crypto' import { createReadStream, type ReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import type { + CloudPublishUploadTarget, CloudSyncCapacityConflict, CloudSyncConflict, CloudSyncConflictCode, @@ -57,12 +58,25 @@ export interface DesktopCloudSyncClientOptions { contentReferences?: boolean } +/** One staged publish attachment to PUT: a local file streamed from disk, or a + * remote vault's file already in memory. */ +export interface PublishAssetSource { + mime: string + byteLength: number + file?: string + bytes?: Uint8Array +} + +export type DesktopCloudSyncClient = CloudSyncApiClient & { + uploadPublishAsset(target: CloudPublishUploadTarget, source: PublishAssetSource): Promise +} + export function createCloudSyncClient( baseUrl: string, token: string, fetchImplementation: typeof fetch = fetch, options: DesktopCloudSyncClientOptions = {} -): CloudSyncApiClient { +): DesktopCloudSyncClient { const accountId = options.accountId ?? createHash('sha256').update(token).digest('hex') return new DesktopCloudSyncApiClient( (options.rateLimits ?? cloudSyncRateLimits).wrap( @@ -202,6 +216,56 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { return await this.completeDirectUpload(vaultId, instruction.id, mutation) } + /** + * PUT one staged publish attachment to the presigned URL the service gave it, + * with the same safeguards as a sync upload: HTTPS only, no redirects, and an + * explicit Content-Length (object storage refuses a chunked PUT, 411), from a + * file that must still be the size that was declared. + */ + async uploadPublishAsset(target: CloudPublishUploadTarget, source: PublishAssetSource): Promise { + this.signal?.throwIfAborted() + const uploadUrl = secureDirectUploadUrl(target.url) + const headers = new Headers(target.headers) + if (!headers.has('content-length')) headers.set('content-length', String(source.byteLength)) + if (!headers.has('content-type')) headers.set('content-type', source.mime) + + let reader: ReadStream | undefined + let body: FetchBody + if (source.file !== undefined) { + const sourceStats = await stat(source.file) + if (!sourceStats.isFile() || sourceStats.size !== source.byteLength) throw directUploadSizeMismatch() + body = (reader = createReadStream(source.file)) as unknown as FetchBody + } else if (source.bytes !== undefined) { + if (source.bytes.byteLength !== source.byteLength) throw directUploadSizeMismatch() + body = source.bytes as FetchBody + } else { + throw new Error('A publish upload needs a file or its bytes.') + } + + let response: Response + try { + response = await this.fetchImplementation(uploadUrl, { + method: target.method, + headers, + body, + signal: requestSignal(this.signal, DIRECT_UPLOAD_TIMEOUT_MS), + redirect: 'error', + ...(reader ? { duplex: 'half' } : {}) + }) + } finally { + reader?.destroy() + } + await response.body?.cancel().catch(() => {}) + + if (!response.ok) { + throw new CloudServiceRequestError( + `ZenNotes Cloud object upload failed (${response.status}).`, + response.status, + 'DIRECT_UPLOAD_FAILED' + ) + } + } + private async abortQuietly(vaultId: string, uploadId: string): Promise { await this.abortUpload(vaultId, uploadId).catch(() => {}) } diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index bae27e49..7fcd5f66 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import os from 'node:os' import path from 'node:path' import type { + CloudPublishUploadRequest, CloudAccountStatus, CloudBackupSchedule, CloudBackupScheduleResponse, @@ -175,7 +176,21 @@ async function setup( backupDownloadPath: vi.fn( (vaultId: string, backupId: string) => `/api/v1/vaults/${vaultId}/backups/${backupId}/download` - ) + ), + createPublishUpload: vi.fn(async (body: CloudPublishUploadRequest) => ({ + data: { + id: '01upload', + expires_at: '2026-08-10T12:30:00.000Z', + uploads: body.assets.map((asset) => ({ + ref: asset.ref, + method: 'PUT' as const, + url: `https://storage.test/${asset.ref}`, + headers: {} + })) + } + })), + abortPublishUpload: vi.fn(async () => {}), + uploadPublishAsset: vi.fn(async () => {}) } const service = new DesktopCloudSyncService({ storageDirectory, @@ -337,16 +352,56 @@ describe('DesktopCloudSyncService', () => { markdown: '# Launch' } + const files = { localPath: () => null, readRemote: async () => new Uint8Array() } + await expect(service.listPublishedNotes()).resolves.toEqual([]) - await expect(service.publishNote(input)).resolves.toMatchObject({ id: 42 }) - await expect(service.updatePublishedNote(42, input)).resolves.toMatchObject({ id: 42 }) + await expect(service.publishNote(input, files)).resolves.toMatchObject({ id: 42 }) + await expect(service.updatePublishedNote(42, input, files)).resolves.toMatchObject({ id: 42 }) await expect(service.unpublishNote(42)).resolves.toBeUndefined() - expect(client.publishNote).toHaveBeenCalledWith(input) - expect(client.updatePublishedNote).toHaveBeenCalledWith(42, input) + expect(client.publishNote).toHaveBeenCalledWith({ ...input, assets: [] }) + expect(client.updatePublishedNote).toHaveBeenCalledWith(42, { ...input, assets: [] }) expect(client.unpublishNote).toHaveBeenCalledWith(42) }) + it('stages a note\'s attachments from disk and publishes naming the upload', async () => { + const { service, client, localRoot } = await setup() + const photo = path.join(localRoot, 'attachements', 'photo.png') + await mkdir(path.dirname(photo), { recursive: true }) + await writeFile(photo, Buffer.from([1, 2, 3])) + const input = { + note_path: 'Notes/Launch.md', + title: 'Launch', + markdown: '![Photo](photo.png)', + assets: [{ ref: 'photo.png', name: 'photo.png', mime: 'image/png', path: 'attachements/photo.png' }] + } + const files = { localPath: (rel: string) => path.join(localRoot, rel), readRemote: vi.fn() } + + await expect(service.publishNote(input, files)).resolves.toMatchObject({ id: 42 }) + + expect(client.createPublishUpload).toHaveBeenCalledWith({ + assets: [{ + ref: 'photo.png', + name: 'photo.png', + mime: 'image/png', + byte_length: 3, + sha256: createHash('sha256').update(Buffer.from([1, 2, 3])).digest('hex') + }] + }) + expect(client.uploadPublishAsset).toHaveBeenCalledWith( + { ref: 'photo.png', method: 'PUT', url: 'https://storage.test/photo.png', headers: {} }, + { mime: 'image/png', byteLength: 3, file: photo } + ) + expect(client.publishNote).toHaveBeenCalledWith({ + note_path: 'Notes/Launch.md', + title: 'Launch', + markdown: '![Photo](photo.png)', + upload_id: '01upload', + asset_refs: ['photo.png'] + }) + expect(files.readRemote).not.toHaveBeenCalled() + }) + it('treats the optional published-note list as empty when publishing is unavailable', async () => { const disconnected = await setup([], undefined, async () => ({ state: 'disconnected', diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index f58d3af5..55b6bc53 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -37,6 +37,12 @@ import { import { setVaultSettings } from './vault' import type { CloudSyncApiClient } from '@zennotes/shared-domain/cloud-sync-api' import { CloudServiceRequestError, type DesktopCloudSyncClientOptions } from './cloud-sync-client' +import { publishWithStagedUploads } from '@zennotes/shared-domain/cloud-publish-uploads' +import { + desktopPublishAssetPlatform, + type DesktopPublishAssetFiles, + type PublishAssetUploader +} from './cloud-publish-assets' import { CLOUD_VAULT_REMOVED_MESSAGE, confirmCloudVaultMissing, isCloudResourceMissing, sameCloudVaultLink } from '@zennotes/shared-domain/cloud-vault-availability' import { createDesktopCloudSyncCoordinator, DesktopCloudSyncStateStore } from './cloud-sync-filesystem' @@ -46,6 +52,8 @@ type SyncClient = Pick< | 'listPublishedNotes' | 'publishNote' | 'updatePublishedNote' + | 'createPublishUpload' + | 'abortPublishUpload' | 'unpublishNote' | 'listVaults' | 'createVault' @@ -64,7 +72,7 @@ type SyncClient = Pick< | 'backupRestore' | 'restoreBackupNote' | 'backupDownloadPath' -> +> & PublishAssetUploader export interface DesktopCloudSyncServiceDependencies { storageDirectory: string @@ -123,17 +131,23 @@ export class DesktopCloudSyncService { } } - async publishNote(input: CloudPublishNoteInput): Promise { + async publishNote( + input: CloudPublishNoteInput, + files: DesktopPublishAssetFiles + ): Promise { const { client } = await this.connection() - return await client.publishNote(input) + return await publishWithStagedUploads(client, input, desktopPublishAssetPlatform(client, files)) } async updatePublishedNote( shareId: number, - input: CloudPublishNoteInput + input: CloudPublishNoteInput, + files: DesktopPublishAssetFiles ): Promise { const { client } = await this.connection() - return await client.updatePublishedNote(shareId, input) + return await publishWithStagedUploads(client, input, desktopPublishAssetPlatform(client, files), { + shareId + }) } async unpublishNote(shareId: number): Promise { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9c748844..f3a4ca71 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -195,6 +195,7 @@ import { import { CloudAuthManager, resolveCloudBaseUrl } from "./cloud-auth"; import { shouldForceGnomeLibsecret } from "./linux-password-store"; import { CloudAuthLoopbackServer } from "./cloud-auth-loopback"; +import type { DesktopPublishAssetFiles } from "./cloud-publish-assets"; import { createCloudSyncClient } from "./cloud-sync-client"; import { DesktopCloudSyncService } from "./cloud-sync-service"; import { CloudSyncWindowBarrier } from "./cloud-sync-window-barrier"; @@ -464,6 +465,24 @@ function getCloudSyncService(): DesktopCloudSyncService { return cloudSyncService; } +/** + * A published note's attachments, by the vault-relative paths the renderer + * names: resolved through the vault's traversal guard, never trusted as given. + */ +function publishAssetFiles(): DesktopPublishAssetFiles { + return { + localPath: (rel) => + isRemoteWorkspaceActive() + ? null + : absolutePath(requireVault().root, String(rel ?? "")), + readRemote: async (rel) => { + const response = + await requireRemoteWorkspaceClient().fetchAssetResponse(String(rel ?? "")); + return new Uint8Array(await response.arrayBuffer()); + }, + }; +} + function requireLocalCloudVaultRoot(): string { if (isRemoteWorkspaceActive()) { throw new Error("ZenNotes Cloud sync is only available for local vaults."); @@ -3052,12 +3071,16 @@ function registerIpc(): void { handle( IPC.CLOUD_PUBLISHED_NOTE_CREATE, (_event, input: CloudPublishNoteInput) => - getCloudSyncService().publishNote(input), + getCloudSyncService().publishNote(input, publishAssetFiles()), ); handle( IPC.CLOUD_PUBLISHED_NOTE_UPDATE, (_event, shareId: number, input: CloudPublishNoteInput) => - getCloudSyncService().updatePublishedNote(shareId, input), + getCloudSyncService().updatePublishedNote( + shareId, + input, + publishAssetFiles(), + ), ); handle(IPC.CLOUD_PUBLISHED_NOTE_DELETE, (_event, shareId: number) => getCloudSyncService().unpublishNote(shareId), diff --git a/packages/app-core/src/lib/cloud-publishing.test.ts b/packages/app-core/src/lib/cloud-publishing.test.ts index 0309cd35..4ac096d4 100644 --- a/packages/app-core/src/lib/cloud-publishing.test.ts +++ b/packages/app-core/src/lib/cloud-publishing.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import type { CloudServiceAccount } from '@zennotes/bridge-contract/cloud-sync' import { collectCloudPublishAssets, - prepareCloudPublishLogo, publishCloudNote, type CloudPublishingBridge } from './cloud-publishing' @@ -48,8 +47,7 @@ function setup(published = false, publishActive = true) { }] : []), publishCloudNote: createPublishedNote, - updateCloudPublishedNote, - readVaultAssetBase64: vi.fn(async () => 'AQID') + updateCloudPublishedNote } return { bridge, createPublishedNote, updateCloudPublishedNote } @@ -125,22 +123,33 @@ describe('cloud publishing', () => { expect(createPublishedNote).not.toHaveBeenCalled() }) - it('reads and publishes supported local attachments', async () => { + it('hands supported local attachments to the platform by vault path, without reading them', async () => { const { bridge, createPublishedNote } = setup() const withPhoto = { ...note, body: '![Photo](photo.png)', assetEmbeds: ['photo.png'] } useStore.setState({ assetFiles: [{ path: 'Notes/photo.png', name: 'photo.png' } as never] }) - const assets = await collectCloudPublishAssets(withPhoto, '/vault', bridge) + const assets = collectCloudPublishAssets(withPhoto, '/vault') await publishCloudNote(withPhoto, bridge, assets) - expect(bridge.readVaultAssetBase64).toHaveBeenCalledWith('Notes/photo.png') expect(createPublishedNote).toHaveBeenCalledWith(expect.objectContaining({ - assets: [{ ref: 'photo.png', name: 'photo.png', mime: 'image/png', base64: 'AQID' }] + assets: [{ ref: 'photo.png', name: 'photo.png', mime: 'image/png', path: 'Notes/photo.png' }] })) }) + it('allows 50 attachments and says how many fit beyond that', () => { + const refs = (count: number) => Array.from({ length: count }, (_, index) => `photo-${index}.png`) + useStore.setState({ + assetFiles: refs(51).map((ref) => ({ path: `Notes/${ref}`, name: ref }) as never) + }) + + expect(collectCloudPublishAssets({ ...note, assetEmbeds: refs(50) }, '/vault')).toHaveLength(50) + expect(() => collectCloudPublishAssets({ ...note, assetEmbeds: refs(51) }, '/vault')).toThrow( + 'This note has 51 attached files, but a published note can have at most 50. Remove some attachments and publish again.' + ) + }) + it('requires an active publishing entitlement', async () => { const { bridge, createPublishedNote } = setup(false, false) @@ -161,19 +170,4 @@ describe('cloud publishing', () => { appearance }) }) - - it('prepares only small safe raster logos', async () => { - const logo = new File([new Uint8Array([1, 2, 3])], 'brand.png', { type: 'image/png' }) - - await expect(prepareCloudPublishLogo(logo)).resolves.toEqual({ - ref: 'brand-logo', - name: 'brand.png', - mime: 'image/png', - base64: 'AQID' - }) - - await expect(prepareCloudPublishLogo( - new File([''], 'brand.svg', { type: 'image/svg+xml' }) - )).rejects.toThrow('PNG') - }) }) diff --git a/packages/app-core/src/lib/cloud-publishing.ts b/packages/app-core/src/lib/cloud-publishing.ts index e9d354b1..a089a08e 100644 --- a/packages/app-core/src/lib/cloud-publishing.ts +++ b/packages/app-core/src/lib/cloud-publishing.ts @@ -17,7 +17,6 @@ export type CloudPublishingBridge = Pick< | 'listCloudPublishedNotes' | 'publishCloudNote' | 'updateCloudPublishedNote' - | 'readVaultAssetBase64' > export interface PublishableCloudNote { @@ -108,7 +107,7 @@ export async function publishCloudNoteWithFeedback( appearance?: CloudPublishAppearanceInput ): Promise { const vaultRoot = useStore.getState().vault?.root ?? '' - const assets = await collectCloudPublishAssets(note, vaultRoot, bridge) + const assets = collectCloudPublishAssets(note, vaultRoot) const outcome = await publishCloudNote(note, bridge, assets, appearance) bridge.clipboardWriteText(outcome.url) useToastStore.getState().addToast( @@ -120,77 +119,37 @@ export async function publishCloudNoteWithFeedback( return outcome } -const PUBLISH_LOGO_MIMES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/avif']) -const MAX_PUBLISH_LOGO_BYTES = 1024 * 1024 +/** Matches the service's limit for apps that upload attachments ahead of the + * publish; it checks the count, sizes and types again before anything is sent. */ +const MAX_PUBLISH_ATTACHMENTS = 50 -export async function prepareCloudPublishLogo(file: File): Promise { - if (!PUBLISH_LOGO_MIMES.has(file.type)) { - throw new Error('Choose a PNG, JPEG, WebP, or AVIF logo.') - } - if (file.size > MAX_PUBLISH_LOGO_BYTES) { - throw new Error('The logo may not be larger than 1 MB.') - } - - const bytes = new Uint8Array(await file.arrayBuffer()) - let binary = '' - const chunkSize = 0x8000 - for (let offset = 0; offset < bytes.length; offset += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) - } - - return { - ref: 'brand-logo', - name: file.name || 'brand-logo', - mime: file.type, - base64: btoa(binary) - } -} - -const MAX_PUBLISH_ASSET_BYTES = 10 * 1024 * 1024 -const MAX_PUBLISH_ASSET_TOTAL_BYTES = 25 * 1024 * 1024 - -export async function collectCloudPublishAssets( +/** + * The attachments a note embeds, by vault-relative path. The platform reads + * each file itself when it publishes, so nothing is loaded here. + */ +export function collectCloudPublishAssets( note: PublishableCloudNote, - vaultRoot: string, - bridge: Pick -): Promise { + vaultRoot: string +): CloudPublishAssetInput[] { const refs = [...new Set(note.assetEmbeds)] if (refs.length === 0) return [] if (!vaultRoot) throw new Error('Open a local vault before publishing attachments.') - if (refs.length > 50) throw new Error('A public note can include up to 50 attachments.') - - let totalBytes = 0 - const assets: CloudPublishAssetInput[] = [] - for (const ref of refs) { - const assetPath = resolveAssetVaultRelativePath(vaultRoot, note.path, ref) - if (!assetPath) throw new Error(`ZenNotes could not read the attachment “${ref}”.`) - const base64 = await bridge.readVaultAssetBase64(assetPath) - const byteLength = base64ByteLength(base64) - if (byteLength > MAX_PUBLISH_ASSET_BYTES) { - throw new Error(`The attachment “${ref}” is larger than 10 MB.`) - } - totalBytes += byteLength - if (totalBytes > MAX_PUBLISH_ASSET_TOTAL_BYTES) { - throw new Error('Published-note attachments may not exceed 25 MB in total.') - } + if (refs.length > MAX_PUBLISH_ATTACHMENTS) { + throw new Error( + `This note has ${refs.length} attached files, but a published note can have at most ${MAX_PUBLISH_ATTACHMENTS}. Remove some attachments and publish again.` + ) + } + return refs.map((ref) => { + const path = resolveAssetVaultRelativePath(vaultRoot, note.path, ref) + if (!path) throw new Error(`ZenNotes could not read the attachment “${ref}”.`) const name = assetName(ref) const mime = publishableMime(null, name) if (!mime) { throw new Error(`The attachment “${ref}” is not a supported image, audio, video, or PDF.`) } - assets.push({ ref, name, mime, base64 }) - } - return assets -} - -function base64ByteLength(value: string): number { - const normalized = value.replace(/\s/g, '') - if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z\d+/]*={0,2}$/.test(normalized)) { - throw new Error('ZenNotes received invalid attachment data.') - } - const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0 - return (normalized.length / 4) * 3 - padding + return { ref, name, mime, path } + }) } function assetName(ref: string): string { diff --git a/packages/bridge-contract/src/cloud-sync.ts b/packages/bridge-contract/src/cloud-sync.ts index 2d88b6c6..045332e7 100644 --- a/packages/bridge-contract/src/cloud-sync.ts +++ b/packages/bridge-contract/src/cloud-sync.ts @@ -481,16 +481,59 @@ export interface CloudPublishedNoteAppearance { export interface CloudPublishAppearanceInput { theme: string; /** Omitted preserves the current logo, null removes it, and a file replaces it. */ - logo?: CloudPublishAssetInput | null; + logo?: CloudPublishEncodedAsset | null; } +/** + * An attachment to publish, by its vault-relative path: the platform reads the + * file itself (desktop main, the phone shells), so its bytes never cross the + * bridge as base64. + */ export interface CloudPublishAssetInput { + ref: string; + name: string; + mime: string; + path: string; +} + +/** An attachment carried inside a one-request (multipart) publish. */ +export interface CloudPublishEncodedAsset { ref: string; name: string; mime: string; base64: string; } +/** What the server is told about each attachment before it is uploaded. */ +export interface CloudPublishUploadAsset { + ref: string; + name: string; + mime: string; + byte_length: number; + sha256: string; +} + +export interface CloudPublishUploadRequest { + /** The share a republish replaces; omitted for a new publish. */ + share_id?: number; + assets: CloudPublishUploadAsset[]; +} + +export interface CloudPublishUploadTarget { + ref: string; + method: 'PUT'; + url: string; + headers: Record; +} + +export interface CloudPublishUploadResponse { + data: { + id: string; + expires_at: string; + uploads: CloudPublishUploadTarget[]; + }; +} + export interface CloudPublishedNoteCollection { data: CloudPublishedNote[]; } diff --git a/packages/shared-domain/src/cloud-publish-uploads.test.ts b/packages/shared-domain/src/cloud-publish-uploads.test.ts new file mode 100644 index 00000000..fe069e6f --- /dev/null +++ b/packages/shared-domain/src/cloud-publish-uploads.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CloudPublishAssetInput, CloudPublishUploadTarget } from '@zennotes/bridge-contract/cloud-sync' +import { + mapWithConcurrency, + publishWithStagedUploads, + type CloudPublishApi, + type CloudPublishAssetPlatform +} from './cloud-publish-uploads' + +const note = { note_path: 'Notes/Trip.md', title: 'Trip', markdown: '# Trip\n\n![[a.png]] ![[b.png]]' } +const assets: CloudPublishAssetInput[] = [ + { ref: 'a.png', name: 'a.png', mime: 'image/png', path: 'attachements/a.png' }, + { ref: 'b.png', name: 'b.png', mime: 'image/png', path: 'attachements/b.png' } +] +const published = { id: 7, slug: 'trip', url: 'https://zennotes.org/s/trip' } + +class StatusError extends Error { + constructor(readonly status: number) { + super(`HTTP ${status}`) + } +} + +function setup(overrides: Partial = {}) { + const api: CloudPublishApi = { + publishNote: vi.fn(async () => published), + updatePublishedNote: vi.fn(async () => published), + createPublishUpload: vi.fn(async (body) => ({ + data: { + id: '01upload', + expires_at: '2026-10-09T20:00:00Z', + uploads: body.assets.map((asset: { ref: string }): CloudPublishUploadTarget => ({ + ref: asset.ref, + method: 'PUT', + url: `https://storage.test/${asset.ref}`, + headers: { Host: 'storage.test' } + })) + } + })), + abortPublishUpload: vi.fn(async () => {}), + ...overrides + } + const platform: CloudPublishAssetPlatform = { + describe: vi.fn(async (asset) => ({ byteLength: asset.ref.length * 100, sha256: `hash-${asset.ref}`, handle: `/vault/${asset.path}` })), + upload: vi.fn(async () => {}), + readBase64: vi.fn(async (asset) => `base64-${asset.ref}`) + } + return { api, platform } +} + +describe('publishWithStagedUploads', () => { + it('stages each attachment, uploads it to its own target, then publishes naming the upload', async () => { + const { api, platform } = setup() + const progress: Array<[number, number]> = [] + + const result = await publishWithStagedUploads(api, { ...note, assets }, platform, { + onProgress: (uploaded, total) => progress.push([uploaded, total]) + }) + + expect(result).toEqual(published) + expect(api.createPublishUpload).toHaveBeenCalledWith({ + assets: [ + { ref: 'a.png', name: 'a.png', mime: 'image/png', byte_length: 500, sha256: 'hash-a.png' }, + { ref: 'b.png', name: 'b.png', mime: 'image/png', byte_length: 500, sha256: 'hash-b.png' } + ] + }) + expect(platform.upload).toHaveBeenCalledWith( + expect.objectContaining({ ref: 'a.png', url: 'https://storage.test/a.png' }), + expect.objectContaining({ ref: 'a.png', handle: '/vault/attachements/a.png', byteLength: 500 }) + ) + expect(api.publishNote).toHaveBeenCalledWith({ ...note, upload_id: '01upload', asset_refs: ['a.png', 'b.png'] }) + expect(platform.readBase64).not.toHaveBeenCalled() + expect(api.abortPublishUpload).not.toHaveBeenCalled() + expect(progress).toEqual([[1, 2], [2, 2]]) + }) + + it('republishes a share with an upload made for it', async () => { + const { api, platform } = setup() + + await publishWithStagedUploads(api, { ...note, assets }, platform, { shareId: 7 }) + + expect(api.createPublishUpload).toHaveBeenCalledWith(expect.objectContaining({ share_id: 7 })) + expect(api.updatePublishedNote).toHaveBeenCalledWith(7, { ...note, upload_id: '01upload', asset_refs: ['a.png', 'b.png'] }) + expect(api.publishNote).not.toHaveBeenCalled() + }) + + it('publishes in one request when the service has no staged uploads', async () => { + const { api, platform } = setup({ createPublishUpload: vi.fn(async () => { throw new StatusError(404) }) }) + + await publishWithStagedUploads(api, { ...note, assets }, platform) + + expect(platform.upload).not.toHaveBeenCalled() + expect(api.publishNote).toHaveBeenCalledWith({ + ...note, + assets: [ + { ref: 'a.png', name: 'a.png', mime: 'image/png', base64: 'base64-a.png' }, + { ref: 'b.png', name: 'b.png', mime: 'image/png', base64: 'base64-b.png' } + ] + }) + }) + + it('reports a refused upload instead of falling back', async () => { + const refusal = Object.assign(new StatusError(422), { message: 'This note has 51 attached files' }) + const { api, platform } = setup({ createPublishUpload: vi.fn(async () => { throw refusal }) }) + + await expect(publishWithStagedUploads(api, { ...note, assets }, platform)).rejects.toBe(refusal) + expect(api.publishNote).not.toHaveBeenCalled() + }) + + it('cancels the upload when a file or the publish fails', async () => { + const failingUpload = setup() + vi.mocked(failingUpload.platform.upload).mockRejectedValueOnce(new Error('offline')) + await expect(publishWithStagedUploads(failingUpload.api, { ...note, assets }, failingUpload.platform)).rejects.toThrow('offline') + expect(failingUpload.api.abortPublishUpload).toHaveBeenCalledWith('01upload') + expect(failingUpload.api.publishNote).not.toHaveBeenCalled() + + const failingPublish = setup({ publishNote: vi.fn(async () => { throw new StatusError(422) }) }) + await expect(publishWithStagedUploads(failingPublish.api, { ...note, assets }, failingPublish.platform)).rejects.toBeInstanceOf(StatusError) + expect(failingPublish.api.abortPublishUpload).toHaveBeenCalledWith('01upload') + }) + + it('sends a note without attachments, or one that changes the logo, in one request', async () => { + const plain = setup() + await publishWithStagedUploads(plain.api, note, plain.platform) + expect(plain.api.createPublishUpload).not.toHaveBeenCalled() + expect(plain.api.publishNote).toHaveBeenCalledWith({ ...note, assets: [] }) + + const logo = setup() + const appearance = { theme: 'system', logo: { ref: 'brand-logo', name: 'logo.png', mime: 'image/png', base64: 'AQID' } } + await publishWithStagedUploads(logo.api, { ...note, assets, appearance }, logo.platform) + expect(logo.api.createPublishUpload).not.toHaveBeenCalled() + expect(logo.platform.readBase64).toHaveBeenCalledTimes(2) + }) +}) + +describe('mapWithConcurrency', () => { + it('keeps order and never runs more than the limit at once', async () => { + let running = 0 + let peak = 0 + const results = await mapWithConcurrency([1, 2, 3, 4, 5, 6, 7], 3, async (value) => { + running += 1 + peak = Math.max(peak, running) + await new Promise((resolve) => setTimeout(resolve, 5)) + running -= 1 + return value * 10 + }) + + expect(results).toEqual([10, 20, 30, 40, 50, 60, 70]) + expect(peak).toBe(3) + }) +}) diff --git a/packages/shared-domain/src/cloud-publish-uploads.ts b/packages/shared-domain/src/cloud-publish-uploads.ts new file mode 100644 index 00000000..d4d87680 --- /dev/null +++ b/packages/shared-domain/src/cloud-publish-uploads.ts @@ -0,0 +1,160 @@ +import type { + CloudPublishAssetInput, + CloudPublishedNoteResult, + CloudPublishEncodedAsset, + CloudPublishNoteInput, + CloudPublishUploadRequest, + CloudPublishUploadResponse, + CloudPublishUploadTarget +} from '@zennotes/bridge-contract/cloud-sync' +import type { CloudPublishRequest } from './cloud-sync-api' + +/** The service calls a publish needs, from whichever client the platform uses. */ +export interface CloudPublishApi { + publishNote(input: CloudPublishRequest): Promise + updatePublishedNote(shareId: number, input: CloudPublishRequest): Promise + createPublishUpload(body: CloudPublishUploadRequest): Promise + abortPublishUpload(uploadId: string): Promise +} + +/** An attachment the platform has looked at: its size and SHA-256, and a handle + * the platform's own upload understands (a file path, a native URI, bytes). */ +export interface DescribedPublishAsset { + byteLength: number + sha256: string + handle: Handle +} + +/** The file access a publish needs, which only the platform can do. */ +export interface CloudPublishAssetPlatform { + describe(asset: CloudPublishAssetInput): Promise> + upload( + target: CloudPublishUploadTarget, + asset: CloudPublishAssetInput & DescribedPublishAsset + ): Promise + /** The file's bytes, for a service without staged uploads (the one-request publish). */ + readBase64(asset: CloudPublishAssetInput): Promise +} + +export interface CloudPublishUploadOptions { + /** Republish this share instead of creating one. */ + shareId?: number + /** Files read or uploaded at once. */ + concurrency?: number + /** Called after each attachment is in storage. */ + onProgress?(uploaded: number, total: number): void +} + +const DEFAULT_CONCURRENCY = 4 + +/** + * Publish a note, sending its attachments straight to storage first: the + * service checks every limit before a byte is sent, then signs one PUT per + * file, and the publish only names the upload. A service without staged + * uploads answers 404, and the note goes out the old way, in one request. + */ +export async function publishWithStagedUploads( + api: CloudPublishApi, + input: CloudPublishNoteInput, + platform: CloudPublishAssetPlatform, + options: CloudPublishUploadOptions = {} +): Promise { + const { assets = [], ...note } = input + const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY) + const send = (request: CloudPublishRequest): Promise => + options.shareId === undefined ? api.publishNote(request) : api.updatePublishedNote(options.shareId, request) + const inOneRequest = async (): Promise => + await send({ ...note, assets: await encodeAll(assets, platform, concurrency) }) + + // Staged uploads carry no logo: a logo change goes the one-request way. + if (assets.length === 0 || (note.appearance?.logo !== undefined && note.appearance.logo !== null)) { + return await inOneRequest() + } + + const described = await mapWithConcurrency(assets, concurrency, async (asset) => ({ + ...asset, + ...(await platform.describe(asset)) + })) + + let upload: CloudPublishUploadResponse['data'] + try { + upload = (await api.createPublishUpload({ + ...(options.shareId === undefined ? {} : { share_id: options.shareId }), + assets: described.map((asset) => ({ + ref: asset.ref, + name: asset.name, + mime: asset.mime, + byte_length: asset.byteLength, + sha256: asset.sha256 + })) + })).data + } catch (error) { + if (errorStatus(error) === 404) return await inOneRequest() + throw error + } + + try { + const targets = new Map(upload.uploads.map((target) => [target.ref, target])) + let uploaded = 0 + await mapWithConcurrency(described, concurrency, async (asset) => { + const target = targets.get(asset.ref) + if (!target) throw new Error(`ZenNotes Cloud did not prepare an upload for “${asset.name}”.`) + await platform.upload(target, asset) + uploaded += 1 + options.onProgress?.(uploaded, described.length) + }) + + return await send({ ...note, upload_id: upload.id, asset_refs: described.map((asset) => asset.ref) }) + } catch (error) { + // Cancelling an upload a publish already recorded changes nothing, so this + // is safe even when the publish committed and only its answer was lost. + await api.abortPublishUpload(upload.id).catch(() => {}) + throw error + } +} + +async function encodeAll( + assets: CloudPublishAssetInput[], + platform: CloudPublishAssetPlatform, + concurrency: number +): Promise { + return await mapWithConcurrency(assets, concurrency, async (asset) => ({ + ref: asset.ref, + name: asset.name, + mime: asset.mime, + base64: await platform.readBase64(asset) + })) +} + +function errorStatus(error: unknown): number | null { + if (error && typeof error === 'object' && 'status' in error) { + const status = (error as { status: unknown }).status + return typeof status === 'number' ? status : null + } + return null +} + +/** Map in order, at most `limit` at a time; the first failure rejects. */ +export async function mapWithConcurrency( + items: readonly Item[], + limit: number, + run: (item: Item, index: number) => Promise +): Promise { + const results = new Array(items.length) + let next = 0 + let failed = false + const worker = async (): Promise => { + while (!failed && next < items.length) { + const index = next + next += 1 + try { + results[index] = await run(items[index] as Item, index) + } catch (error) { + failed = true + throw error + } + } + } + await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker)) + return results +} diff --git a/packages/shared-domain/src/cloud-sync-api.test.ts b/packages/shared-domain/src/cloud-sync-api.test.ts index 6d2c4203..06e617f6 100644 --- a/packages/shared-domain/src/cloud-sync-api.test.ts +++ b/packages/shared-domain/src/cloud-sync-api.test.ts @@ -323,6 +323,60 @@ describe('CloudSyncApiClient', () => { expect([...new Uint8Array(await (asset as File).arrayBuffer())]).toEqual([1, 2, 3]) }) + it('stages published-note attachments and publishes naming the upload, without files', async () => { + const requests: CloudSyncHttpRequest[] = [] + const client = new CloudSyncApiClient({ + async request(request: CloudSyncHttpRequest): Promise { + requests.push(request) + return {} as Response + } + }) + const upload = { + share_id: 42, + assets: [{ ref: 'photo.png', name: 'photo.png', mime: 'image/png', byte_length: 3, sha256: 'a'.repeat(64) }] + } + + await client.createPublishUpload(upload) + await client.updatePublishedNote(42, { + note_path: 'inbox/Photo.md', + title: 'Photo', + markdown: '![Photo](photo.png)', + appearance: { theme: 'paper', logo: null }, + upload_id: '01upload', + asset_refs: ['photo.png'] + }) + await client.abortPublishUpload('01 upload') + + expect(requests).toEqual([ + { method: 'POST', path: '/api/v1/shares/uploads', body: upload }, + { + method: 'PUT', + path: '/api/v1/shares/42', + body: { + payload: JSON.stringify({ + note_path: 'inbox/Photo.md', + title: 'Photo', + markdown: '![Photo](photo.png)', + appearance: { theme: 'paper', logo_action: 'remove' }, + tikz_svgs: [], + asset_refs: ['photo.png'], + upload_id: '01upload' + }) + }, + timeoutMs: 300_000 + }, + { method: 'DELETE', path: '/api/v1/shares/uploads/01%20upload' } + ]) + await expect(client.publishNote({ + note_path: 'inbox/Photo.md', + title: 'Photo', + markdown: '', + appearance: { theme: 'paper', logo: { ref: 'brand-logo', name: 'logo.png', mime: 'image/png', base64: 'AQID' } }, + upload_id: '01upload', + asset_refs: [] + })).rejects.toThrow('A staged publish cannot replace the logo.') + }) + it('serializes published appearance and a replacement logo', async () => { const requests: CloudSyncHttpRequest[] = [] const client = new CloudSyncApiClient({ diff --git a/packages/shared-domain/src/cloud-sync-api.ts b/packages/shared-domain/src/cloud-sync-api.ts index 425ebe9b..91552e41 100644 --- a/packages/shared-domain/src/cloud-sync-api.ts +++ b/packages/shared-domain/src/cloud-sync-api.ts @@ -8,9 +8,12 @@ import type { CloudBackupSnapshotCollection, CloudBackupSnapshotItemCollection, CloudBackupSnapshotResponse, + CloudPublishAppearanceInput, CloudPublishedNoteCollection, CloudPublishedNoteResult, - CloudPublishNoteInput, + CloudPublishEncodedAsset, + CloudPublishUploadRequest, + CloudPublishUploadResponse, CloudServiceAccountResponse, CloudSyncChangeResponse, CloudSyncContentRequestOptions, @@ -44,6 +47,23 @@ export { type CloudSyncResponseHeaders } from './cloud-sync-rate-limit' +/** The note a publish sends, without its attachments. */ +export interface CloudPublishNoteFields { + note_path: string + title: string + markdown: string + appearance?: CloudPublishAppearanceInput +} + +/** + * A publish as sent to the service: the attachments either ride inside the + * request (multipart, the one-request path) or were staged ahead of it and are + * named by the upload's id. + */ +export type CloudPublishRequest = + | (CloudPublishNoteFields & { assets?: CloudPublishEncodedAsset[] }) + | (CloudPublishNoteFields & { upload_id: string; asset_refs: string[] }) + export interface CloudSyncHttpRequest { method: 'GET' | 'POST' | 'PUT' | 'DELETE' path: string @@ -171,7 +191,7 @@ export class CloudSyncApiClient { return this.http.request({ method: 'GET', path: '/api/v1/shares' }) } - async publishNote(input: CloudPublishNoteInput): Promise { + async publishNote(input: CloudPublishRequest): Promise { return this.http.request({ method: 'POST', path: '/api/v1/shares', @@ -180,9 +200,22 @@ export class CloudSyncApiClient { }) } + /** Stage a publish's attachments: answers one presigned PUT per file, or 404 + * where the service has no staged uploads. */ + async createPublishUpload(body: CloudPublishUploadRequest): Promise { + return this.http.request({ method: 'POST', path: '/api/v1/shares/uploads', body }) + } + + async abortPublishUpload(uploadId: string): Promise { + await this.http.request({ + method: 'DELETE', + path: `/api/v1/shares/uploads/${encodeURIComponent(uploadId)}` + }) + } + async updatePublishedNote( shareId: number, - input: CloudPublishNoteInput + input: CloudPublishRequest ): Promise { return this.http.request({ method: 'PUT', @@ -389,7 +422,24 @@ export class CloudSyncApiClient { } } -function publishedNoteBody(input: CloudPublishNoteInput): { payload: string } | FormData { +function publishedNoteBody(input: CloudPublishRequest): { payload: string } | FormData { + if ('upload_id' in input) { + // The files are already in storage; the publish names them and their upload. + const { upload_id, asset_refs, appearance, ...note } = input + if (appearance?.logo) throw new Error('A staged publish cannot replace the logo.') + return { + payload: JSON.stringify({ + ...note, + ...(appearance === undefined + ? {} + : { appearance: { theme: appearance.theme, logo_action: appearance.logo === null ? 'remove' : 'keep' } }), + tikz_svgs: [], + asset_refs, + upload_id + }) + } + } + const { assets = [], appearance, ...note } = input const brandLogo = appearance?.logo const serializedAppearance = From 2be79691867c03009f971fde9ca68b41327f1348 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 9 Oct 2026 15:22:54 -0500 Subject: [PATCH 2/4] Docs(ideas): staged uploads for published notes, the design 2.66.0 builds The plan behind 1c05780e and website e94851c: why a published note was capped at 20 attachments, the stage-then-publish flow, why not literal batches of 20, and the three decisions made with Adib on October 9 (50 attachments, 100 MB per note, no logo in the publish). Its status banner says what shipped and what is still design: progress in the publish dialog, and reusing unchanged attachments on republish. --- docs/ideas/publish-staged-uploads.md | 159 +++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/ideas/publish-staged-uploads.md diff --git a/docs/ideas/publish-staged-uploads.md b/docs/ideas/publish-staged-uploads.md new file mode 100644 index 00000000..1d780d1b --- /dev/null +++ b/docs/ideas/publish-staged-uploads.md @@ -0,0 +1,159 @@ +# Staged uploads for published notes + +Lift the 20-attachment limit on published notes by sending attachments ahead of +the publish, instead of inside it. + +> **Status (October 9, 2026).** Built the same day for 2.66.0. The service side is +> website `e94851c` (on in production behind `CLOUD_PUBLISH_UPLOADS`, verified +> live with a 30-image note); the apps share `publishWithStagedUploads` in +> shared-domain (desktop `1c05780e`; the phone shells call it through their native +> file hashing and upload). Built as designed below, with 50 attachments and +> 100 MB per note. Not built yet: upload progress in the publish dialog (the +> routine reports it through `onProgress`, nothing shows it), and referencing +> unchanged attachments by hash on republish. + +## Why the limit exists + +Publishing (`POST /api/v1/shares`, `PUT /api/v1/shares/{share}`) sends one +multipart request: a JSON `payload` (markdown, appearance, `asset_refs`) plus +every attachment as `assets[]` and, when it changes, a `brand_logo`. PHP drops +every file past `max_file_uploads` before Laravel sees the request. Production +runs PHP's default of 20, and Laravel Cloud has no setting for it, so the server +now caps a note at 20 attachments and says so (19 when a new logo rides along). + +The one request has other costs too: + +- **25 MB per publish.** The whole request is capped, not the note's real needs. +- **Memory.** Desktop reads every attachment as base64 and moves it across IPC + twice (read, then publish); the phones' CapacitorHttp turns FormData into + base64 entries. A 40-image note is held in memory several times over. +- **All or nothing.** One slow image holds up the request until it times out + (300 s), and a retry sends everything again. + +## Goal + +- A published note can have up to **50 attachments and 100 MB in total**, each + still within the plan's per-file limit (`publish_max_asset_bytes`, 10 MB on + the Publish add-on). +- No dependence on PHP's file limit or on one request's size. +- Attachments stream from disk, with progress ("Uploading 12 of 40"). +- Capacity, locking and cleanup stay as strict as today. +- Apps already in people's hands keep working, with the 20 limit. + +## Plan: stage, then publish + +The flow sync already uses for large files (`SyncUploadController`), applied to +published notes. + +1. **Start an upload.** `POST /api/v1/shares/uploads` with the note path and one + entry per attachment: `ref`, file name, mime type, byte length, SHA-256. The + server checks the count (50), the note's total (100 MB), mime types, per-file + sizes and publish capacity (as the publish pre-check does today), records a + session, and answers with one presigned PUT per attachment (Content-Type and + Content-Length signed, 15 minutes), staged under + `shares/staging/{user}/{session}/`. +2. **Upload.** The app PUTs each attachment straight to storage, four to six at + a time, streaming from disk. No PHP, no base64, no file-count limit. A failed + file is retried alone. +3. **Publish.** The usual `POST`/`PUT /api/v1/shares` with JSON only: the payload + plus `upload_id`, and no logo. The server checks every staged object (exists, + size, SHA-256: sync's `verifyUploadedObject`, which reads each object once; + the 100 MB total keeps that to a second or two), then, under the same account + lock as today, checks capacity again, moves the objects into `shares/{slug}/`, + records the assets, and marks the session used. A republish deletes the old + objects after commit, as it does now. +4. **Clean up.** A scheduled job deletes staged objects of sessions that expired + or were never used (sync's `PruneExpiredSyncUploads` pattern: every 5 minutes, + with a grace period). + +Attachments the note already has on its current publication could be referenced +by hash instead of uploaded again, so republishing a long note after a typo fix +sends nothing but text. That is a follow-up, not part of the first version. + +### Why not literally "batches of 20" + +The alternative is the same session with a multipart endpoint that takes up to +20 files per call. It needs no presigned URLs, but every byte still passes +through PHP, desktop and phones still move base64, and each batch keeps the +per-request size cap. Uploading straight to storage removes all three and +reuses code that is already tested in production: presigning, verification and +pruning on the server, the streaming PUT on desktop (including the explicit +Content-Length fix for the 411 problem), and the phones' native `ZenDirectUpload` +plugin. If presigned uploads hit a snag, the batch endpoint is the fallback with +the same session model. + +## Work by repo + +**Website** + +- `publish_upload_sessions` and `publish_upload_assets` tables (sync's upload + table is tied to vaults, so it is not reused). +- `ShareUploadController` (create, abort) with the shares middleware stack + (`entitled:publish`, `share:manage`, `throttle:shares`); a service built on + sync's presign and verify helpers. +- `StoreSharedNoteRequest` accepts `upload_id` instead of files and refuses a + logo on that path; the controller's record and replace paths take staged + objects. +- Two server settings: attachments per note (50) and total per note (100 MB), + next to the existing per-file limit from the plan. +- The multipart path stays for older apps, with the 20 limit and its logo + support. The account API advertises the new path and both limits, so apps + know which path to use and what to check before uploading. +- Prune job and schedule; behind a flag (`CLOUD_PUBLISH_UPLOADS`) until the apps + ship. + +**Desktop and shared code** (`zennotes`) + +- `shared-domain/src/cloud-sync-api.ts`: start-upload and publish-with-upload + calls next to the current ones. +- `app-core/src/lib/cloud-publishing.ts`: collect attachment paths, sizes and + hashes instead of base64; check the server's advertised limits; progress in + the publish dialog. Remove `prepareCloudPublishLogo`, which nothing calls. +- Desktop main: stream attachments with the existing direct-upload client. + +**Phones** (`zennotesios`, `zennotesandroid`) + +- Upload staged attachments with `ZenDirectUpload`, as sync does. +- Adopt the core release that carries the client change. + +## Order + +0. **Now, small:** the app still says "A public note can include up to 50 + attachments" when a note has more than 50. Make it check 20 and say so, in + the next desktop release and phone core adoption. +1. Website: sessions, staging, prune job, flag off. Deploy. +2. Desktop: the new client path, used only when the server advertises it. +3. Phones: the same, then store releases. +4. Turn the flag on. The staged path allows 50 attachments and 100 MB per note; + the multipart path keeps 20. + +## Tests + +- Website: start-upload validation (count over 50, total over 100 MB, mime, + per-file size, capacity), a logo on the staged path refused, a missing or + mismatched staged object refused, a session used once only, another user's + session refused, expiry and pruning, two publishes racing for the last + capacity, the multipart path unchanged (logo included). +- Clients: unit tests for the new calls, with the old path kept green. +- End to end: publish a 50-image note from the built desktop app over CDP and + from both phone simulators, then a live check against production with the QA + account, as on October 9. + +## Decisions (October 9, 2026) + +- **50 attachments per note, not 100.** The publish API was designed and load + tested for 50, the apps already check 50, and the Publish add-on's 1 GB across + up to 100 notes averages about 10 MB a note. Publishing is barely used so far + (two published notes in production), and the limit is a server setting the + apps read, so it can rise later without an app release. +- **100 MB per note.** Without a total, one note could take nearly the whole + 1 GB, and a page that heavy is unusable on a phone. The total also bounds the + publish-time verification (the server reads each staged file once) and keeps + public notes from turning into file hosting. 100 MB fits 50 photos or + screenshots at a typical 2 MB; the 10 MB per-file limit stays. +- **No logo in the publish request.** No current app sends one: the publish + dialog says theme and logo are managed for the whole publication in ZenNotes + Cloud, `prepareCloudPublishLogo` has no callers, and the public page prefers + the account-wide logo, so the per-note logo is legacy. The staged path takes + none; the multipart path keeps accepting it for older apps. This also retires + the "19 with a new logo" special case. From 54f80ae069abc038e62999294bae26e2cfddcbd3 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 9 Oct 2026 15:26:15 -0500 Subject: [PATCH 3/4] Release: align desktop and shared packages at 2.66.0 --- apps/desktop/package.json | 2 +- apps/quicklook/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 20 ++++++++++---------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ddae5d7f..43fab915 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.65.0", + "version": "2.66.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 261747ca..194723bc 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.65.0", + "version": "2.66.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 16a5ad02..6713f540 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.65.0", + "version": "2.66.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 0588e4ab..f10e9503 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.65.0", + "version": "2.66.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 d2ebb47d..0099081c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.65.0", + "version": "2.66.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.65.0", + "version": "2.66.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.65.0", + "version": "2.66.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.65.0", + "version": "2.66.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -987,7 +987,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.65.0", + "version": "2.66.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.65.0", + "version": "2.66.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16360,7 +16360,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.65.0", + "version": "2.66.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16447,14 +16447,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.65.0", + "version": "2.66.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.65.0", + "version": "2.66.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16466,7 +16466,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.65.0" + "version": "2.66.0" } } } diff --git a/package.json b/package.json index 8af523d8..cbf05b14 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.65.0", + "version": "2.66.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 ed9b823e..8023576f 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.65.0", + "version": "2.66.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 30f574f9..55435fbf 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.65.0", + "version": "2.66.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 512911eb..7164bc50 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.65.0", + "version": "2.66.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 6216544e..97e0d9c5 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.65.0", + "version": "2.66.0", "type": "module", "exports": { ".": "./src/index.ts" From 461fe76dff0522e6d90fb6591b8b21b255eb4cbd Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 9 Oct 2026 15:37:03 -0500 Subject: [PATCH 4/4] Tooling(smoke): test windows open on a Mac's built-in display The three smoke suites opened their window at a fixed point on the main screen, which on a MacBook driving an external monitor is the desk's display, in the way of whatever is open there. testWindowState() puts it on the built-in display when there is one (found with NSScreen through JXA) and keeps the old spot everywhere else, CI included. All three suites passed with it on the 2.66.0 build. --- tooling/scripts/editor-improvements-smoke.mjs | 9 ++----- tooling/scripts/sidebar-vim-smoke.mjs | 3 ++- tooling/scripts/test-window-state.mjs | 27 +++++++++++++++++++ tooling/scripts/vim-editor-smoke.mjs | 9 ++----- 4 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 tooling/scripts/test-window-state.mjs diff --git a/tooling/scripts/editor-improvements-smoke.mjs b/tooling/scripts/editor-improvements-smoke.mjs index ae059f13..7b775867 100644 --- a/tooling/scripts/editor-improvements-smoke.mjs +++ b/tooling/scripts/editor-improvements-smoke.mjs @@ -19,6 +19,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import WebSocket from 'ws' +import { testWindowState } from './test-window-state.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') @@ -114,13 +115,7 @@ async function seedUserData(userDataRoot, configRoot, vaultRoot) { remoteWorkspace: null, remoteWorkspaceProfileId: null, remoteWorkspaceProfiles: [], - windowState: { - x: 60, - y: 60, - width: 1180, - height: 780, - isMaximized: false - }, + windowState: testWindowState(1180, 780), zoomFactor: 1, quickCaptureHotkey: '' }, diff --git a/tooling/scripts/sidebar-vim-smoke.mjs b/tooling/scripts/sidebar-vim-smoke.mjs index cc2f7966..76e057dd 100644 --- a/tooling/scripts/sidebar-vim-smoke.mjs +++ b/tooling/scripts/sidebar-vim-smoke.mjs @@ -32,6 +32,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import WebSocket from 'ws' +import { testWindowState } from './test-window-state.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') @@ -89,7 +90,7 @@ async function seedUserData(userDataRoot, configRoot, vaultRoot) { await Promise.all([mkdir(userDataRoot, { recursive: true }), mkdir(configRoot, { recursive: true })]) await writeFile(join(userDataRoot, 'zennotes.config.json'), JSON.stringify({ workspaceMode: 'local', vaultRoot, remoteWorkspace: null, remoteWorkspaceProfileId: null, - remoteWorkspaceProfiles: [], windowState: { x: 60, y: 60, width: 1280, height: 860, isMaximized: false }, + remoteWorkspaceProfiles: [], windowState: testWindowState(1280, 860), zoomFactor: 1, quickCaptureHotkey: '' }, null, 2)) // Portable prefs live in config.toml, outside userData. Without a config dir diff --git a/tooling/scripts/test-window-state.mjs b/tooling/scripts/test-window-state.mjs new file mode 100644 index 00000000..6a92c2d5 --- /dev/null +++ b/tooling/scripts/test-window-state.mjs @@ -0,0 +1,27 @@ +import { execFileSync } from 'node:child_process' + +/** + * Where a test window opens: on a Mac with a built-in display (a MacBook with an + * external monitor), on that display, so test windows stay off the desk's main + * screen; anywhere else (CI, Linux, Windows), at the old fixed spot. Cocoa frames + * are bottom-left based, so the top edge is the primary screen's height minus + * the screen's top. + */ +export function testWindowState(width, height) { + if (process.platform === 'darwin') { + try { + const script = 'ObjC.import("AppKit"); const s = $.NSScreen.screens; const p = s.objectAtIndex(0).frame; let r = null; for (let i = 0; i < s.count; i++) { const sc = s.objectAtIndex(i); if (/Built-in/.test(ObjC.unwrap(sc.localizedName))) { const f = sc.frame; r = { x: f.origin.x, y: p.size.height - (f.origin.y + f.size.height), w: f.size.width, h: f.size.height } } } JSON.stringify(r)' + const display = JSON.parse(execFileSync('osascript', ['-l', 'JavaScript', '-e', script], { encoding: 'utf8' }).trim()) + if (display) { + return { + x: Math.round(display.x + 40), + y: Math.round(display.y + 40), + width: Math.min(width, display.w - 80), + height: Math.min(height, display.h - 80), + isMaximized: false + } + } + } catch {} + } + return { x: 60, y: 60, width, height, isMaximized: false } +} diff --git a/tooling/scripts/vim-editor-smoke.mjs b/tooling/scripts/vim-editor-smoke.mjs index c909b5c7..1fd17e61 100644 --- a/tooling/scripts/vim-editor-smoke.mjs +++ b/tooling/scripts/vim-editor-smoke.mjs @@ -20,6 +20,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import WebSocket from 'ws' +import { testWindowState } from './test-window-state.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') @@ -100,13 +101,7 @@ async function seedUserData(userDataRoot, configRoot, vaultRoot) { remoteWorkspace: null, remoteWorkspaceProfileId: null, remoteWorkspaceProfiles: [], - windowState: { - x: 60, - y: 60, - width: 1180, - height: 760, - isMaximized: false - }, + windowState: testWindowState(1180, 760), zoomFactor: 1, quickCaptureHotkey: '' },