From 7f0b3ffd1330d8cf740c08e47c6a8c55f9781aac Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 26 Aug 2026 10:58:09 -0500 Subject: [PATCH 1/4] feat: add resilient mobile cloud uploads --- src/bridge/cloud-sync-client.ts | 70 ++++- src/bridge/mobile-direct-upload.test.ts | 214 +++++++++++++ src/bridge/mobile-direct-upload.ts | 394 ++++++++++++++++++++++++ 3 files changed, 673 insertions(+), 5 deletions(-) create mode 100644 src/bridge/mobile-direct-upload.test.ts create mode 100644 src/bridge/mobile-direct-upload.ts diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index 8667f10..665cf24 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -4,15 +4,33 @@ import { type CloudSyncHttpRequest, type CloudSyncHttpTransport } from '@zennotes/shared-domain/cloud-sync-api' +import type { + CloudSyncMutationRequest, + CloudSyncMutationResponse +} from '@zennotes/bridge-contract/cloud-sync' +import { + MobileDirectUploadError, + mobileObjectUploadOptions, + mutateWithMobileDirectUploads, + type MobileObjectUpload +} from './mobile-direct-upload' export class CloudServiceRequestError extends Error { + readonly status: number + readonly code: string | null + readonly details: Record | null + constructor( message: string, - readonly status: number, - readonly code: string | null + status: number, + code: string | null, + details: Record | null = null ) { super(message) this.name = 'CloudServiceRequestError' + this.status = status + this.code = code + this.details = details } } @@ -42,7 +60,7 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync // legitimately pushes 100-item base64 batches over cellular, and a // timeout here retries into the same wall forever. Desktop's fetch // transport has no read timeout at all. - readTimeout: 300_000 + readTimeout: request.timeoutMs ?? 300_000 }) if (response.status < 200 || response.status >= 300) { @@ -55,7 +73,8 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync ? response.data.message : `ZenNotes Cloud request failed (${response.status}).`), response.status, - typeof error?.code === 'string' ? error.code : null + typeof error?.code === 'string' ? error.code : null, + isRecord(error?.details) ? error.details : null ) } @@ -78,7 +97,44 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync } } - return new CloudSyncApiClient(transport) + return new MobileCloudSyncApiClient(transport, uploadObject) +} + +class MobileCloudSyncApiClient extends CloudSyncApiClient { + constructor( + http: CloudSyncHttpTransport, + private readonly uploadObject: MobileObjectUpload + ) { + super(http) + } + + override async mutate( + vaultId: string, + body: CloudSyncMutationRequest + ): Promise { + return mutateWithMobileDirectUploads( + { + mutate: (nextVaultId, nextBody) => super.mutate(nextVaultId, nextBody), + initiateUpload: (nextVaultId, nextBody) => super.initiateUpload(nextVaultId, nextBody), + completeUpload: (nextVaultId, uploadId) => super.completeUpload(nextVaultId, uploadId), + abortUpload: (nextVaultId, uploadId) => super.abortUpload(nextVaultId, uploadId) + }, + vaultId, + body, + this.uploadObject + ) + } +} + +const uploadObject: MobileObjectUpload = async (request) => { + const response = await CapacitorHttp.request(mobileObjectUploadOptions(request)) + if (response.status < 200 || response.status >= 300) { + throw new MobileDirectUploadError( + `ZenNotes Cloud object upload failed (${response.status}).`, + response.status, + 'DIRECT_UPLOAD_FAILED' + ) + } } export function firstValidationMessage(errors: unknown): string | null { @@ -94,6 +150,10 @@ export function firstValidationMessage(errors: unknown): string | null { return null } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + async function serializeFormData(form: FormData): Promise { + it('builds a native binary PUT without an account bearer token or redirects', () => { + const options = mobileObjectUploadOptions({ + url: 'https://objects.example.test/upload?signature=signed', + method: 'PUT', + headers: { + 'Content-Type': 'image/png', + 'Content-Length': '3' + }, + base64: 'AQID', + byteLength: 3 + }) + + assert.deepEqual(options, { + url: 'https://objects.example.test/upload?signature=signed', + method: 'PUT', + headers: { + 'Content-Type': 'image/png', + 'Content-Length': '3' + }, + data: 'AQID', + dataType: 'file', + connectTimeout: 30_000, + readTimeout: 300_000, + disableRedirects: true + }) + assert.equal(options.headers.Authorization, undefined) + }) + + it('keeps files at the inline limit in the normal mutation request', async () => { + const mutation = upsertMutation(CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, 'AA==') + const inlineBodies: CloudSyncMutationRequest[] = [] + const api = fakeApi({ + mutate: async (_vaultId, body) => { + inlineBodies.push(body) + return emptyResponse() + } + }) + + await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [mutation] }, async () => { + assert.fail('object upload must not run for an inline file') + }) + + assert.deepEqual(inlineBodies, [{ mutations: [mutation] }]) + }) + + it('uploads a file above the inline limit through the signed object URL', async () => { + const base64 = Buffer.alloc(CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1, 7).toString('base64') + const mutation = upsertMutation(CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1, base64) + const calls: string[] = [] + const acknowledgement = { + operation_id: mutation.operation_id, + item_id: mutation.item_id, + revision: 3, + sequence: 9 + } + let completionAttempts = 0 + const api = fakeApi({ + initiateUpload: async () => { + calls.push('initiate') + return uploadInstruction(mutation, 'https://objects.example.test/upload?signature=signed') + }, + completeUpload: async () => { + calls.push('complete') + completionAttempts += 1 + if (completionAttempts === 1) { + throw new MobileDirectUploadError('Try again.', 503, 'TEMPORARY_FAILURE') + } + return { + data: { + id: 'upload-1', + operation_id: mutation.operation_id, + status: 'completed', + result: { acknowledged: [acknowledgement], conflicts: [], cursor: 9 } + } + } + } + }) + const uploads: Parameters[0][] = [] + + const result = await mutateWithMobileDirectUploads( + api, + 'vault-1', + { mutations: [mutation] }, + async (upload) => { + calls.push('put') + uploads.push(upload) + } + ) + + assert.deepEqual(result, { acknowledged: [acknowledgement], conflicts: [], cursor: 9 }) + assert.deepEqual(calls, ['initiate', 'put', 'complete', 'complete']) + assert.equal(uploads[0]?.url, 'https://objects.example.test/upload?signature=signed') + assert.equal(uploads[0]?.base64, base64) + assert.equal(uploads[0]?.byteLength, CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1) + assert.equal(uploads[0]?.headers.Authorization, undefined) + }) + + it('aborts the reservation when object storage rejects the upload', async () => { + const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1 + const mutation = upsertMutation(byteLength, Buffer.alloc(byteLength, 11).toString('base64')) + const aborted: string[] = [] + const api = fakeApi({ + initiateUpload: async () => uploadInstruction(mutation, 'https://objects.example.test/upload'), + abortUpload: async (_vaultId, uploadId) => { + aborted.push(uploadId) + } + }) + + await assert.rejects( + mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [mutation] }, async () => { + throw new MobileDirectUploadError('Object upload failed.', 503, 'DIRECT_UPLOAD_FAILED') + }), + (error: unknown) => + error instanceof MobileDirectUploadError && error.code === 'DIRECT_UPLOAD_FAILED' + ) + assert.deepEqual(aborted, ['upload-1']) + }) + + it('rejects insecure signed URLs before transmitting bytes and aborts the reservation', async () => { + const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1 + const mutation = upsertMutation(byteLength, Buffer.alloc(byteLength, 12).toString('base64')) + const aborted: string[] = [] + let uploaded = false + const api = fakeApi({ + initiateUpload: async () => uploadInstruction(mutation, 'http://objects.example.test/upload'), + abortUpload: async (_vaultId, uploadId) => { + aborted.push(uploadId) + } + }) + + await assert.rejects( + mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [mutation] }, async () => { + uploaded = true + }), + (error: unknown) => + error instanceof MobileDirectUploadError && error.code === 'INSECURE_DIRECT_UPLOAD_URL' + ) + assert.equal(uploaded, false) + assert.deepEqual(aborted, ['upload-1']) + }) +}) + +function fakeApi(overrides: Partial): MobileDirectUploadApi { + return { + mutate: async () => emptyResponse(), + initiateUpload: async () => { + throw new Error('Unexpected initiateUpload call') + }, + completeUpload: async () => { + throw new Error('Unexpected completeUpload call') + }, + abortUpload: async () => {}, + ...overrides + } +} + +function emptyResponse(): CloudSyncMutationResponse { + return { acknowledged: [], conflicts: [], cursor: 0 } +} + +function upsertMutation(byteLength: number, data: string): CloudSyncUpsertMutation { + return { + type: 'upsert', + operation_id: 'operation-1', + item_id: 'item-1', + base_revision: null, + path: 'assets/photo.png', + kind: 'binary', + content: { + encoding: 'base64', + data, + sha256: 'a'.repeat(64), + byte_length: byteLength, + media_type: 'image/png' + } + } +} + +function uploadInstruction(mutation: CloudSyncUpsertMutation, url: string) { + return { + data: { + id: 'upload-1', + operation_id: mutation.operation_id, + status: 'uploading' as const, + expected_bytes: mutation.content.byte_length, + expires_at: '2026-08-26T18:30:00.000Z', + upload: { + method: 'PUT' as const, + url, + headers: { + 'Content-Type': mutation.content.media_type, + 'Content-Length': String(mutation.content.byte_length) + } + } + } + } +} diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts new file mode 100644 index 0000000..22aa87b --- /dev/null +++ b/src/bridge/mobile-direct-upload.ts @@ -0,0 +1,394 @@ +import type { + CloudSyncCapacityConflict, + CloudSyncConflict, + CloudSyncConflictCode, + CloudSyncMutation, + CloudSyncMutationRequest, + CloudSyncMutationResponse, + CloudSyncUpsertMutation, + CloudSyncUploadCompletionResponse, + CloudSyncUploadInitiationResponse, + CloudSyncUploadRequest +} from '@zennotes/bridge-contract/cloud-sync' + +export const CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 + +const DIRECT_UPLOAD_COMPLETION_ATTEMPTS = 3 +const SYNC_CONFLICT_CODES = new Set([ + 'REVISION_CONFLICT', + 'PATH_CONFLICT', + 'ITEM_DELETED', + 'QUOTA_EXCEEDED', + 'CAPACITY_EXCEEDED', + 'FILE_SIZE_LIMIT_EXCEEDED' +]) + +export interface MobileDirectUploadApi { + mutate(vaultId: string, body: CloudSyncMutationRequest): Promise + initiateUpload( + vaultId: string, + body: CloudSyncUploadRequest + ): Promise + completeUpload(vaultId: string, uploadId: string): Promise + abortUpload(vaultId: string, uploadId: string): Promise +} + +export interface MobileObjectUploadRequest { + url: string + method: 'PUT' + headers: Record + base64: string + byteLength: number +} + +export type MobileObjectUpload = (request: MobileObjectUploadRequest) => Promise + +export interface MobileObjectUploadOptions { + url: string + method: 'PUT' + headers: Record + data: string + dataType: 'file' + connectTimeout: number + readTimeout: number + disableRedirects: true +} + +export function mobileObjectUploadOptions( + request: MobileObjectUploadRequest +): MobileObjectUploadOptions { + return { + url: request.url, + method: request.method, + headers: request.headers, + data: request.base64, + dataType: 'file', + connectTimeout: 30_000, + readTimeout: 300_000, + disableRedirects: true + } +} + +export class MobileDirectUploadError extends Error { + readonly status: number + readonly code: string | null + readonly details: Record | null + + constructor( + message: string, + status: number, + code: string | null, + details: Record | null = null + ) { + super(message) + this.name = 'MobileDirectUploadError' + this.status = status + this.code = code + this.details = details + } +} + +export async function mutateWithMobileDirectUploads( + api: MobileDirectUploadApi, + vaultId: string, + body: CloudSyncMutationRequest, + uploadObject: MobileObjectUpload +): Promise { + if (!body.mutations.some(usesDirectUpload)) return api.mutate(vaultId, body) + + const responses: CloudSyncMutationResponse[] = [] + let inlineMutations: CloudSyncMutation[] = [] + const flushInline = async (): Promise => { + if (inlineMutations.length === 0) return + responses.push(await api.mutate(vaultId, { mutations: inlineMutations })) + inlineMutations = [] + } + + for (const mutation of body.mutations) { + if (usesDirectUpload(mutation)) { + await flushInline() + responses.push(await directUpload(api, vaultId, mutation, uploadObject)) + } else { + inlineMutations.push(mutation) + } + } + await flushInline() + + return { + acknowledged: responses.flatMap((response) => response.acknowledged), + conflicts: responses.flatMap((response) => response.conflicts), + cursor: Math.max(0, ...responses.map((response) => response.cursor)) + } +} + +function usesDirectUpload(mutation: CloudSyncMutation): mutation is CloudSyncUpsertMutation { + return mutation.type === 'upsert' && + mutation.content.byte_length > CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES +} + +async function directUpload( + api: MobileDirectUploadApi, + vaultId: string, + mutation: CloudSyncUpsertMutation, + uploadObject: MobileObjectUpload +): Promise { + const base64 = uploadBase64(mutation) + let initiation: CloudSyncUploadInitiationResponse + + try { + initiation = await api.initiateUpload(vaultId, uploadRequest(mutation)) + } catch (error) { + const conflict = directUploadConflict(error, mutation) + if (conflict) return { acknowledged: [], conflicts: [conflict], cursor: 0 } + throw error + } + + let instruction: CloudSyncUploadInitiationResponse['data'] + try { + instruction = directUploadInstruction(initiation, mutation) + } catch (error) { + const uploadId = uploadSessionId(initiation) + if (uploadId) await abortQuietly(api, vaultId, uploadId) + throw error + } + + try { + await uploadObject({ + url: secureDirectUploadUrl(instruction.upload.url), + method: instruction.upload.method, + headers: instruction.upload.headers, + base64, + byteLength: mutation.content.byte_length + }) + } catch (error) { + await abortQuietly(api, vaultId, instruction.id) + throw error + } + + return completeDirectUpload(api, vaultId, instruction.id, mutation) +} + +async function completeDirectUpload( + api: MobileDirectUploadApi, + vaultId: string, + uploadId: string, + mutation: CloudSyncUpsertMutation +): Promise { + for (let attempt = 1; attempt <= DIRECT_UPLOAD_COMPLETION_ATTEMPTS; attempt++) { + try { + return (await api.completeUpload(vaultId, uploadId)).data.result + } catch (error) { + const conflict = directUploadConflict(error, mutation) + if (conflict) return { acknowledged: [], conflicts: [conflict], cursor: 0 } + if (attempt === DIRECT_UPLOAD_COMPLETION_ATTEMPTS || !retryableCompletionError(error)) { + throw error + } + } + } + throw new Error('ZenNotes Cloud upload completion ended unexpectedly.') +} + +async function abortQuietly( + api: MobileDirectUploadApi, + vaultId: string, + uploadId: string +): Promise { + await api.abortUpload(vaultId, uploadId).catch(() => {}) +} + +function uploadRequest(mutation: CloudSyncUpsertMutation): CloudSyncUploadRequest { + return { + operation_id: mutation.operation_id, + item_id: mutation.item_id, + base_revision: mutation.base_revision, + path: mutation.path, + kind: mutation.kind, + content: { + encoding: mutation.content.encoding, + sha256: mutation.content.sha256, + byte_length: mutation.content.byte_length, + media_type: mutation.content.media_type + } + } +} + +function uploadBase64(mutation: CloudSyncUpsertMutation): string { + if (mutation.content.encoding === 'utf8') { + const bytes = new TextEncoder().encode(mutation.content.data) + if (bytes.byteLength !== mutation.content.byte_length) throw directUploadSizeMismatch() + return bytesToBase64(bytes) + } + + const normalized = mutation.content.data.includes(',') + ? mutation.content.data.slice(mutation.content.data.indexOf(',') + 1).replace(/\s/g, '') + : mutation.content.data.replace(/\s/g, '') + if (!validBase64(normalized) || decodedBase64Length(normalized) !== mutation.content.byte_length) { + throw directUploadSizeMismatch() + } + return normalized +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 32_768 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} + +function validBase64(value: string): boolean { + if (value.length % 4 !== 0) return false + const paddingStart = value.indexOf('=') + const contentEnd = paddingStart < 0 ? value.length : paddingStart + const padding = value.length - contentEnd + if (padding > 2 || (padding > 0 && contentEnd < value.length - 2)) return false + + for (let index = 0; index < contentEnd; index++) { + const code = value.charCodeAt(index) + const valid = + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 43 || + code === 47 + if (!valid) return false + } + for (let index = contentEnd; index < value.length; index++) { + if (value.charCodeAt(index) !== 61) return false + } + return true +} + +function decodedBase64Length(value: string): number { + if (value === '') return 0 + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0 + return (value.length / 4) * 3 - padding +} + +function directUploadInstruction( + initiation: CloudSyncUploadInitiationResponse, + mutation: CloudSyncUpsertMutation +): CloudSyncUploadInitiationResponse['data'] { + const candidate = initiation as unknown as { data?: unknown } + if (!isRecord(candidate.data)) throw invalidDirectUploadResponse() + const data = candidate.data + if (!isRecord(data.upload)) throw invalidDirectUploadResponse() + const upload = data.upload + if ( + data.operation_id !== mutation.operation_id || + data.expected_bytes !== mutation.content.byte_length || + typeof data.id !== 'string' || + data.id === '' || + upload.method !== 'PUT' || + typeof upload.url !== 'string' || + !isRecord(upload.headers) || + !Object.values(upload.headers).every((value) => typeof value === 'string') + ) { + throw invalidDirectUploadResponse() + } + return data as unknown as CloudSyncUploadInitiationResponse['data'] +} + +function uploadSessionId(initiation: CloudSyncUploadInitiationResponse): string | null { + const candidate = initiation as unknown as { data?: unknown } + return isRecord(candidate.data) && typeof candidate.data.id === 'string' && candidate.data.id !== '' + ? candidate.data.id + : null +} + +function secureDirectUploadUrl(value: string): string { + let url: URL + try { + url = new URL(value) + } catch { + throw insecureDirectUploadUrl() + } + + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '') + const loopback = hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') + if ( + (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || + url.username || + url.password + ) { + throw insecureDirectUploadUrl() + } + return url.href +} + +function directUploadConflict( + error: unknown, + mutation: CloudSyncUpsertMutation +): CloudSyncConflict | null { + if (!isServiceError(error) || error.status !== 409 || !error.code) return null + if (!SYNC_CONFLICT_CODES.has(error.code as CloudSyncConflictCode)) return null + const capacity = capacityConflictDetails(error.details) + return { + operation_id: mutation.operation_id, + item_id: mutation.item_id, + code: error.code as CloudSyncConflictCode, + current_revision: + typeof error.details?.current_revision === 'number' ? error.details.current_revision : null, + current_path: + typeof error.details?.current_path === 'string' ? error.details.current_path : null, + ...(capacity ? { capacity } : {}) + } +} + +function capacityConflictDetails( + details: Record | null +): CloudSyncCapacityConflict | null { + if ( + !details || + typeof details.dimension !== 'string' || + typeof details.used !== 'number' || + typeof details.reserved !== 'number' || + typeof details.limit !== 'number' || + typeof details.projected !== 'number' || + typeof details.can_retry_after_reduction !== 'boolean' + ) return null + return details as unknown as CloudSyncCapacityConflict +} + +function isServiceError(error: unknown): error is { + status: number + code: string | null + details: Record | null +} { + return error instanceof Error && + typeof (error as { status?: unknown }).status === 'number' && + ((error as { code?: unknown }).code === null || typeof (error as { code?: unknown }).code === 'string') +} + +function retryableCompletionError(error: unknown): boolean { + return !isServiceError(error) || error.status >= 500 +} + +function directUploadSizeMismatch(): MobileDirectUploadError { + return new MobileDirectUploadError( + 'The local file changed while ZenNotes was preparing its Cloud upload.', + 0, + 'DIRECT_UPLOAD_SIZE_MISMATCH' + ) +} + +function invalidDirectUploadResponse(): MobileDirectUploadError { + return new MobileDirectUploadError( + 'ZenNotes Cloud returned an invalid object upload instruction.', + 0, + 'INVALID_DIRECT_UPLOAD_RESPONSE' + ) +} + +function insecureDirectUploadUrl(): MobileDirectUploadError { + return new MobileDirectUploadError( + 'ZenNotes Cloud returned an insecure object upload URL.', + 0, + 'INSECURE_DIRECT_UPLOAD_URL' + ) +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} From 129e00c3dacf3062dde3c41a852020e3a5613e24 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 26 Aug 2026 10:58:15 -0500 Subject: [PATCH 2/4] chore: make mobile cloud releases reproducible --- .github/dependabot.yml | 16 +++ .github/workflows/ci.yml | 56 ++++++++ .github/workflows/cloud-e2e.yml | 40 ++++++ .gitignore | 1 + .zennotes-commit | 2 +- README.md | 36 ++++- ios/App/AppUITests/CloudFlowUITests.swift | 14 +- package-lock.json | 76 ++++++---- package.json | 8 +- tailwind.config.js | 2 +- tooling/cloud-direct-upload-e2e.mjs | 164 ++++++++++++++++++++++ tooling/prepare-zennotes.sh | 61 ++++++++ tooling/upstream-check.sh | 35 ++--- tsconfig.json | 18 +-- vite.config.ts | 2 +- 15 files changed, 457 insertions(+), 74 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/cloud-e2e.yml create mode 100755 tooling/cloud-direct-upload-e2e.mjs create mode 100755 tooling/prepare-zennotes.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d5247fb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + production-dependencies: + dependency-type: production + development-dependencies: + dependency-type: development + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f7da574 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: Mobile CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: mobile-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: TypeScript, source pin, and iOS build + runs-on: macos-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install mobile dependencies + run: npm ci + + - name: Prepare exact ZenNotes source + run: npm run source:prepare + + - name: Reject high-severity production advisories + run: | + npm audit --omit=dev --audit-level=high + npm --prefix .zennotes-source audit --omit=dev --audit-level=high + + - name: Test and typecheck bridges + run: | + npm test + npm run typecheck + npm run upstream + + - name: Build and sync iOS project + run: npm run sync + + - name: Compile app and UI-test targets + run: >- + xcodebuild build-for-testing + -workspace ios/App/App.xcworkspace + -scheme AppCloudUITests + -configuration Debug + -destination 'generic/platform=iOS Simulator' + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/cloud-e2e.yml b/.github/workflows/cloud-e2e.yml new file mode 100644 index 0000000..35ca3ac --- /dev/null +++ b/.github/workflows/cloud-e2e.yml @@ -0,0 +1,40 @@ +name: Cloud direct-upload E2E + +on: + workflow_dispatch: + schedule: + - cron: '17 9 * * 1' + +permissions: + contents: read + +jobs: + direct-upload: + name: Deployed Cloud direct-upload smoke test + runs-on: ubuntu-latest + env: + ZENNOTES_CLOUD_E2E_BASE_URL: ${{ secrets.ZENNOTES_CLOUD_E2E_BASE_URL }} + ZENNOTES_CLOUD_E2E_TOKEN: ${{ secrets.ZENNOTES_CLOUD_E2E_TOKEN }} + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Check E2E configuration + id: config + shell: bash + run: | + if [[ -n "$ZENNOTES_CLOUD_E2E_BASE_URL" && -n "$ZENNOTES_CLOUD_E2E_TOKEN" ]]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "Cloud E2E secrets are not configured; skipping deployed smoke test." + fi + + - name: Exercise API, object storage, completion, and manifest + if: steps.config.outputs.ready == 'true' + run: npm run e2e:cloud diff --git a/.gitignore b/.gitignore index 9206ef7..afe71d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.zennotes-source/ dist/ .DS_Store diff --git a/.zennotes-commit b/.zennotes-commit index 0d685d6..de8aa60 100644 --- a/.zennotes-commit +++ b/.zennotes-commit @@ -1 +1 @@ -1b949850cf2f72640d6cbaf4e31de654a53007b7 +43994ff7a37557d9014c48dffd06fd9f733405bd diff --git a/README.md b/README.md index 178f4b2..4220a80 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # ZenNotes for iPhone A Capacitor shell that runs the ZenNotes product core (`packages/app-core` from -the [zennotes monorepo](../../opensource/zennotes)) inside a WKWebView, backed +the [zennotes monorepo](https://github.com/ZenNotes/zennotes)) inside a WKWebView, backed by a local-first vault on the device filesystem. Implements the architecture in `docs/specs/mobile/` (Phase 0 + the on-device parts of Phase 1). -The zennotes repo is consumed **read-only, straight from source**, via Vite/TS -path aliases (see `vite.config.ts`) — nothing in that repo is modified. The -repo is expected at `../../opensource/zennotes` relative to this directory. +The zennotes repo is consumed **read-only at the exact commit in +`.zennotes-commit`**. `npm run source:prepare` checks that commit out under the +ignored `.zennotes-source/` directory and installs its locked dependencies. +Every typecheck and release build verifies the pin; no ambient sibling checkout +can silently change a mobile binary. ## Architecture @@ -76,7 +78,7 @@ Key decisions (all forced by "don't modify the zennotes repo"): ```sh npm install -npm run sync # vite build + cap sync ios +npm run sync # prepare pinned source + vite build + cap sync ios npx cap open ios # open in Xcode, or: xcodebuild -workspace ios/App/App.xcworkspace -scheme App \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build @@ -86,6 +88,10 @@ Dev loop against a browser (no simulator): `npm run dev` — note Capacitor plugins are absent in a plain browser, so vault I/O won't work; use the simulator for real testing. +To adopt a newer ZenNotes core, update `.zennotes-commit` to a reviewed full +commit SHA and run `npm run upstream`. Commit the pin with the mobile changes +that depend on it. + ## What works today (verified on the iPhone 17 Pro simulator) - Vault CRUD: list/read/write/create/rename (with inbound wikilink rewrite)/ @@ -201,7 +207,25 @@ Settings → Cloud. The mobile bridge stores the account token in the native keychain, links or creates a cloud vault, runs the shared offline-first sync engine, and exposes backups, note-level restore, publishing, and automatic sync on app foreground and local changes. Local vaults and iCloud continue to -work without an account or subscription. +work without an account or subscription. Files larger than the 5 MiB inline +limit use Cloud's signed object-storage upload flow, with the account token +kept off the object-storage request and a five-minute mobile transfer timeout. + +## Release verification + +Pull requests and `main` run bridge tests, a pinned-source typecheck, +production dependency audits for both repositories, a Capacitor sync, and an +Xcode `build-for-testing` of the app and Cloud UI-test targets. Dependabot +opens weekly npm and GitHub Actions updates. + +The scheduled `Cloud direct-upload E2E` workflow verifies the deployed API, +signed object upload, completion, manifest, and cleanup with a deterministic +6 MiB file. Configure these repository secrets before enabling it: + +- `ZENNOTES_CLOUD_E2E_BASE_URL` — the HTTPS production or staging origin. +- `ZENNOTES_CLOUD_E2E_TOKEN` — a dedicated active-device token with + `sync:read` and `sync:write`, Cloud Sync access, and room for one temporary + vault. Rotate it independently from human accounts. ## Not yet built (per the spec's phasing) diff --git a/ios/App/AppUITests/CloudFlowUITests.swift b/ios/App/AppUITests/CloudFlowUITests.swift index 68a2d93..14fe07c 100644 --- a/ios/App/AppUITests/CloudFlowUITests.swift +++ b/ios/App/AppUITests/CloudFlowUITests.swift @@ -264,13 +264,23 @@ final class CloudFlowUITests: XCTestCase { let email = element(label: "Email address", in: safari) if email.waitForExistence(timeout: 10) { + let environment = ProcessInfo.processInfo.environment + guard + let cloudEmail = environment["ZENNOTES_CLOUD_E2E_EMAIL"], + !cloudEmail.isEmpty, + let cloudPassword = environment["ZENNOTES_CLOUD_E2E_PASSWORD"], + !cloudPassword.isEmpty + else { + XCTFail("Set ZENNOTES_CLOUD_E2E_EMAIL and ZENNOTES_CLOUD_E2E_PASSWORD for a fresh Cloud UI-test login.") + return + } email.tap() - email.typeText("test@example.com") + email.typeText(cloudEmail) let password = safari.secureTextFields["Password"] XCTAssertTrue(password.waitForExistence(timeout: 3)) email.typeText("\t") - password.typeText("password\n") + password.typeText("\(cloudPassword)\n") } let authorize = element(label: "Authorize", in: safari) diff --git a/package-lock.json b/package-lock.json index b5b8592..bce5985 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1779,9 +1779,9 @@ "license": "MIT" }, "node_modules/@mermaid-js/parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", - "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", "license": "MIT", "dependencies": { "@chevrotain/types": "~11.1.2" @@ -2941,16 +2941,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -3856,9 +3856,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4070,6 +4070,15 @@ "node": ">= 6" } }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4719,9 +4728,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -5214,26 +5223,27 @@ } }, "node_modules/mermaid": { - "version": "11.16.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", - "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.2.0", + "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.3", + "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.20", + "dayjs": "^1.11.21", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", - "katex": "^0.16.45", + "fastdom": "1.0.12", + "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", @@ -5918,9 +5928,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -6183,9 +6193,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -6203,7 +6213,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6958,6 +6968,12 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7116,9 +7132,9 @@ } }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/package.json b/package.json index b226ce5..d49d319 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,16 @@ "homepage": "https://zennotes.org", "scripts": { "dev": "vite", + "source:prepare": "sh tooling/prepare-zennotes.sh", + "prebuild": "npm run source:prepare", "build": "vite build", "test": "node --test", + "e2e:cloud": "node tooling/cloud-direct-upload-e2e.mjs", + "pretypecheck": "npm run source:prepare", "typecheck": "tsc --noEmit", - "sync": "vite build && cap sync ios && git -C ../../opensource/zennotes rev-parse HEAD > .zennotes-commit", + "sync": "npm run build && cap sync ios", "upstream": "sh tooling/upstream-check.sh", - "ios": "vite build && cap sync ios && cap open ios" + "ios": "npm run build && cap sync ios && cap open ios" }, "dependencies": { "@aparajita/capacitor-secure-storage": "7.1.6", diff --git a/tailwind.config.js b/tailwind.config.js index 8dcc15c..1f9ddc5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -6,7 +6,7 @@ export default { content: [ './index.html', './src/**/*.{ts,tsx}', - '../../opensource/zennotes/packages/app-core/src/**/*.{ts,tsx}' + '.zennotes-source/packages/app-core/src/**/*.{ts,tsx}' ], theme: { extend: { diff --git a/tooling/cloud-direct-upload-e2e.mjs b/tooling/cloud-direct-upload-e2e.mjs new file mode 100755 index 0000000..af0d16d --- /dev/null +++ b/tooling/cloud-direct-upload-e2e.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict' +import { createHash, randomUUID } from 'node:crypto' + +const baseUrl = requiredUrl('ZENNOTES_CLOUD_E2E_BASE_URL') +const token = required('ZENNOTES_CLOUD_E2E_TOKEN') +const byteLength = 6 * 1024 * 1024 +const bytes = Buffer.allocUnsafe(byteLength) +for (let index = 0; index < bytes.length; index += 1) bytes[index] = index % 251 + +const sha256 = createHash('sha256').update(bytes).digest('hex') +const itemId = randomUUID() +const operationId = randomUUID() +const path = `assets/mobile-direct-upload-${Date.now()}.bin` +let vaultId = null +let uploadId = null +let completed = false + +try { + await api('/api/v1/account') + + const vault = await api('/api/v1/vaults', { + method: 'POST', + body: { name: `Mobile CI ${new Date().toISOString()}` } + }) + vaultId = requiredString(vault?.data?.id, 'temporary vault id') + + const initiation = await api(`/api/v1/vaults/${encodeURIComponent(vaultId)}/uploads`, { + method: 'POST', + body: { + operation_id: operationId, + item_id: itemId, + base_revision: null, + path, + kind: 'binary', + content: { + encoding: 'base64', + sha256, + byte_length: bytes.length, + media_type: 'application/octet-stream' + } + } + }) + + uploadId = requiredString(initiation?.data?.id, 'upload session id') + assert.equal(initiation?.data?.expected_bytes, bytes.length) + assert.equal(initiation?.data?.upload?.method, 'PUT') + const uploadUrl = secureUploadUrl(initiation?.data?.upload?.url) + const uploadHeaders = stringHeaders(initiation?.data?.upload?.headers) + assert.equal(hasAuthorizationHeader(uploadHeaders), false) + + const objectResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: uploadHeaders, + body: bytes, + redirect: 'error', + signal: AbortSignal.timeout(300_000) + }) + assert.equal(objectResponse.ok, true, `object upload failed (${objectResponse.status})`) + + const completion = await api( + `/api/v1/vaults/${encodeURIComponent(vaultId)}/uploads/${encodeURIComponent(uploadId)}/complete`, + { method: 'POST', timeoutMs: 300_000 } + ) + assert.equal(completion?.data?.status, 'completed') + assert.equal(completion?.data?.result?.conflicts?.length, 0) + assert.equal(completion?.data?.result?.acknowledged?.[0]?.item_id, itemId) + completed = true + + const manifest = await api( + `/api/v1/vaults/${encodeURIComponent(vaultId)}/manifest?per_page=100` + ) + const item = manifest?.data?.find?.((candidate) => candidate?.item_id === itemId) + assert.ok(item, 'uploaded item missing from manifest') + assert.equal(item.path, path) + assert.equal(item.kind, 'binary') + assert.equal(item.byte_length, bytes.length) + assert.equal(item.sha256, sha256) + + console.log(`Cloud direct-upload smoke test passed (${bytes.length} bytes).`) +} finally { + if (vaultId && uploadId && !completed) { + await api( + `/api/v1/vaults/${encodeURIComponent(vaultId)}/uploads/${encodeURIComponent(uploadId)}`, + { method: 'DELETE' } + ).catch(() => {}) + } + if (vaultId) { + await api(`/api/v1/vaults/${encodeURIComponent(vaultId)}`, { + method: 'DELETE' + }).catch((error) => { + console.error(`Could not remove temporary Cloud E2E vault: ${error.message}`) + process.exitCode = 1 + }) + } +} + +async function api(pathname, options = {}) { + const response = await fetch(`${baseUrl}${pathname}`, { + method: options.method ?? 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }) + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + redirect: 'error', + signal: AbortSignal.timeout(options.timeoutMs ?? 30_000) + }) + const text = await response.text() + const payload = text === '' ? null : JSON.parse(text) + if (!response.ok) { + const code = payload?.error?.code ? ` ${payload.error.code}` : '' + throw new Error(`Cloud API request failed (${response.status}${code}).`) + } + return payload +} + +function required(name) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`${name} is required.`) + return value +} + +function requiredUrl(name) { + const url = new URL(required(name)) + if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) { + throw new Error(`${name} must use HTTPS.`) + } + url.pathname = url.pathname.replace(/\/+$/, '') + url.search = '' + url.hash = '' + return url.toString().replace(/\/+$/, '') +} + +function secureUploadUrl(value) { + const url = new URL(requiredString(value, 'signed upload URL')) + if (url.username || url.password || url.protocol !== 'https:') { + throw new Error('Cloud returned an unsafe signed upload URL.') + } + return url.toString() +} + +function requiredString(value, label) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Cloud response is missing ${label}.`) + } + return value +} + +function stringHeaders(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Cloud response has invalid signed upload headers.') + } + return Object.fromEntries( + Object.entries(value).map(([key, headerValue]) => [ + key, + requiredString(headerValue, `signed header ${key}`) + ]) + ) +} + +function hasAuthorizationHeader(headers) { + return Object.keys(headers).some((name) => name.toLowerCase() === 'authorization') +} diff --git a/tooling/prepare-zennotes.sh b/tooling/prepare-zennotes.sh new file mode 100755 index 0000000..73edc04 --- /dev/null +++ b/tooling/prepare-zennotes.sh @@ -0,0 +1,61 @@ +#!/bin/sh +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PIN_FILE="$ROOT/.zennotes-commit" +SOURCE_DIR="$ROOT/.zennotes-source" +REMOTE="${ZENNOTES_SOURCE_REMOTE:-https://github.com/ZenNotes/zennotes.git}" + +if [ ! -f "$PIN_FILE" ]; then + echo "Missing .zennotes-commit source pin." >&2 + exit 1 +fi + +PIN="$(tr -d '[:space:]' < "$PIN_FILE")" +case "$PIN" in + *[!0-9a-f]*|'') + echo ".zennotes-commit must contain a full lowercase Git commit SHA." >&2 + exit 1 + ;; +esac +if [ "${#PIN}" -ne 40 ]; then + echo ".zennotes-commit must contain a full 40-character Git commit SHA." >&2 + exit 1 +fi + +if [ ! -d "$SOURCE_DIR/.git" ]; then + if [ -e "$SOURCE_DIR" ]; then + echo "$SOURCE_DIR exists but is not a Git checkout; remove or rename it." >&2 + exit 1 + fi + git clone --filter=blob:none "$REMOTE" "$SOURCE_DIR" +fi + +if [ -n "$(git -C "$SOURCE_DIR" status --porcelain)" ]; then + echo "$SOURCE_DIR has local changes; refusing to replace reproducible source." >&2 + exit 1 +fi + +if ! git -C "$SOURCE_DIR" cat-file -e "$PIN^{commit}" 2>/dev/null; then + git -C "$SOURCE_DIR" fetch --depth=1 origin "$PIN" +fi + +git -C "$SOURCE_DIR" checkout --quiet --detach "$PIN" +ACTUAL="$(git -C "$SOURCE_DIR" rev-parse HEAD)" +if [ "$ACTUAL" != "$PIN" ]; then + echo "Expected ZenNotes source $PIN but checked out $ACTUAL." >&2 + exit 1 +fi + +DEPENDENCY_MARKER="$SOURCE_DIR/node_modules/.zennotes-source-commit" +INSTALLED_PIN="" +if [ -f "$DEPENDENCY_MARKER" ]; then + INSTALLED_PIN="$(tr -d '[:space:]' < "$DEPENDENCY_MARKER")" +fi +if [ "$INSTALLED_PIN" != "$PIN" ]; then + echo "Installing dependencies for pinned ZenNotes source..." + npm --prefix "$SOURCE_DIR" ci --ignore-scripts --no-audit --no-fund + printf '%s\n' "$PIN" > "$DEPENDENCY_MARKER" +fi + +echo "ZenNotes source ready at $ACTUAL" diff --git a/tooling/upstream-check.sh b/tooling/upstream-check.sh index 6a5847d..377c0a7 100755 --- a/tooling/upstream-check.sh +++ b/tooling/upstream-check.sh @@ -1,27 +1,18 @@ #!/bin/sh -# Report what changed in the zennotes repo since this app's last `npm run sync`, -# then typecheck against the current source. Two failure modes matter: -# 1. Bridge-contract additions — tsc fails until mobile-bridge implements them. -# 2. New vault semantics/settings — look for packages/bridge-contract, vault -# settings, or apps/desktop/src/main/vault.ts in the commit list below and -# mirror them (mobile keeps its own copy of vault + settings handling). -set -e +# Verify and typecheck against the exact ZenNotes source used by release builds. +set -eu + DIR="$(cd "$(dirname "$0")/.." && pwd)" -ZEN="$DIR/../../opensource/zennotes" -STAMP="$DIR/.zennotes-commit" +sh "$DIR/tooling/prepare-zennotes.sh" -if [ ! -f "$STAMP" ]; then - echo "No .zennotes-commit stamp yet — run 'npm run sync' once to create it." -else - SINCE="$(cat "$STAMP")" - echo "zennotes changes since last mobile sync ($SINCE):" - echo "--------------------------------------------------------------" - git -C "$ZEN" log --oneline "$SINCE"..HEAD -- packages apps/desktop/src/main || true - echo "--------------------------------------------------------------" - echo "Contract/vault files touched (need manual mirroring if any):" - git -C "$ZEN" diff --stat "$SINCE"..HEAD -- packages/bridge-contract apps/desktop/src/main/vault.ts | tail -5 || true +PIN="$(tr -d '[:space:]' < "$DIR/.zennotes-commit")" +ACTUAL="$(git -C "$DIR/.zennotes-source" rev-parse HEAD)" +if [ "$PIN" != "$ACTUAL" ]; then + echo "Source mismatch: expected $PIN, found $ACTUAL" >&2 + exit 1 fi -echo -echo "Typechecking mobile against current zennotes source..." -cd "$DIR" && npx tsc --noEmit && echo "OK: bridge contract satisfied." +echo "Typechecking mobile against pinned ZenNotes source $PIN..." +cd "$DIR" +npx tsc --noEmit +echo "OK: pinned bridge contract satisfied." diff --git a/tsconfig.json b/tsconfig.json index 7f0e073..8448f1e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,15 +18,15 @@ "types": ["vite/client", "node"], "baseUrl": ".", "paths": { - "@renderer/*": ["../../opensource/zennotes/packages/app-core/src/*"], - "@shared/*": ["../../opensource/zennotes/packages/shared-domain/src/*"], - "@bridge-contract/*": ["../../opensource/zennotes/packages/bridge-contract/src/*"], - "@zennotes/app-core/*": ["../../opensource/zennotes/packages/app-core/src/*"], - "@zennotes/shared-domain/*": ["../../opensource/zennotes/packages/shared-domain/src/*"], - "@zennotes/bridge-contract/*": ["../../opensource/zennotes/packages/bridge-contract/src/*"], - "@desktop-main/*": ["../../opensource/zennotes/apps/desktop/src/main/*"], - "@codemirror/*": ["../../opensource/zennotes/node_modules/@codemirror/*"], - "@lezer/*": ["../../opensource/zennotes/node_modules/@lezer/*"] + "@renderer/*": [".zennotes-source/packages/app-core/src/*"], + "@shared/*": [".zennotes-source/packages/shared-domain/src/*"], + "@bridge-contract/*": [".zennotes-source/packages/bridge-contract/src/*"], + "@zennotes/app-core/*": [".zennotes-source/packages/app-core/src/*"], + "@zennotes/shared-domain/*": [".zennotes-source/packages/shared-domain/src/*"], + "@zennotes/bridge-contract/*": [".zennotes-source/packages/bridge-contract/src/*"], + "@desktop-main/*": [".zennotes-source/apps/desktop/src/main/*"], + "@codemirror/*": ["node_modules/@codemirror/*"], + "@lezer/*": ["node_modules/@lezer/*"] } }, "include": ["src", "vite.config.ts", "capacitor.config.ts"] diff --git a/vite.config.ts b/vite.config.ts index 4ad13d1..741ef67 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,7 @@ import react from '@vitejs/plugin-react' // The ZenNotes monorepo is consumed read-only, straight from source, the same // way apps/web does it (aliases into packages/*). Nothing in that repo is // modified by this project. -const ZENNOTES = resolve(__dirname, '../../opensource/zennotes') +const ZENNOTES = resolve(__dirname, '.zennotes-source') // app-core's custom-code-language engine imports the oniguruma wasm as // `?url`. Inline it as a data URL (same plugin as apps/web) so the lazily From 5e728d8332bc38634743371071c6ce6f1b831d8e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 28 Aug 2026 10:50:01 -0500 Subject: [PATCH 3/4] feat: Android-parity swipe gestures and attach-file button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the two community features that shipped in Android 1.1.12 (zennotesandroid#24, zennotes#690) so both shells behave alike: - Settings → Appearance → Swipe gestures: swipe left / right over a note map to next/previous note (the shipped default), Browse, the note outline, or off; pull down from the top of a note runs a quick action — command palette by default, or search, or the new-note sheet, or off — with a hint pill and a haptic tick when it arms. The existing flick recognizer stays the arbiter; only the dispatch consults the mapping. Prefs in localStorage (zn:gestures), normalized per-field, node-tested. - A paperclip on the formatting toolbar opens WKWebView's own picker (Photo Library / Take Photo / Choose Files); picked files run through importDroppedFile like a desktop drop and land at the cursor via app-core's insertion formatting. One WebKit difference from Android: the hidden file input is positioned off-screen rather than display:none, which WebKit declines to open a picker for. - sheet-state exports isMobileSheetOpen (the pull-down gate needs it). Verified on the iPhone 17 simulator: pull-down opens the palette, flick moves to the next note, the picker sheet appears, a Photo Library pick copies the image into the vault root and inserts the embed, and the Settings card renders beside Layout. No native changes. --- src/ui-mobile/EditorToolbar.tsx | 9 ++ src/ui-mobile/MobileShell.tsx | 268 +++++++++++++++++++++++++++++++- src/ui-mobile/attach.ts | 83 ++++++++++ src/ui-mobile/gestures.test.ts | 53 +++++++ src/ui-mobile/gestures.ts | 77 +++++++++ src/ui-mobile/mobile.css | 47 +++++- src/ui-mobile/sheet-state.ts | 4 + src/viewport.ts | 4 + 8 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 src/ui-mobile/attach.ts create mode 100644 src/ui-mobile/gestures.test.ts create mode 100644 src/ui-mobile/gestures.ts diff --git a/src/ui-mobile/EditorToolbar.tsx b/src/ui-mobile/EditorToolbar.tsx index a7b7145..4fcea0e 100644 --- a/src/ui-mobile/EditorToolbar.tsx +++ b/src/ui-mobile/EditorToolbar.tsx @@ -17,6 +17,7 @@ import { openSearchPanel } from '@codemirror/search' import { EditorSelection } from '@codemirror/state' import { useStore } from '@zennotes/app-core/store' import { setBlockType, toggleWrap, wrapLink } from '@zennotes/app-core/lib/cm-format' +import { promptAttachFiles } from './attach' function view(): EditorView | null { return useStore.getState().editorViewRef @@ -82,6 +83,14 @@ const BUTTONS: ToolButton[] = [ if (v) openSearchPanel(v) } }, + { + key: 'attach', + label: 'Attach file', + d: 'M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l8.57-8.57A4 4 0 0118 8.84l-8.59 8.57a2 2 0 01-2.83-2.83l8.49-8.48', + // No withView: the picker sheet takes over anyway; the insertion path + // refocuses the editor when the pick lands (zennotes#690). + run: () => promptAttachFiles() + }, { key: 'todo', label: 'Checkbox', diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index a649b26..d757aa6 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -11,6 +11,13 @@ import ReactDOM from 'react-dom/client' import { Haptics, ImpactStyle } from '@capacitor/haptics' import { Keyboard } from '@capacitor/keyboard' import { useStore } from '@zennotes/app-core/store' +import { + getGesturePrefs, + setGesturePrefs, + type GesturePrefs, + type PullAction, + type SwipeAction +} from './gestures' import type { TaskMutation } from '@zennotes/app-core/store' import type { VaultTask } from '@shared/tasks' import { toIsoDateLocal } from '@shared/tasks' @@ -49,7 +56,12 @@ import { listSwitchableVaults, type MobileVaultEntry } from '../bridge/mobile-bridge' -import { closeMobileSheet, openMobileSheet, useMobileSheet } from './sheet-state' +import { + closeMobileSheet, + isMobileSheetOpen, + openMobileSheet, + useMobileSheet +} from './sheet-state' import { WELCOME_PENDING_KEY, FAB_HINT_KEY } from './Onboarding' import { WELCOME_NOTE_PATH } from '../bridge/welcome-note' import ensoUrl from '../assets/enso.png' @@ -472,6 +484,14 @@ function MobileNav(): React.JSX.Element | null { () => localStorage.getItem(FAB_HINT_KEY) === 'pending' ) + // The pull-down quick action (usePullDownAction) can be set to "New + // note"; the sheet is this component's, so it is summoned by event. + useEffect(() => { + const onCreate = (): void => setCreateOpen(true) + window.addEventListener('zn:quick-create', onCreate) + return () => window.removeEventListener('zn:quick-create', onCreate) + }, []) + if (!vault) return null const dismissHint = (): void => { @@ -1032,9 +1052,12 @@ function useEdgeSwipeDrawer(): void { } /** - * Swipe between notes: a fast horizontal flick over the note surface opens - * the previous (swipe right) or next (swipe left) note, in EXACTLY the order - * the Browse drawer shows for that folder (note-order.ts, pinned first). + * Horizontal flick over the note surface. What it does is the user's choice + * (Settings → Appearance → Swipe gestures, zennotesandroid#24, ported for + * parity): by default it opens the previous (swipe right) or next (swipe + * left) note, in EXACTLY the order the Browse drawer shows for that folder + * (note-order.ts, pinned first); either direction can instead open Browse or + * the note outline — the Obsidian-style sidebar swipes — or do nothing. * * Deliberately strict about what counts as a flick — everything horizontal * on this surface already means something else somewhere: @@ -1050,7 +1073,7 @@ function useEdgeSwipeDrawer(): void { * this is prev/next within a folder, a different gesture, added with the * rest of the gesture pass on 2026-08-17.) */ -function useNoteSwipeNav(): void { +function useNoteSwipeGestures(): void { useEffect(() => { if (!isPhoneWidth()) return const EDGE = 40 @@ -1098,6 +1121,19 @@ function useNoteSwipeNav(): void { const dir: -1 | 1 = dx < 0 ? -1 : 1 if (horizontallyScrollableAncestor(s0.target as HTMLElement | null, dir)) return + const prefs = getGesturePrefs() + const action = dir === -1 ? prefs.swipeLeft : prefs.swipeRight + if (action === 'off') return + if (action === 'browse') { + setDrawerOpen(true) + return + } + if (action === 'outline') { + const st = useStore.getState() + if (st.activeNote) st.setOutlinePaletteOpen(true) + return + } + const state = useStore.getState() const activePath = state.activeNote?.path if (!activePath) return @@ -1131,6 +1167,123 @@ function useNoteSwipeNav(): void { }, []) } +/** + * Pull down for a quick action (zennotesandroid#24, Obsidian's "pull-down quick + * action"): with the note scrolled to the top, dragging down past the + * threshold and releasing opens the command palette by default — or search, + * or the new-note sheet, or nothing (Settings → Appearance → Swipe gestures). + * + * Deliberately strict, since an over-scroll at the top of a note is common: + * the touch must START at scrollTop 0 (a flick that merely ends at the top + * never qualifies), travel mostly vertically, and cross the threshold before + * release; a floating hint shows what release will do and the threshold + * crossing buzzes once. Text selection and second fingers abandon it. Note + * surface only — the drawer keeps its own pull-to-refresh. + */ +function usePullDownAction(): void { + useEffect(() => { + if (!isPhoneWidth()) return + const TRIGGER = 88 + const HSLOP = 40 + let start: { x: number; y: number } | null = null + let armed = false + let hint: HTMLDivElement | null = null + + const actionLabel = (): string => { + const a = getGesturePrefs().pullDown + return a === 'search' ? 'search' : a === 'new' ? 'a new note' : 'commands' + } + const showHint = (progress: number, ready: boolean): void => { + if (!hint) { + hint = document.createElement('div') + hint.className = 'zn-pull-hint' + document.body.appendChild(hint) + } + hint.textContent = `${ready ? 'Release' : 'Pull'} for ${actionLabel()}` + hint.style.opacity = String(Math.min(1, progress)) + hint.classList.toggle('is-ready', ready) + } + const hideHint = (): void => { + hint?.remove() + hint = null + } + const reset = (): void => { + start = null + armed = false + hideHint() + } + const scrollTopOf = (el: HTMLElement | null): number => { + for (let n = el; n && n !== document.body; n = n.parentElement) { + if (n.scrollHeight > n.clientHeight + 1) return n.scrollTop + } + return 0 + } + + const onTouchStart = (e: TouchEvent): void => { + reset() + if (e.touches.length !== 1 || isDrawerOpen() || isMobileSheetOpen()) return + if (getGesturePrefs().pullDown === 'off') return + const t = e.touches[0]! + const target = e.target as HTMLElement | null + if (!target?.closest?.('.cm-editor, .prose-zen')) return + if (scrollTopOf(target) > 0) return + start = { x: t.clientX, y: t.clientY } + } + + const onTouchMove = (e: TouchEvent): void => { + if (!start) return + if (e.touches.length > 1) { + reset() + return + } + const t = e.touches[0]! + const dx = Math.abs(t.clientX - start.x) + const dy = t.clientY - start.y + if (dx > HSLOP && dx > dy) { + reset() + return + } + const sel = window.getSelection() + if (sel && !sel.isCollapsed) { + reset() + return + } + if (dy <= 0) { + armed = false + hideHint() + return + } + const ready = dy >= TRIGGER + if (ready && !armed) void Haptics.impact({ style: ImpactStyle.Light }).catch(() => {}) + armed = ready + showHint(dy / TRIGGER, ready) + } + + const onTouchEnd = (): void => { + const fire = armed && start !== null + reset() + if (!fire) return + const st = useStore.getState() + const action = getGesturePrefs().pullDown + if (action === 'palette') st.setCommandPaletteOpen(true) + else if (action === 'search') st.setSearchOpen(true) + else if (action === 'new') window.dispatchEvent(new Event('zn:quick-create')) + } + + document.addEventListener('touchstart', onTouchStart, { passive: true, capture: true }) + document.addEventListener('touchmove', onTouchMove, { passive: true, capture: true }) + document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true }) + document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true }) + return () => { + document.removeEventListener('touchstart', onTouchStart, { capture: true } as never) + document.removeEventListener('touchmove', onTouchMove, { capture: true } as never) + document.removeEventListener('touchend', onTouchEnd, { capture: true } as never) + document.removeEventListener('touchcancel', onTouchEnd, { capture: true } as never) + hideHint() + } + }, []) +} + /** * Pinch to resize the editor font: two fingers over the note surface scale * app-core's `editorFontSize` pref (12–28px), which already persists and @@ -2319,6 +2472,101 @@ const LAYOUT_CHOICES: { mode: LayoutMode; label: string }[] = [ { mode: 'desktop', label: 'Desktop' } ] +// --------------------------------------------------------------------------- +// Settings → Appearance → Swipe gestures (zennotesandroid#24, ported for parity). Three slots — swipe left, +// swipe right, pull down — each mapped to one action. Phone layout only: the +// gesture hooks themselves are phone-gated. +// --------------------------------------------------------------------------- + +const SWIPE_CHOICES: (dir: 'left' | 'right') => { value: SwipeAction; label: string }[] = ( + dir +) => [ + { value: 'note', label: dir === 'left' ? 'Next note' : 'Previous' }, + { value: 'browse', label: 'Browse' }, + { value: 'outline', label: 'Outline' }, + { value: 'off', label: 'Off' } +] + +const PULL_CHOICES: { value: PullAction; label: string }[] = [ + { value: 'palette', label: 'Commands' }, + { value: 'search', label: 'Search' }, + { value: 'new', label: 'New note' }, + { value: 'off', label: 'Off' } +] + +function GestureSeg({ + label, + value, + choices, + onPick +}: { + label: string + value: T + choices: { value: T; label: string }[] + onPick: (next: T) => void +}): React.JSX.Element { + return ( +
+ {label} +
+ {choices.map((choice) => ( + + ))} +
+
+ ) +} + +function SettingsGesturesRow(): React.JSX.Element { + const [prefs, setPrefs] = useState(() => getGesturePrefs()) + const update = (patch: Partial): void => { + const next = { ...prefs, ...patch } + setGesturePrefs(next) + setPrefs(next) + } + return ( +
+
+
Swipe gestures
+
+ One-handed shortcuts over an open note. A quick flick left or right, + or a pull down from the top of the note. Swiping in from the left + screen edge always opens Browse. +
+
+
+ update({ swipeLeft })} + /> + update({ swipeRight })} + /> + update({ pullDown })} + /> +
+
+ ) +} + function SettingsLayoutRow(): React.JSX.Element { const [mode, setMode] = useState(() => getLayoutMode()) const showing = isPhoneWidth() ? 'phone' : 'desktop' @@ -2391,7 +2639,12 @@ function useLayoutSettingsRow(): void { container = document.createElement('div') container.className = 'zn-settings-layout-host' root = ReactDOM.createRoot(container) - root.render() + root.render( + <> + + {isPhoneWidth() && } + + ) } parent.insertBefore(container, anchor) } @@ -2485,7 +2738,8 @@ function MobileShellRoot(): React.JSX.Element { usePlaceholderCleanup() useTagsEmptyStateHint() useEdgeSwipeDrawer() - useNoteSwipeNav() + useNoteSwipeGestures() + usePullDownAction() usePinchFontSize() useRightPanelCloseButton() useCalendarWeekMode() diff --git a/src/ui-mobile/attach.ts b/src/ui-mobile/attach.ts new file mode 100644 index 0000000..ac98bda --- /dev/null +++ b/src/ui-mobile/attach.ts @@ -0,0 +1,83 @@ +/** + * Attach a file to the open note (zennotes#690, ported from Android for + * parity): until now an attachment could only enter a note by pasting an + * image or `![[`-embedding a file already inside the vault. + * + * No native plugin needed: WKWebView presents its own picker for + * `` (Photo Library / Take Photo / Choose File → Files), + * and the picked Files flow through the SAME import path as desktop + * drag-drop — `importDroppedFile` on the active vault (bytes in, unique + * name, change event; MobileVault and RemoteVault both implement it) — then + * land at the cursor via app-core's own insertion formatting, exactly as + * EditorPane inserts drops on desktop. + * + * Module-level rather than toolbar-local on purpose: the picker sheet takes + * focus, the keyboard drops, and the focus-gated toolbar unmounts — a + * component-owned input would never deliver its change event. The insertion + * targets the store's editorViewRef, which survives all of that; refocusing + * it afterwards brings the keyboard back. + */ +import { formatImportedAssetsForInsertion } from '@zennotes/app-core/lib/editor-drops' +import { useStore } from '@zennotes/app-core/store' +import type { ImportedAsset } from '@bridge-contract/ipc' +import { activeVault } from '../bridge/mobile-bridge' + +const INPUT_CLASS = 'zn-attach-input' + +export function promptAttachFiles(): void { + const notePath = useStore.getState().selectedPath + if (!notePath || notePath.startsWith('zen://')) return + + // Older WebKit never fires `cancel` on file inputs, so a dismissed picker + // can strand its element — sweep leftovers instead of guarding re-entry + // (the picker sheet is modal; a second tap while it's up goes nowhere). + for (const stale of document.querySelectorAll(`.${INPUT_CLASS}`)) stale.remove() + + const input = document.createElement('input') + input.type = 'file' + input.multiple = true + input.className = INPUT_CLASS + // Not display:none — WebKit declines to present the picker for an input + // that isn't rendered. Off-screen and transparent keeps it clickable. + input.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none' + document.body.appendChild(input) + + input.addEventListener('cancel', () => input.remove()) + input.addEventListener('change', () => { + const files = Array.from(input.files ?? []) + input.remove() + if (files.length === 0) return + void importAndInsert(notePath, files) + }) + input.click() +} + +async function importAndInsert(notePath: string, files: File[]): Promise { + try { + const imported: ImportedAsset[] = [] + for (const file of files) { + imported.push(await activeVault().importDroppedFile(notePath, file)) + } + insertAtCursor(imported) + } catch (error) { + window.alert(error instanceof Error ? error.message : 'Could not attach the file.') + } +} + +/** Mirrors EditorPane's insertImportedAssets, minus drop coordinates: the + * markdown goes to the cursor, with the same before/after spacing rules. */ +function insertAtCursor(imported: ImportedAsset[]): void { + if (imported.length === 0) return + const view = useStore.getState().editorViewRef + if (!view) return + const insertAt = view.state.selection.main.head + const doc = view.state.doc + const before = insertAt > 0 ? doc.sliceString(insertAt - 1, insertAt) : '' + const after = insertAt < doc.length ? doc.sliceString(insertAt, insertAt + 1) : '' + const insert = formatImportedAssetsForInsertion(imported, before, after) + view.dispatch({ + changes: { from: insertAt, to: insertAt, insert }, + selection: { anchor: insertAt + insert.length } + }) + view.focus() +} diff --git a/src/ui-mobile/gestures.test.ts b/src/ui-mobile/gestures.test.ts new file mode 100644 index 0000000..f25f74a --- /dev/null +++ b/src/ui-mobile/gestures.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +// The module reads localStorage lazily (inside the functions), so a stub +// installed before the first call is all node needs. +const store = new Map() +;(globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key) +} + +const { DEFAULT_GESTURES, getGesturePrefs, setGesturePrefs } = await import('./gestures.ts') +const { GESTURES_KEY } = await import('../viewport.ts') + +test('garbage in storage falls back to the defaults', () => { + store.set(GESTURES_KEY, '{not json') + assert.deepEqual(getGesturePrefs(), DEFAULT_GESTURES) +}) + +test('unknown values normalize per-field, not wholesale', () => { + setGesturePrefs({ + swipeLeft: 'outline', + // @ts-expect-error deliberately invalid, e.g. from a newer version's value + swipeRight: 'jetpack', + pullDown: 'search' + }) + assert.deepEqual(getGesturePrefs(), { + swipeLeft: 'outline', + swipeRight: DEFAULT_GESTURES.swipeRight, + pullDown: 'search' + }) +}) + +test('set/get roundtrip persists through storage', () => { + setGesturePrefs({ swipeLeft: 'browse', swipeRight: 'outline', pullDown: 'new' }) + assert.deepEqual(JSON.parse(store.get(GESTURES_KEY) ?? ''), { + swipeLeft: 'browse', + swipeRight: 'outline', + pullDown: 'new' + }) + assert.deepEqual(getGesturePrefs(), { + swipeLeft: 'browse', + swipeRight: 'outline', + pullDown: 'new' + }) +}) + +test('setting the defaults removes the key entirely', () => { + setGesturePrefs({ ...DEFAULT_GESTURES }) + assert.equal(store.has(GESTURES_KEY), false) + assert.deepEqual(getGesturePrefs(), DEFAULT_GESTURES) +}) diff --git a/src/ui-mobile/gestures.ts b/src/ui-mobile/gestures.ts new file mode 100644 index 0000000..d9b50ac --- /dev/null +++ b/src/ui-mobile/gestures.ts @@ -0,0 +1,77 @@ +/** + * Configurable swipe gestures (issue #24, modelled on Obsidian mobile). + * + * The phone shell already had three touch gestures over an open note: a + * left-edge swipe opens Browse, a horizontal flick moves to the previous / + * next note, and a pinch resizes the font. The request was for the Obsidian + * set — swipe to the file browser, swipe to the outline, pull down for a + * quick action — and, since those collide with the flick, a setting to + * choose. This module owns that setting: three slots (swipe left, swipe + * right, pull down), each mapping to one action. Defaults keep the shipped + * swipe behavior and turn the new pull-down on, where nothing else lived. + * + * Reads are cached: the gesture hooks consult the prefs on every touch and + * the Settings card writes through `setGesturePrefs`, so a change applies to + * the very next swipe without remounting anything. + */ +import { GESTURES_KEY } from '../viewport.ts' + +/** 'note' is direction-aware: next note on a left swipe, previous on a right. */ +export type SwipeAction = 'note' | 'browse' | 'outline' | 'off' +export type PullAction = 'palette' | 'search' | 'new' | 'off' + +export interface GesturePrefs { + swipeLeft: SwipeAction + swipeRight: SwipeAction + pullDown: PullAction +} + +export const DEFAULT_GESTURES: GesturePrefs = { + swipeLeft: 'note', + swipeRight: 'note', + pullDown: 'palette' +} + +const SWIPE_ACTIONS: readonly SwipeAction[] = ['note', 'browse', 'outline', 'off'] +const PULL_ACTIONS: readonly PullAction[] = ['palette', 'search', 'new', 'off'] + +let cached: GesturePrefs | null = null + +function normalize(raw: unknown): GesturePrefs { + const obj = raw && typeof raw === 'object' ? (raw as Record) : {} + const swipe = (value: unknown, fallback: SwipeAction): SwipeAction => + SWIPE_ACTIONS.includes(value as SwipeAction) ? (value as SwipeAction) : fallback + const pull = (value: unknown): PullAction => + PULL_ACTIONS.includes(value as PullAction) ? (value as PullAction) : DEFAULT_GESTURES.pullDown + return { + swipeLeft: swipe(obj.swipeLeft, DEFAULT_GESTURES.swipeLeft), + swipeRight: swipe(obj.swipeRight, DEFAULT_GESTURES.swipeRight), + pullDown: pull(obj.pullDown) + } +} + +export function getGesturePrefs(): GesturePrefs { + if (cached) return cached + try { + const raw = localStorage.getItem(GESTURES_KEY) + cached = normalize(raw ? JSON.parse(raw) : null) + } catch { + cached = { ...DEFAULT_GESTURES } + } + return cached +} + +export function setGesturePrefs(next: GesturePrefs): void { + const normalized = normalize(next) + cached = normalized + try { + const isDefault = (Object.keys(DEFAULT_GESTURES) as (keyof GesturePrefs)[]).every( + (key) => normalized[key] === DEFAULT_GESTURES[key] + ) + // Defaults remove the key, so a fresh install and a reset look identical. + if (isDefault) localStorage.removeItem(GESTURES_KEY) + else localStorage.setItem(GESTURES_KEY, JSON.stringify(normalized)) + } catch { + // Storage unavailable: the choice applies to this session only. + } +} diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index 6923241..82241d5 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -2366,7 +2366,8 @@ /* ---- Settings → Appearance → Layout (island, #652) ----------------------- */ .zn-settings-layout-host { - display: block; + display: grid; + gap: 0.75rem; } .zn-settings-layout { display: flex; @@ -2416,6 +2417,50 @@ box-shadow: 0 1px 2px rgb(0 0 0 / 0.25); } +/* ---- Settings → Appearance → Swipe gestures (island, zennotesandroid#24) --------------- */ +.zn-settings-gestures-rows { + flex: 1 1 100%; + display: grid; + gap: 0.625rem; +} +.zn-settings-gestures-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} +.zn-settings-gestures-row > span { + font-size: 0.875rem; + color: rgb(var(--z-fg) / 0.85); +} +.zn-settings-gestures .zn-settings-layout-seg button { + padding: 0 0.625rem; + font-size: 0.8125rem; +} + +/* Pull-down quick action hint (usePullDownAction): a pill floating under the + note header while the finger is down, fading in with the pull. */ +.zn-pull-hint { + position: fixed; + top: calc(env(safe-area-inset-top, 0px) + 4.5rem); + left: 50%; + transform: translateX(-50%); + z-index: 40; + padding: 0.375rem 0.875rem; + border-radius: 999px; + border: 0.5px solid rgb(var(--z-bg-4) / 0.7); + background: rgb(var(--z-bg-2) / 0.96); + color: rgb(var(--z-grey-1)); + font-size: 0.8125rem; + white-space: nowrap; + pointer-events: none; +} +.zn-pull-hint.is-ready { + color: rgb(var(--z-accent)); + border-color: rgb(var(--z-accent) / 0.5); +} + /* ---- Atlas chrome --------------------------------------------------------- */ /* The FAB parks over the right end of the Atlas chip bar (it covered "filter" and hid "fit" — Adib's iPhone report). Reserve that corner and diff --git a/src/ui-mobile/sheet-state.ts b/src/ui-mobile/sheet-state.ts index 0923141..f9a20ee 100644 --- a/src/ui-mobile/sheet-state.ts +++ b/src/ui-mobile/sheet-state.ts @@ -22,6 +22,10 @@ export function openMobileSheet(kind: MobileSheetKind): void { for (const cb of subscribers) cb() } +export function isMobileSheetOpen(): boolean { + return current !== null +} + export function closeMobileSheet(): void { if (current === null) return current = null diff --git a/src/viewport.ts b/src/viewport.ts index c58fa99..7cc194a 100644 --- a/src/viewport.ts +++ b/src/viewport.ts @@ -119,6 +119,10 @@ export type LayoutMode = 'auto' | 'phone' | 'desktop' /** localStorage key; read synchronously at boot, before any React mounts. */ export const LAYOUT_MODE_KEY = 'zn:layout-mode' +/** localStorage key for the swipe-gesture assignments (#24); JSON, see + * ui-mobile/gestures.ts. */ +export const GESTURES_KEY = 'zn:gestures' + export function getLayoutMode(): LayoutMode { try { const raw = localStorage.getItem(LAYOUT_MODE_KEY) From 11f7f575e49ab1bc1674b8c58d8fcd822e519420 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 28 Aug 2026 10:50:01 -0500 Subject: [PATCH 4/4] Version 1.9.4 (build 15) --- ios/App/App.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 468e3e5..c251923 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -566,12 +566,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.3; + MARKETING_VERSION = 1.9.4; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -588,12 +588,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.3; + MARKETING_VERSION = 1.9.4; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -608,12 +608,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.3; + MARKETING_VERSION = 1.9.4; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -646,12 +646,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.3; + MARKETING_VERSION = 1.9.4; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos;