diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c96d48998..138980e37 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.68.0", + "version": "2.69.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/cli/commands/tasks.test.ts b/apps/desktop/src/cli/commands/tasks.test.ts new file mode 100644 index 000000000..e80deb088 --- /dev/null +++ b/apps/desktop/src/cli/commands/tasks.test.ts @@ -0,0 +1,66 @@ +import { promises as fsp } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createBackend } from '../backend' +import type { ParsedArgs } from '../args' +import { cmdTaskList } from './tasks' + +function makeArgs(flags: Array<[string, string]> = []): ParsedArgs { + const map = new Map() + for (const [k, v] of flags) map.set(k, [...(map.get(k) ?? []), v]) + return { positionals: [], flags: map } +} + +let tmpDir: string +let root: string +let out: string[] + +beforeAll(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-task-cli-')) + root = path.join(tmpDir, 'vault') + for (const d of ['inbox', 'quick', 'archive', 'trash']) { + await fsp.mkdir(path.join(root, d), { recursive: true }) + } + await fsp.writeFile( + path.join(root, 'inbox', 'todo.md'), + '- [ ] Buy milk\n- [-] Cancelled plan\n- [x] Done thing\n' + ) +}) + +afterAll(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }) +}) + +beforeEach(() => { + out = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + out.push(String(chunk)) + return true + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const backend = (): ReturnType => createBackend({ kind: 'local', root }) +const listed = (): string[] => + (JSON.parse(out.join('')) as Array<{ content: string }>).map((t) => t.content) + +describe('zn task list', () => { + it('leaves `[-]` cancelled tasks out of the open list (#450, #476)', async () => { + await cmdTaskList(backend(), makeArgs([['json', 'true']])) + expect(listed()).toEqual(['Buy milk']) + }) + + it('leaves cancelled tasks out of --unchecked', async () => { + await cmdTaskList(backend(), makeArgs([['unchecked', 'true'], ['json', 'true']])) + expect(listed()).toEqual(['Buy milk']) + }) + + it('still lists cancelled tasks with --all', async () => { + await cmdTaskList(backend(), makeArgs([['all', 'true'], ['json', 'true']])) + expect(listed()).toContain('Cancelled plan') + }) +}) diff --git a/apps/desktop/src/cli/commands/tasks.ts b/apps/desktop/src/cli/commands/tasks.ts index 05776d326..33f117f67 100644 --- a/apps/desktop/src/cli/commands/tasks.ts +++ b/apps/desktop/src/cli/commands/tasks.ts @@ -20,9 +20,10 @@ export async function cmdTaskList(vault: VaultBackend, args: ParsedArgs): Promis if (!showAll) { // A `[>]` forwarded record is history of a move; its live copy is in the // destination note and lists on its own, so showing both doubled every - // carried task (#611 review). `--all` still surfaces the records. - if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked && !t.forwarded) - else tasks = tasks.filter((t) => !t.checked && !t.waiting && !t.forwarded) + // carried task (#611 review). A `[-]` cancelled task is abandoned, so it is + // not open either (#450, #476). `--all` still surfaces both. + if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked && !t.forwarded && !t.cancelled) + else tasks = tasks.filter((t) => !t.checked && !t.waiting && !t.forwarded && !t.cancelled) } if (tag) tasks = tasks.filter((t) => t.tags.includes(tag)) diff --git a/apps/desktop/src/cli/vault-target.test.ts b/apps/desktop/src/cli/vault-target.test.ts index be91f67c6..014bc32ae 100644 --- a/apps/desktop/src/cli/vault-target.test.ts +++ b/apps/desktop/src/cli/vault-target.test.ts @@ -7,6 +7,7 @@ import { looksLikeServerUrl, resolveAuthToken, resolveDefaultTarget, + resolveServerToken, resolveTarget, resolveServerTarget, resolveVaultTarget, @@ -22,6 +23,19 @@ async function writeConfig(config: Record): Promise { await fsp.writeFile(path.join(configDir, 'zennotes.config.json'), JSON.stringify(config)) } +/** credentials.toml the way `zn connect` writes it: BurntSushi/toml indents + * the table's quoted URL keys. */ +async function writeCredentials(tokens: Record): Promise { + const lines = Object.entries(tokens).map(([url, token]) => ` "${url}" = "${token}"`) + await fsp.writeFile( + path.join(configDir, 'credentials.toml'), + ['# Server tokens for zn, keyed by URL. Keep this file private.', '[tokens]', ...lines, ''].join( + '\n' + ), + { mode: 0o600 } + ) +} + const HOME_PROFILE = { id: 'p1', name: 'home', @@ -55,8 +69,9 @@ beforeEach(async () => { }) }) -afterEach(() => { +afterEach(async () => { vi.unstubAllEnvs() + await fsp.rm(path.join(configDir, 'credentials.toml'), { force: true }) }) describe('looksLikeServerUrl', () => { @@ -293,6 +308,85 @@ describe('the default target follows the workspace the app has open (#688)', () }) }) +describe('the token `zn connect` saved reaches the server the app has open (#830)', () => { + const remoteApp = () => + writeConfig({ + vaultRoot: workVault, + workspaceMode: 'remote', + remoteWorkspace: { baseUrl: 'http://192.168.1.10:7878' }, + remoteWorkspaceProfileId: 'p1', + remoteWorkspaceProfiles: [{ ...HOME_PROFILE, authToken: undefined }] + }) + + it('follows the app with the saved token when nothing else supplies one', async () => { + await remoteApp() + await writeCredentials({ 'http://192.168.1.10:7878': 'saved-token' }) + expect(await resolveDefaultTarget(process.env)).toEqual({ + kind: 'remote', + name: 'home', + baseUrl: 'http://192.168.1.10:7878', + authToken: 'saved-token' + }) + }) + + it('ranks it last: --token, the environment and a legacy profile token all win', async () => { + await writeCredentials({ 'http://192.168.1.10:7878': 'saved-token' }) + expect(await resolveServerToken('http://192.168.1.10:7878', 'flag', null, {})).toBe('flag') + expect( + await resolveServerToken('http://192.168.1.10:7878', undefined, null, { + ZENNOTES_REMOTE_TOKEN: 'env-token' + }) + ).toBe('env-token') + expect(await resolveServerToken('http://192.168.1.10:7878', undefined, 'legacy', {})).toBe( + 'legacy' + ) + expect(await resolveServerToken('http://192.168.1.10:7878', undefined, null, {})).toBe( + 'saved-token' + ) + }) + + it('finds the key zn wrote for the same server however the app spells the URL', async () => { + // zn keys by the URL trimmed, without trailing slashes, and lowercased. + await writeCredentials({ 'https://notes.example.com/zen': 'saved-token' }) + expect(await resolveServerToken('https://Notes.Example.com/Zen/', undefined, null, {})).toBe( + 'saved-token' + ) + }) + + it('never lends a saved token to a different server', async () => { + await writeCredentials({ + 'http://192.168.1.10:7878': 'home-token', + 'https://notes.example.com': 'other-token' + }) + expect(await resolveServerToken('http://192.168.1.10:7879', undefined, null, {})).toBeNull() + expect(await resolveServerToken('https://notes.example.com:8443', undefined, null, {})).toBeNull() + expect(await resolveServerToken('http://notes.example.com', undefined, null, {})).toBeNull() + }) + + it('treats a missing or broken credentials file as no token, without throwing', async () => { + expect(await resolveServerToken('http://192.168.1.10:7878', undefined, null, {})).toBeNull() + await fsp.writeFile(path.join(configDir, 'credentials.toml'), 'tokens = "not a table"\n') + expect(await resolveServerToken('http://192.168.1.10:7878', undefined, null, {})).toBeNull() + await fsp.writeFile(path.join(configDir, 'credentials.toml'), '[tokens\n"http://x" = \n') + expect(await resolveServerToken('http://192.168.1.10:7878', undefined, null, {})).toBeNull() + await remoteApp() + expect(await resolveDefaultTarget(process.env)).toMatchObject({ authToken: null }) + }) + + it('also authenticates --server and --vault when they name that server', async () => { + await writeConfig({ + vaultRoot: workVault, + remoteWorkspaceProfiles: [{ ...HOME_PROFILE, authToken: undefined }] + }) + await writeCredentials({ 'http://192.168.1.10:7878': 'saved-token' }) + expect(await resolveServerTarget('home', undefined)).toMatchObject({ authToken: 'saved-token' }) + expect(await resolveServerTarget('192.168.1.10:7878', undefined)).toMatchObject({ + authToken: 'saved-token' + }) + expect(await resolveVaultTarget('home', undefined)).toMatchObject({ authToken: 'saved-token' }) + }) +}) + describe('sameVault: a vault switch is a different vault, not new credentials', () => { const local = (root: string): VaultTarget => ({ kind: 'local', root }) const server = (baseUrl: string, name = '', authToken: string | null = null): VaultTarget => ({ diff --git a/apps/desktop/src/cli/vault-target.ts b/apps/desktop/src/cli/vault-target.ts index ed125549e..3a67912be 100644 --- a/apps/desktop/src/cli/vault-target.ts +++ b/apps/desktop/src/cli/vault-target.ts @@ -25,6 +25,7 @@ import { readActiveWorkspaceFromConfig, readKnownVaultsFromConfig, readRemoteProfilesFromConfig, + readSavedServerToken, resolveVaultRoot, type KnownRemoteProfile } from '../mcp/vault-ops.js' @@ -77,6 +78,25 @@ export function resolveAuthToken( return profileToken?.trim() || null } +/** + * The token for a server already chosen by URL: `resolveAuthToken`, then the + * token `zn connect` saved for that URL (#830). A profile token here is only + * ever a legacy plaintext one, because the app keeps its own copy in the OS + * secret store, which this process cannot read; without the saved token, the + * MCP entry Settings → MCP installs could not reach a server that needs one + * even after `zn connect`. The Go zn checks the same four places in the same + * order when it follows the app. Looked up by the URL the caller already + * picked, a saved token can only authenticate that server, never choose one. + */ +export async function resolveServerToken( + baseUrl: string, + flagToken: string | undefined, + profileToken: string | null, + env: NodeJS.ProcessEnv = process.env +): Promise { + return resolveAuthToken(flagToken, profileToken, env) ?? (await readSavedServerToken(baseUrl)) +} + /** A bare URL rather than a profile name: has a scheme, or looks like * `host:port`. Profile names are free-form, so this stays conservative. */ export function looksLikeServerUrl(value: string): boolean { @@ -110,16 +130,17 @@ export async function resolveServerTarget( kind: 'remote', name: profile.name, baseUrl: profile.baseUrl, - authToken: resolveAuthToken(flagToken, profile.authToken) + authToken: await resolveServerToken(profile.baseUrl, flagToken, profile.authToken) } } if (looksLikeServerUrl(trimmed)) { + const baseUrl = normalizeBaseUrl(trimmed) return { kind: 'remote', name: '', - baseUrl: normalizeBaseUrl(trimmed), - authToken: resolveAuthToken(flagToken, null) + baseUrl, + authToken: await resolveServerToken(baseUrl, flagToken, null) } } @@ -161,7 +182,7 @@ export async function resolveVaultTarget( kind: 'remote', name: profile.name, baseUrl: profile.baseUrl, - authToken: resolveAuthToken(flagToken, profile.authToken) + authToken: await resolveServerToken(profile.baseUrl, flagToken, profile.authToken) } } @@ -174,8 +195,8 @@ export async function resolveVaultTarget( * the desktop app has open, a connected server included (#688). This is what * `zn mcp` falls back to without `--vault` / `--server` (#831), so an agent * works on the vault the user is looking at; the app's own token stays in the - * OS secret store, so a server that needs one gets it from - * `ZENNOTES_REMOTE_TOKEN` (or `--token`). + * OS secret store, so a server that needs one gets it from `--token`, + * `ZENNOTES_REMOTE_TOKEN`, or the token `zn connect` saved for its URL (#830). */ export async function resolveDefaultTarget( env: NodeJS.ProcessEnv = process.env, @@ -192,7 +213,7 @@ export async function resolveDefaultTarget( kind: 'remote', name: active.name, baseUrl: active.baseUrl, - authToken: resolveAuthToken(flagToken, active.authToken, env) + authToken: await resolveServerToken(active.baseUrl, flagToken, active.authToken, env) } } return { kind: 'local', root: await resolveVaultRoot() } diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index 0ad23bbd1..0f21e4652 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -13,6 +13,7 @@ import type { CloudSyncVault } from '@zennotes/bridge-contract/cloud-sync' import { afterEach, describe, expect, it, vi } from 'vitest' +import { CLOUD_VAULT_SUPERSEDED_MESSAGE } from '@zennotes/shared-domain/cloud-vault-availability' import { CloudServiceRequestError, createCloudSyncClient } from './cloud-sync-client' import { DesktopCloudSyncService, type DesktopCloudSyncServiceDependencies } from './cloud-sync-service' @@ -971,7 +972,8 @@ describe('deleted Cloud vault recovery (#791)', () => { ['unauthenticated', new CloudServiceRequestError('Sign in again', 401, 'UNAUTHENTICATED')], ['forbidden', new CloudServiceRequestError('Access denied', 403, 'FORBIDDEN')], ['server failure', new CloudServiceRequestError('Try later', 503, null)], - ['unstructured proxy 404', new CloudServiceRequestError('Not found', 404, null)] + ['unstructured proxy 404', new CloudServiceRequestError('Not found', 404, null)], + ['410 without Cloud\'s code', new CloudServiceRequestError('Gone', 410, null)] ])('keeps the association and cursor after %s', async (_label, failure) => { const { service, client, localRoot, storageDirectory } = await setup([cloudVault()]) const link = await service.link(localRoot, 'vault-1') @@ -1038,3 +1040,50 @@ describe('deleted Cloud vault recovery (#791)', () => { expect(await service.linkedVault(localRoot)).toEqual(replacement) }) }) + +describe('Cloud vault replaced by an encrypted one', () => { + const cloudVault = (): CloudSyncVault => ({ + id: 'vault-1', name: 'Notes', cursor: 0, + created_at: '2026-10-13T12:00:00.000Z', updated_at: '2026-10-13T12:00:00.000Z' + }) + const superseded = () => new CloudServiceRequestError( + 'This vault was replaced by an end-to-end encrypted vault. Update ZenNotes on this device to keep syncing it.', + 410, 'VAULT_SUPERSEDED', { successor_vault_id: 'vault-2' } + ) + const fingerprint = (value: string) => createHash('sha256').update(value).digest('hex') + const savedStatePath = (storage: string, root: string) => path.join( + storage, 'states', fingerprint(path.resolve(root)), fingerprint('https://zennotes.org'), + `${fingerprint('vault-1')}.json` + ) + + it('unlinks on the first 410 and says the vault is now encrypted, leaving local notes alone', async () => { + const { service, client, localRoot, storageDirectory } = await setup([cloudVault()]) + await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + const note = Buffer.from('Unsent local edit 📝\r\n') + await writeFile(path.join(localRoot, 'note.md'), note) + client.changes.mockRejectedValue(superseded()) + client.manifest.mockRejectedValue(superseded()) + + await expect(service.sync(localRoot)).rejects.toThrow(CLOUD_VAULT_SUPERSEDED_MESSAGE) + + expect(await service.linkedVault(localRoot)).toBeNull() + await expect(readFile(savedStatePath(storageDirectory, localRoot))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(path.join(localRoot, 'note.md'))).toEqual(note) + expect(client.deleteVault).not.toHaveBeenCalled() + }) + + it('unlinks the same way when the background probe meets the 410 first', async () => { + const { service, client, localRoot } = await setup([cloudVault()]) + await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + client.manifest.mockRejectedValue(superseded()) + + await expect(service.hasRemoteChanges(localRoot)).rejects.toThrow(CLOUD_VAULT_SUPERSEDED_MESSAGE) + + expect(await service.linkedVault(localRoot)).toBeNull() + client.manifest.mockClear() + expect(await service.hasRemoteChanges(localRoot)).toBe(false) + expect(client.manifest).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index b1ccec5bf..f446ec617 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -43,7 +43,14 @@ import { type DesktopPublishAssetFiles, type PublishAssetUploader } from './cloud-publish-assets' -import { CLOUD_VAULT_REMOVED_MESSAGE, confirmCloudVaultMissing, isCloudResourceMissing, sameCloudVaultLink } from '@zennotes/shared-domain/cloud-vault-availability' +import { + CLOUD_VAULT_REMOVED_MESSAGE, + CLOUD_VAULT_SUPERSEDED_MESSAGE, + confirmCloudVaultMissing, + isCloudResourceMissing, + isCloudVaultSuperseded, + sameCloudVaultLink +} from '@zennotes/shared-domain/cloud-vault-availability' import { createDesktopCloudSyncCoordinator, DesktopCloudSyncStateStore } from './cloud-sync-filesystem' import { rewriteBackupArchive } from './cloud-backup-archive' import { @@ -511,6 +518,9 @@ export class DesktopCloudSyncService { }) return manifest.cursor !== state.cursor } catch (error) { + if (isCloudVaultSuperseded(error) && await this.retireMissingLink(localRoot, link)) { + throw new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE) + } if (isCloudResourceMissing(error) && await this.retireMissingLink(localRoot, link)) { throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) } @@ -649,10 +659,8 @@ export class DesktopCloudSyncService { legacy_conflict_copies: result.legacyConflictCopies } } catch (error) { - if (cloudErrorCode(error) === 'VAULT_SUPERSEDED' && await this.retireMissingLink(localRoot, link)) { - throw new Error( - 'This Cloud vault is now end-to-end encrypted. Unlock encryption in Settings, then open the encrypted vault on this device.' - ) + if (isCloudVaultSuperseded(error) && await this.retireMissingLink(localRoot, link)) { + throw new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE) } if (await confirmCloudVaultMissing(client, link.vault_id, error) && await this.retireMissingLink(localRoot, link)) { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 981299321..4118d2274 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -128,6 +128,7 @@ import { setPortableConfig, getConfigFilePath, ensureConfigFile, + getConfigDir, } from "./app-config"; import { getCustomThemesDir, @@ -252,6 +253,15 @@ import { installMcpForClient, uninstallMcpForClient, } from "./mcp-integrations"; +import { + followAppServerToken, + getServerTokenState, + removeServerToken, + shareServerToken, + type ServerTokenPaths, + type ServerTokenTarget, +} from "./mcp-server-token"; +import { normalizeBaseUrl } from "./remote/connection"; import { getCliInstallStatus, installCli, @@ -2600,6 +2610,61 @@ async function exportNotePdf( } } +/** Where a shared server token lives, and the app's receipts for it (#830). */ +function serverTokenPaths(): ServerTokenPaths { + return { + configDir: getConfigDir(), + receiptsPath: path.join( + app.getPath("userData"), + "mcp", + "shared-server-tokens.json", + ), + }; +} + +/** The server Settings → MCP → Server token is about: the persisted + * workspace, because that is what both MCP servers read to follow the app, + * picked the same way (the configured profile, else one with the same URL), + * with the token the app connects to it with. */ +async function serverTokenTarget(): Promise { + const cfg = await loadConfig(); + if (cfg.workspaceMode !== "remote" || !cfg.remoteWorkspace?.baseUrl) { + return null; + } + const baseUrl = normalizeBaseUrl(cfg.remoteWorkspace.baseUrl); + const profile = + findRemoteProfileById( + cfg.remoteWorkspaceProfiles, + cfg.remoteWorkspaceProfileId, + ) ?? + cfg.remoteWorkspaceProfiles.find( + (entry) => normalizeBaseUrl(entry.baseUrl) === baseUrl, + ) ?? + null; + const token = + (profile && (await getRemoteWorkspaceSecret(profile.id))) ?? + cfg.remoteWorkspace.authToken ?? + null; + return { baseUrl, token: token?.trim() || null }; +} + +/** A shared token follows the app's; the file is never worth failing a + * profile change over. */ +async function followSharedServerToken( + baseUrl: string, + previous: string | null, + next: string | null, +): Promise { + try { + await followAppServerToken(baseUrl, previous, next, serverTokenPaths()); + } catch (err) { + console.warn( + "[mcp] could not update the shared server token:", + (err as Error).message, + ); + } +} + async function listRemoteWorkspaceProfiles(): Promise< RemoteWorkspaceProfile[] > { @@ -2620,6 +2685,15 @@ async function saveRemoteWorkspaceProfile( input: RemoteWorkspaceProfileInput & { lastConnectedAt?: number | null }, ): Promise { const normalizedId = input.id?.trim() || randomUUID(); + const nextToken = input.clearAuthToken + ? null + : typeof input.authToken === "string" && input.authToken.trim() + ? input.authToken.trim() + : undefined; + const previousToken = + nextToken === undefined + ? null + : await getRemoteWorkspaceSecret(normalizedId); await updateConfig((cfg) => { const normalizedBaseUrl = normalizeRemoteBaseUrl(input.baseUrl); const trimmedName = input.name?.trim() || ""; @@ -2679,6 +2753,9 @@ async function saveRemoteWorkspaceProfile( } else if (typeof input.authToken === "string" && input.authToken.trim()) { await setRemoteWorkspaceSecret(normalizedId, input.authToken.trim()); } + if (nextToken !== undefined) { + await followSharedServerToken(input.baseUrl, previousToken, nextToken); + } const cfg = await loadConfig(); const normalized = findRemoteProfileById( cfg.remoteWorkspaceProfiles, @@ -2703,6 +2780,7 @@ async function saveRemoteWorkspaceProfile( async function deleteRemoteWorkspaceProfile(id: string): Promise { const deletedSecret = await getRemoteWorkspaceSecret(id); + let forgottenServer = null as string | null; await updateConfig((cfg) => { const deletedProfile = findRemoteProfileById( cfg.remoteWorkspaceProfiles, @@ -2711,6 +2789,17 @@ async function deleteRemoteWorkspaceProfile(id: string): Promise { const nextProfiles = cfg.remoteWorkspaceProfiles.filter( (entry) => entry.id !== id, ); + // Another saved vault on the same server still needs its token. + if ( + deletedProfile && + !nextProfiles.some( + (entry) => + normalizeBaseUrl(entry.baseUrl) === + normalizeBaseUrl(deletedProfile.baseUrl), + ) + ) { + forgottenServer = deletedProfile.baseUrl; + } const nextCurrentProfileId = cfg.remoteWorkspaceProfileId === id ? null : cfg.remoteWorkspaceProfileId; const shouldClearLegacyRemoteWorkspace = @@ -2734,6 +2823,9 @@ async function deleteRemoteWorkspaceProfile(id: string): Promise { }; }); await deleteRemoteWorkspaceSecret(id); + if (forgottenServer) { + await followSharedServerToken(forgottenServer, deletedSecret, null); + } if (deletedSecret && currentRemoteWorkspaceProfileId === id) { remoteWorkspaceClient = null; } @@ -4890,6 +4982,23 @@ function registerIpc(): void { IPC.MCP_UNINSTALL, async (_e, id: McpClientId) => await uninstallMcpForClient(id), ); + // The renderer names no server and gets no token back: main reads both + // from its own config and only reports which state the file is in. + handle( + IPC.MCP_SERVER_TOKEN_STATE, + async () => + await getServerTokenState(await serverTokenTarget(), serverTokenPaths()), + ); + handle( + IPC.MCP_SHARE_SERVER_TOKEN, + async () => + await shareServerToken(await serverTokenTarget(), serverTokenPaths()), + ); + handle( + IPC.MCP_REMOVE_SERVER_TOKEN, + async () => + await removeServerToken(await serverTokenTarget(), serverTokenPaths()), + ); handle( IPC.MCP_GET_INSTRUCTIONS, async (): Promise => { diff --git a/apps/desktop/src/main/mcp-server-token.test.ts b/apps/desktop/src/main/mcp-server-token.test.ts new file mode 100644 index 000000000..1a8dfce48 --- /dev/null +++ b/apps/desktop/src/main/mcp-server-token.test.ts @@ -0,0 +1,132 @@ +import { promises as fsp } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + followAppServerToken, + getServerTokenState, + removeServerToken, + shareServerToken, + type ServerTokenPaths +} from './mcp-server-token' +import { credentialsPath, readSavedServerToken, writeSavedServerToken } from './remote/zn-credentials' + +let root: string +let paths: ServerTokenPaths + +beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-share-')) + paths = { + configDir: path.join(root, 'config'), + receiptsPath: path.join(root, 'userData', 'mcp', 'shared-server-tokens.json') + } +}) + +afterEach(async () => { + await fsp.rm(root, { recursive: true, force: true }) +}) + +const SERVER = 'http://192.168.1.10:7878' +const app = (token: string | null = 'app-token') => ({ baseUrl: SERVER, token }) +const saved = () => readSavedServerToken(paths.configDir, SERVER) + +describe('the state Settings shows, never the token', () => { + it('has nothing to share for a local vault or a server without a token', async () => { + expect(await getServerTokenState(null, paths)).toEqual({ kind: 'none', reason: 'local' }) + expect(await getServerTokenState(app(null), paths)).toEqual({ kind: 'none', reason: 'no-token' }) + }) + + it('reports missing, shared, different and unreadable, by URL and file only', async () => { + const where = { serverUrl: SERVER, credentialsPath: credentialsPath(paths.configDir) } + expect(await getServerTokenState(app(), paths)).toEqual({ kind: 'missing', ...where }) + await writeSavedServerToken(paths.configDir, SERVER, 'app-token') + expect(await getServerTokenState(app(), paths)).toEqual({ kind: 'shared', ...where }) + await writeSavedServerToken(paths.configDir, SERVER, 'from-zn-connect') + const different = await getServerTokenState(app(), paths) + expect(different).toEqual({ kind: 'different', ...where }) + expect(JSON.stringify(different)).not.toMatch(/app-token|from-zn-connect/) + await fsp.writeFile(credentialsPath(paths.configDir), '[tokens\n') + expect(await getServerTokenState(app(), paths)).toMatchObject({ kind: 'unreadable', ...where }) + }) + + it('names the server the way the MCP servers will look it up', async () => { + const state = await getServerTokenState({ baseUrl: '192.168.1.10:7878/', token: 't' }, paths) + expect(state).toMatchObject({ kind: 'missing', serverUrl: SERVER }) + }) +}) + +describe('sharing and removing', () => { + it('shares the app token, and Replace overwrites a different one', async () => { + expect(await shareServerToken(app(), paths)).toMatchObject({ kind: 'shared' }) + expect(await saved()).toBe('app-token') + await writeSavedServerToken(paths.configDir, SERVER, 'from-zn-connect') + expect(await shareServerToken(app(), paths)).toMatchObject({ kind: 'shared' }) + expect(await saved()).toBe('app-token') + }) + + it('does nothing without a server token to share', async () => { + expect(await shareServerToken(app(null), paths)).toEqual({ kind: 'none', reason: 'no-token' }) + expect(await shareServerToken(null, paths)).toEqual({ kind: 'none', reason: 'local' }) + await expect(fsp.access(credentialsPath(paths.configDir))).rejects.toThrow() + }) + + it('removes only the app token, never a different one the user saved', async () => { + await writeSavedServerToken(paths.configDir, SERVER, 'from-zn-connect') + expect(await removeServerToken(app(), paths)).toMatchObject({ kind: 'different' }) + expect(await saved()).toBe('from-zn-connect') + await shareServerToken(app(), paths) + expect(await removeServerToken(app(), paths)).toMatchObject({ kind: 'missing' }) + expect(await saved()).toBeNull() + }) + + it('refuses to write over a file it cannot read', async () => { + await fsp.mkdir(paths.configDir, { recursive: true }) + await fsp.writeFile(credentialsPath(paths.configDir), '[tokens\n') + await expect(shareServerToken(app(), paths)).rejects.toThrow(/Fix or move it/) + expect(await fsp.readFile(credentialsPath(paths.configDir), 'utf8')).toBe('[tokens\n') + }) +}) + +describe('a shared token follows the app', () => { + it('takes the new token when the server token changes in the app', async () => { + await shareServerToken(app('old'), paths) + await followAppServerToken(SERVER, 'old', 'new', paths) + expect(await saved()).toBe('new') + }) + + it('goes when the server is forgotten in the app', async () => { + await shareServerToken(app('old'), paths) + await followAppServerToken(`${SERVER}/`, 'old', null, paths) + expect(await saved()).toBeNull() + // The receipt went with it: a later zn connect is the user's own. + await writeSavedServerToken(paths.configDir, SERVER, 'old') + await followAppServerToken(SERVER, 'old', null, paths) + expect(await saved()).toBe('old') + }) + + it('leaves a token it never shared, even one equal to the app token', async () => { + await writeSavedServerToken(paths.configDir, SERVER, 'app-token') + await followAppServerToken(SERVER, 'app-token', null, paths) + expect(await saved()).toBe('app-token') + }) + + it('stops claiming a shared token the user has since replaced', async () => { + await shareServerToken(app('old'), paths) + await writeSavedServerToken(paths.configDir, SERVER, 'theirs') + await followAppServerToken(SERVER, 'old', 'new', paths) + expect(await saved()).toBe('theirs') + // No receipt any more, so not even a matching token is touched later. + await writeSavedServerToken(paths.configDir, SERVER, 'new') + await followAppServerToken(SERVER, 'new', null, paths) + expect(await saved()).toBe('new') + }) + + it('ignores a re-save of the same token, and leaves an unreadable file alone', async () => { + await shareServerToken(app('same'), paths) + await followAppServerToken(SERVER, 'same', 'same', paths) + expect(await saved()).toBe('same') + await fsp.writeFile(credentialsPath(paths.configDir), '[tokens\n') + await followAppServerToken(SERVER, 'same', 'next', paths) + expect(await fsp.readFile(credentialsPath(paths.configDir), 'utf8')).toBe('[tokens\n') + }) +}) diff --git a/apps/desktop/src/main/mcp-server-token.ts b/apps/desktop/src/main/mcp-server-token.ts new file mode 100644 index 000000000..6ed562005 --- /dev/null +++ b/apps/desktop/src/main/mcp-server-token.ts @@ -0,0 +1,144 @@ +/** + * Settings → MCP → Server token (#830): on request, hand the app's token for + * the ZenNotes Server it is connected to over to zn and the MCP servers. + * + * The app keeps server tokens in the OS secret store, which an assistant's + * MCP process cannot read, so until now the only way to give it one was + * `zn connect --no-default` in a terminal, typing the token a second + * time. Sharing writes the entry `zn connect` would, in the same file. It is + * opt-in because it moves the token out of the secret store into a file any + * program running as the user can read. + * + * Everything works from the main process's own config: the renderer names no + * server and never sees a token, only which state the file is in. The app + * keeps receipts of what it wrote, so it only ever updates or removes a token + * it put there itself: forgetting a server in the app takes its shared token + * with it, and a token saved with `zn connect` is left alone. + */ + +import { randomUUID } from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import type { McpServerTokenState } from '@shared/mcp-clients' +import { normalizeBaseUrl } from './remote/connection' +import { + credentialsPath, + inspectSavedServerToken, + serverTokenKey, + writeSavedServerToken +} from './remote/zn-credentials' + +/** The server the app is set to (what both MCP servers follow), and the + * token the app connects to it with. */ +export interface ServerTokenTarget { + baseUrl: string + token: string | null +} + +export interface ServerTokenPaths { + /** Where config.toml and zn's credentials.toml live. */ + configDir: string + /** The app's record of the servers whose token it shared. */ + receiptsPath: string +} + +async function readReceipts(file: string): Promise> { + try { + const data = JSON.parse(await fs.readFile(file, 'utf8')) as { + schemaVersion?: unknown + servers?: unknown + } + if (data.schemaVersion !== 1 || !Array.isArray(data.servers)) return new Set() + return new Set(data.servers.filter((key): key is string => typeof key === 'string')) + } catch { + return new Set() + } +} + +async function recordShare(paths: ServerTokenPaths, key: string, shared: boolean): Promise { + const receipts = await readReceipts(paths.receiptsPath) + if (receipts.has(key) === shared) return + if (shared) receipts.add(key) + else receipts.delete(key) + await fs.mkdir(path.dirname(paths.receiptsPath), { recursive: true }) + const temporary = `${paths.receiptsPath}.${randomUUID()}.tmp` + try { + await fs.writeFile( + temporary, + JSON.stringify({ schemaVersion: 1, servers: [...receipts].sort() }) + ) + await fs.rename(temporary, paths.receiptsPath) + } finally { + await fs.rm(temporary, { force: true }) + } +} + +export async function getServerTokenState( + target: ServerTokenTarget | null, + paths: ServerTokenPaths +): Promise { + if (!target) return { kind: 'none', reason: 'local' } + if (!target.token) return { kind: 'none', reason: 'no-token' } + const serverUrl = normalizeBaseUrl(target.baseUrl) + const where = { serverUrl, credentialsPath: credentialsPath(paths.configDir) } + const saved = await inspectSavedServerToken(paths.configDir, serverUrl) + if (saved.kind === 'unreadable') return { kind: 'unreadable', ...where, message: saved.message } + if (saved.kind === 'none') return { kind: 'missing', ...where } + return saved.token === target.token ? { kind: 'shared', ...where } : { kind: 'different', ...where } +} + +/** Saves the app's token for its server where zn and both MCP servers look, + * replacing another token saved for the same server. */ +export async function shareServerToken( + target: ServerTokenTarget | null, + paths: ServerTokenPaths +): Promise { + if (target?.token) { + const serverUrl = normalizeBaseUrl(target.baseUrl) + await writeSavedServerToken(paths.configDir, serverUrl, target.token) + await recordShare(paths, serverTokenKey(serverUrl), true) + } + return await getServerTokenState(target, paths) +} + +/** Removes the saved token, but only while it is the app's: a different one + * for the same server was saved by the user and is theirs to remove. */ +export async function removeServerToken( + target: ServerTokenTarget | null, + paths: ServerTokenPaths +): Promise { + const state = await getServerTokenState(target, paths) + if (state.kind === 'shared') { + await writeSavedServerToken(paths.configDir, state.serverUrl, null) + await recordShare(paths, serverTokenKey(state.serverUrl), false) + } + return await getServerTokenState(target, paths) +} + +/** + * Keeps a shared token in step with the app's: `next` is the server's new + * token in the app, or null once the app has none for it (the token was + * cleared, or the server forgotten). Only a token the app shared, and that + * the file still holds unchanged, is replaced or removed. Anything else there + * now was put by the user, so the app stops claiming it; a file that cannot + * be read is left for the user to fix. + */ +export async function followAppServerToken( + baseUrl: string, + previous: string | null, + next: string | null, + paths: ServerTokenPaths +): Promise { + if (!previous || previous === next) return + const serverUrl = normalizeBaseUrl(baseUrl) + const key = serverTokenKey(serverUrl) + if (!(await readReceipts(paths.receiptsPath)).has(key)) return + const saved = await inspectSavedServerToken(paths.configDir, serverUrl) + if (saved.kind === 'unreadable') return + if (saved.kind === 'none' || saved.token !== previous) { + await recordShare(paths, key, false) + return + } + await writeSavedServerToken(paths.configDir, serverUrl, next) + if (next === null) await recordShare(paths, key, false) +} diff --git a/apps/desktop/src/main/remote/zn-credentials.test.ts b/apps/desktop/src/main/remote/zn-credentials.test.ts new file mode 100644 index 000000000..0369d950f --- /dev/null +++ b/apps/desktop/src/main/remote/zn-credentials.test.ts @@ -0,0 +1,116 @@ +import { promises as fsp } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + credentialsPath, + inspectSavedServerToken, + readSavedServerToken, + serverTokenKey, + UnreadableCredentialsError, + writeSavedServerToken +} from './zn-credentials' + +let configDir: string + +beforeEach(async () => { + configDir = path.join(await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-creds-')), 'zennotes') +}) + +afterEach(async () => { + await fsp.rm(path.dirname(configDir), { recursive: true, force: true }) +}) + +/** The file exactly as `zn connect` writes it (BurntSushi/toml indents the + * table's quoted keys). */ +async function writeAsZn(tokens: Record): Promise { + await fsp.mkdir(configDir, { recursive: true }) + const lines = Object.entries(tokens).map(([url, token]) => ` "${url}" = "${token}"`) + await fsp.writeFile( + credentialsPath(configDir), + ['# Server tokens for zn, keyed by URL. Keep this file private.', '[tokens]', ...lines, ''].join('\n'), + { mode: 0o600 } + ) +} + +describe('serverTokenKey', () => { + it('keys a server the way zn does: trimmed, no trailing slashes, lowercased', () => { + expect(serverTokenKey(' https://Notes.Example.com/Zen// ')).toBe('https://notes.example.com/zen') + expect(serverTokenKey('http://127.0.0.1:7878')).toBe('http://127.0.0.1:7878') + }) +}) + +describe('reading', () => { + it('finds the token zn saved, however the URL is spelled', async () => { + await writeAsZn({ 'https://notes.example.com/zen': 'saved' }) + expect(await readSavedServerToken(configDir, 'https://Notes.Example.com/Zen/')).toBe('saved') + expect(await readSavedServerToken(configDir, 'https://notes.example.com')).toBeNull() + }) + + it('has no token without a file, and none in a file zn could not read either', async () => { + expect(await readSavedServerToken(configDir, 'http://x:1')).toBeNull() + await fsp.mkdir(configDir, { recursive: true }) + await fsp.writeFile(credentialsPath(configDir), '[tokens\n') + expect(await readSavedServerToken(configDir, 'http://x:1')).toBeNull() + expect((await inspectSavedServerToken(configDir, 'http://x:1')).kind).toBe('unreadable') + }) +}) + +describe('writing', () => { + it('creates a private file zn can read, with zn header and table', async () => { + await writeSavedServerToken(configDir, 'http://127.0.0.1:7878/', 'tok') + const raw = await fsp.readFile(credentialsPath(configDir), 'utf8') + expect(raw).toBe( + '# Server tokens for zn, keyed by URL. Keep this file private.\n[tokens]\n"http://127.0.0.1:7878" = "tok"\n' + ) + if (process.platform !== 'win32') { + expect((await fsp.stat(credentialsPath(configDir))).mode & 0o777).toBe(0o600) + } + }) + + it('keeps every other token, and the file private even if it was not', async () => { + await writeAsZn({ 'https://a.example.com': 'a', 'https://b.example.com': 'b' }) + await fsp.chmod(credentialsPath(configDir), 0o644) + await writeSavedServerToken(configDir, 'https://b.example.com', 'b2') + await writeSavedServerToken(configDir, 'https://c.example.com', 'c') + expect(await readSavedServerToken(configDir, 'https://a.example.com')).toBe('a') + expect(await readSavedServerToken(configDir, 'https://b.example.com')).toBe('b2') + expect(await readSavedServerToken(configDir, 'https://c.example.com')).toBe('c') + if (process.platform !== 'win32') { + expect((await fsp.stat(credentialsPath(configDir))).mode & 0o777).toBe(0o600) + } + }) + + it('removes one server, and creates nothing to remove a token that is not there', async () => { + await writeSavedServerToken(configDir, 'http://gone:1', null) + await expect(fsp.access(credentialsPath(configDir))).rejects.toThrow() + await writeAsZn({ 'https://a.example.com': 'a', 'https://b.example.com': 'b' }) + await writeSavedServerToken(configDir, 'https://A.example.com/', null) + expect(await readSavedServerToken(configDir, 'https://a.example.com')).toBeNull() + expect(await readSavedServerToken(configDir, 'https://b.example.com')).toBe('b') + }) + + it('round-trips a token with quotes and backslashes, and keeps keys a later zn may add', async () => { + await fsp.mkdir(configDir, { recursive: true }) + await fsp.writeFile(credentialsPath(configDir), 'version = 2\n[tokens]\n') + const odd = 'a"b\\c\'d' + await writeSavedServerToken(configDir, 'http://x:1', odd) + expect(await readSavedServerToken(configDir, 'http://x:1')).toBe(odd) + expect(await fsp.readFile(credentialsPath(configDir), 'utf8')).toContain('version = 2') + }) + + it('never replaces a file it cannot read, and never quotes it in the error', async () => { + await fsp.mkdir(configDir, { recursive: true }) + for (const broken of [ + '"http://x:1" = "secret-in-a-broken-line\n', + 'tokens = "secret-not-a-table"\n', + '[tokens]\n"http://x:1" = 42\n' + ]) { + await fsp.writeFile(credentialsPath(configDir), broken) + const error = await writeSavedServerToken(configDir, 'http://y:2', 'new').catch((e: unknown) => e) + expect(error).toBeInstanceOf(UnreadableCredentialsError) + expect(String((error as Error).message)).not.toContain('secret') + expect(await fsp.readFile(credentialsPath(configDir), 'utf8')).toBe(broken) + } + }) +}) diff --git a/apps/desktop/src/main/remote/zn-credentials.ts b/apps/desktop/src/main/remote/zn-credentials.ts new file mode 100644 index 000000000..d7b7deaeb --- /dev/null +++ b/apps/desktop/src/main/remote/zn-credentials.ts @@ -0,0 +1,149 @@ +/** + * zn's server-token store: credentials.toml beside config.toml (#830). + * + * The Go zn owns the format (ZenNotes/tui, internal/config/workspaces.go): a + * private file with one `[tokens]` table keyed by the server URL trimmed, + * without trailing slashes, and lowercased. `zn connect` writes it, both MCP + * servers read it when they follow the app to a server, and Settings → MCP + * writes the app's own token there when the user asks. Every writer keeps + * zn's rules: the other tokens survive, the file stays private, and a file + * that cannot be read as that format is never replaced, because the tokens in + * it would be lost. + * + * Plain Node on purpose: the bundled MCP server imports this, and it runs + * without Electron. + */ + +import { randomUUID } from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml' + +const HEADER = '# Server tokens for zn, keyed by URL. Keep this file private.\n' + +export function credentialsPath(configDir: string): string { + return path.join(configDir, 'credentials.toml') +} + +/** zn's key for a server: the URL trimmed, without trailing slashes, lowercased. */ +export function serverTokenKey(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, '').toLowerCase() +} + +/** The file exists but is not zn's format, so nothing may overwrite it. The + * message never quotes the file: any line of it can be a token. */ +export class UnreadableCredentialsError extends Error { + constructor(filePath: string, reason: string) { + super(`${filePath} ${reason} Fix or move it, then try again.`) + this.name = 'UnreadableCredentialsError' + } +} + +interface CredentialsFile { + /** Everything the file holds, so a rewrite keeps keys a later zn may add. */ + document: Record + tokens: Record +} + +async function readCredentialsFile(configDir: string): Promise { + const filePath = credentialsPath(configDir) + let raw: string + try { + raw = await fs.readFile(filePath, 'utf8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { document: {}, tokens: {} } + throw new UnreadableCredentialsError(filePath, 'could not be read.') + } + let document: Record + try { + document = parseToml(raw) as Record + } catch { + throw new UnreadableCredentialsError(filePath, 'is not valid TOML.') + } + const table = document.tokens ?? {} + if (typeof table !== 'object' || table === null || Array.isArray(table)) { + throw new UnreadableCredentialsError(filePath, 'has no [tokens] table.') + } + const tokens: Record = {} + for (const [key, value] of Object.entries(table)) { + // zn decodes the table as strings and refuses the file otherwise. + if (typeof value !== 'string') { + throw new UnreadableCredentialsError(filePath, 'holds a token that is not a string.') + } + tokens[key] = value + } + return { document, tokens } +} + +/** What the file says about one server, for a caller that must not guess. */ +export type SavedServerToken = + | { kind: 'none' } + | { kind: 'saved'; token: string } + | { kind: 'unreadable'; message: string } + +export async function inspectSavedServerToken( + configDir: string, + baseUrl: string +): Promise { + try { + const token = (await readCredentialsFile(configDir)).tokens[serverTokenKey(baseUrl)]?.trim() + return token ? { kind: 'saved', token } : { kind: 'none' } + } catch (err) { + if (err instanceof UnreadableCredentialsError) return { kind: 'unreadable', message: err.message } + throw err + } +} + +/** The token saved for a server, or null. A file that cannot be read holds + * none, as it does for zn. */ +export async function readSavedServerToken( + configDir: string, + baseUrl: string +): Promise { + try { + const saved = await inspectSavedServerToken(configDir, baseUrl) + return saved.kind === 'saved' ? saved.token : null + } catch { + return null + } +} + +/** + * Saves one server's token, or with null removes it, keeping every other + * token. The new file is written privately beside the old one and renamed + * over it, so a reader never sees half a file and the result is 0600 whatever + * the old file's mode was. Throws UnreadableCredentialsError rather than + * replace a file it could not read. + */ +export async function writeSavedServerToken( + configDir: string, + baseUrl: string, + token: string | null +): Promise { + const { document, tokens } = await readCredentialsFile(configDir) + const key = serverTokenKey(baseUrl) + if (token === null) { + if (!(key in tokens)) return + delete tokens[key] + } else { + tokens[key] = token.trim() + } + const body = stringifyToml({ ...document, tokens }) + const target = credentialsPath(configDir) + await fs.mkdir(configDir, { recursive: true }) + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + const handle = await fs.open(temporary, 'wx', 0o600) + try { + await handle.writeFile(`${HEADER}${body.endsWith('\n') ? body : `${body}\n`}`, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + try { + await fs.rename(temporary, target) + } catch (err) { + await fs.rm(temporary, { force: true }) + throw err + } + await fs.chmod(target, 0o600).catch(() => {}) +} diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index 3e63d32e0..a5d8a0fdf 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -56,6 +56,7 @@ describe('vault_info (#688)', () => { }) expect(info).not.toHaveProperty('vaultRoot') expect(String(info.notes)).toContain('ZENNOTES_REMOTE_TOKEN') + expect(String(info.notes)).toContain('zn connect http://192.168.1.10:7878 --no-default') expect(String(info.notes)).toContain('INBOX mode') }) @@ -141,14 +142,80 @@ describe('tools run through the backend', () => { }) }) +describe('list_tasks', () => { + const task = (content: string, extra: Record = {}): unknown => ({ + content, + noteFolder: 'inbox', + checked: false, + waiting: false, + forwarded: false, + cancelled: false, + tags: [], + ...extra + }) + const tasksBackend = backend({ + scanAllTasks: async () => + [ + task('Buy milk', { due: '2026-10-20' }), + task('Cancelled plan', { cancelled: true, due: '2026-10-15' }), + task('Done thing', { checked: true }) + ] as never + }) + const contents = (rows: unknown): string[] => + (rows as Array<{ content: string }>).map((t) => t.content) + + it('leaves `[-]` cancelled tasks out of the default open list (#450, #476)', async () => { + expect(contents(await callTool('list_tasks', {}, tasksBackend))).toEqual(['Buy milk']) + expect( + contents(await callTool('list_tasks', { dueBefore: '2026-10-31' }, tasksBackend)) + ).toEqual(['Buy milk']) + }) + + it('still returns cancelled tasks for status "all"', async () => { + expect(contents(await callTool('list_tasks', { status: 'all' }, tasksBackend))).toContain( + 'Cancelled plan' + ) + }) +}) + describe('describeToolError', () => { + const rejected = () => new RemoteRequestError('The ZenNotes server rejected the connection.', 401) + const server = (authToken: string | null): VaultTarget => ({ + kind: 'remote', + name: 'home', + baseUrl: 'https://notes.example.com', + authToken + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + it('turns a 401 from the server into the token hint', () => { - const text = describeToolError( - new RemoteRequestError('The ZenNotes server rejected the connection.', 401) - ) + const text = describeToolError(rejected()) expect(text).toContain('rejected the connection') expect(text).toContain('ZENNOTES_REMOTE_TOKEN') + expect(text).toContain('zn connect --no-default') + }) + + it('points at the app first, then names the server to run `zn connect` for (#830)', () => { + vi.stubEnv('ZENNOTES_REMOTE_TOKEN', '') + const text = describeToolError(rejected(), server(null)) + expect(text).toContain('no valid token') + expect(text).toContain('Let assistants use it under Settings → MCP → Server token') + expect(text).toContain('`zn connect https://notes.example.com --no-default`') + expect(text).toContain('the next call uses it') + }) + + it('blames a stale ZENNOTES_REMOTE_TOKEN, which a saved token cannot outrank', () => { + vi.stubEnv('ZENNOTES_REMOTE_TOKEN', 'stale') + const text = describeToolError(rejected(), server('stale')) + expect(text).toContain('The rejected token is ZENNOTES_REMOTE_TOKEN') + expect(text).toContain('remove it and run `zn connect https://notes.example.com --no-default`') + // A different rejected token is not the environment's fault. + expect(describeToolError(rejected(), server('saved'))).toContain('no valid token') }) + it('passes other errors through untouched', () => { expect(describeToolError(new Error('boom'))).toBe('boom') expect(describeToolError(new RemoteRequestError('nope', 500))).toBe('nope') @@ -365,6 +432,58 @@ describe('runMcpServer follows the target it is given (#831)', () => { } }) + it('reaches the server the app has open with the token `zn connect` saved, from the next call on (#830)', async () => { + const seen: Array = [] + const fake: HttpServer = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + seen.push(req.headers.authorization ?? null) + if (req.headers.authorization !== 'Bearer saved-830') { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'unauthorized' })) + return + } + const bodies: Record = { + '/api/vault': { root: '/srv/notes', name: 'notes' }, + '/api/vault/settings': { primaryNotesLocation: 'inbox', systemFolderPaths: null }, + '/api/folders': [] + } + res.writeHead(url.pathname in bodies ? 200 : 404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(bodies[url.pathname] ?? null)) + }) + await new Promise((resolve) => fake.listen(0, '127.0.0.1', resolve)) + const address = fake.address() + if (address == null || typeof address === 'string') throw new Error('no port') + const baseUrl = `http://127.0.0.1:${address.port}` + const credentials = path.join(configDir, 'credentials.toml') + + try { + // The app is connected to the server. Its token sits in the OS secret + // store, so the config this process reads carries none. + await writeConfig({ + vaultRoot: alpha, + workspaceMode: 'remote', + remoteWorkspace: { baseUrl }, + remoteWorkspaceProfileId: 'srv', + remoteWorkspaceProfiles: [{ id: 'srv', name: 'Server', baseUrl, vaultPath: '/srv/notes' }] + }) + // No flags: the entry Settings → MCP installs when zn is not the app's own. + const s = await session() + const before = await s.vaultInfo() + expect(before.error).toContain('no valid token') + expect(before.error).toContain(`zn connect ${baseUrl} --no-default`) + + // `zn connect --no-default` in a terminal while the agent runs. + await fsp.writeFile(credentials, `[tokens]\n "${baseUrl}" = "saved-830"\n`, { mode: 0o600 }) + const { info } = await s.vaultInfo() + expect(info).toMatchObject({ kind: 'remote', server: baseUrl, authConfigured: true }) + expect(seen.at(-1)).toBe('Bearer saved-830') + await s.close() + } finally { + await new Promise((resolve) => fake.close(() => resolve())) + await fsp.rm(credentials, { force: true }) + } + }) + it('retries until a vault resolves, and starts the session at the first call it serves', async () => { // One attempt at startup (warned), then one per tool call. const outcomes: Array = [ diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 29189d8dc..75cac5277 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -21,7 +21,12 @@ import { import { resolveInstructions } from './instructions-store.js' import { createBackend, type VaultBackend } from '../cli/backend.js' import { runRecordingAgentEdit } from './agent-edit-recorder.js' -import { resolveDefaultTarget, sameVault, type VaultTarget } from '../cli/vault-target.js' +import { + REMOTE_TOKEN_ENV, + resolveDefaultTarget, + sameVault, + type VaultTarget +} from '../cli/vault-target.js' import { RemoteRequestError } from '../main/remote/connection.js' import type { NoteFolder } from './vault-ops.js' import { addComment, listCommentThreads, replyToComment, resolveComment } from './comment-ops.js' @@ -165,7 +170,7 @@ const TOOLS: ToolDef[] = [ pathNotes + (description.authConfigured ? '' - : ' No token is configured for this server in this process. If calls fail with 401, set ZENNOTES_REMOTE_TOKEN in the MCP server\'s environment to the token the server was started with.') + : ` No token is configured for this server in this process. If calls fail with 401, ${tokenHint(description.baseUrl)}`) } } const vault = description.root @@ -674,7 +679,7 @@ const TOOLS: ToolDef[] = [ type: 'string', enum: ['open', 'done', 'waiting', 'all'], description: - 'open = unchecked & not waiting (default). done = checked. waiting = has @waiting. all = everything.' + 'open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything.' }, priority: { type: 'string', @@ -722,7 +727,10 @@ const TOOLS: ToolDef[] = [ if (folder && t.noteFolder !== folder) return false // A `[>]` record's live copy sits in the destination note; listing the // record as open doubled every carried task for agents (#611 review). - if (status === 'open' && (t.checked || t.waiting || t.forwarded)) return false + // A `[-]` cancelled task is abandoned, so it is not open either (#450, #476). + if (status === 'open' && (t.checked || t.waiting || t.forwarded || t.cancelled)) { + return false + } if (status === 'done' && !t.checked) return false if (status === 'waiting' && !t.waiting) return false if (priority && t.priority !== priority) return false @@ -969,20 +977,48 @@ export function listToolNames(): string[] { /** * A server that turns the request away is the one failure an agent cannot * fix by retrying: the desktop app keeps its token in the OS secret store, - * so this process only has one if the environment supplied it. Say so. + * where this process cannot read it, so say how to give it one. `target` is + * the vault the call ran against, null when none resolved. The Go `zn mcp` + * (ZenNotes/tui, internal/mcp/server.go) words it the same way. */ -export function describeToolError(err: unknown): string { +export function describeToolError(err: unknown, target: VaultTarget | null = null): string { const message = err instanceof Error ? err.message : String(err) if (err instanceof RemoteRequestError && (err.status === 401 || err.status === 403)) { + const server = target?.kind === 'remote' ? target : null + // The environment outranks a saved token, so a stale one there keeps + // failing however often `zn connect` runs. + const fromEnv = process.env[REMOTE_TOKEN_ENV]?.trim() + if (server && fromEnv && server.authToken === fromEnv) { + return ( + `${message} The rejected token is ZENNOTES_REMOTE_TOKEN from the MCP server's environment, which wins over one saved with \`zn connect\`: ` + + `update it there, or remove it and run \`zn connect ${server.baseUrl} --no-default\` once in a terminal.` + ) + } return ( - `${message} The MCP server has no valid token for this ZenNotes server. Set ZENNOTES_REMOTE_TOKEN ` + - 'in the MCP server\'s environment (the desktop app\'s copy lives in the OS secret store, which zn cannot read), ' + - 'or run zn with --token.' + `${message} The MCP server has no valid token for this ZenNotes server; the desktop app keeps its copy in the OS secret store, which this process cannot read. ` + + `To fix it, ${tokenHint(server?.baseUrl ?? '')}` ) } return message } +/** + * How to hand this process a server token. This server ships inside the + * desktop app, so the app's own way comes first: Settings → MCP → Server token + * saves the token where `zn connect` would (#830). `--no-default` matters for + * the terminal way: a plain `zn connect` also makes the server zn's own + * default, and a Go `zn mcp` that does not follow the app would then stop + * following it. + */ +function tokenHint(serverUrl: string): string { + return ( + 'in the ZenNotes app, choose Let assistants use it under Settings → MCP → Server token, ' + + `or run \`zn connect ${serverUrl || ''} --no-default\` once in a terminal. ` + + 'Either saves the token where zn and this MCP server look, and the next call uses it. ' + + 'Setting ZENNOTES_REMOTE_TOKEN in the MCP server\'s environment works too.' + ) +} + /** A vault named in a sentence. The app often names a saved server after its * host already ("Server Vault (10.0.0.5:7878)"), so the name is quoted and * the URL follows "at" rather than a second parenthesis. */ @@ -1038,11 +1074,16 @@ export class VaultSession { private readonly open: (target: VaultTarget) => VaultBackend = createBackend ) {} - /** The backend for one call. `confirm` marks vault_info, the only call that - * moves the session to a new vault; `previous` is the vault it moved away - * from, null when the vault did not change. Calls resolve one at a time so - * a batch sees one consistent session. */ - backendFor(confirm: boolean): Promise<{ backend: VaultBackend; previous: VaultTarget | null }> { + /** The backend for one call and the vault it reaches. `confirm` marks + * vault_info, the only call that moves the session to a new vault; + * `previous` is the vault it moved away from, null when the vault did not + * change. Calls resolve one at a time so a batch sees one consistent + * session. */ + backendFor(confirm: boolean): Promise<{ + backend: VaultBackend + target: VaultTarget + previous: VaultTarget | null + }> { const next = this.queue.then(() => this.step(confirm)) this.queue = next.catch(() => undefined) return next @@ -1050,7 +1091,7 @@ export class VaultSession { private async step( confirm: boolean - ): Promise<{ backend: VaultBackend; previous: VaultTarget | null }> { + ): Promise<{ backend: VaultBackend; target: VaultTarget; previous: VaultTarget | null }> { const target = await this.resolve() let previous: VaultTarget | null = null if (this.target && this.backend && !(await sameVault(this.target, target))) { @@ -1062,7 +1103,7 @@ export class VaultSession { this.backend = this.open(target) this.target = target } - return { backend: this.backend, previous } + return { backend: this.backend, target: this.target, previous } } } @@ -1126,8 +1167,11 @@ export async function runMcpServer(options: McpServerOptions = {}): Promise // An edit to a note is recorded so the user can review it in the app. const result = await runRecordingAgentEdit(name, toolArgs, backend, defaultCommentAuthor(), () => @@ -1138,7 +1182,7 @@ export async function runMcpServer(options: McpServerOptions = {}): Promise { + return await readZnSavedServerToken(portableConfigDir(), baseUrl) +} + /** * The user's "Sync title heading on rename" preference (#455). * @@ -473,7 +484,8 @@ export async function readRemoteProfilesFromConfig(): Promise => ipcRenderer.invoke(IPC.MCP_INSTALL, id), mcpUninstall: (id: McpClientId): Promise => ipcRenderer.invoke(IPC.MCP_UNINSTALL, id), + mcpGetServerTokenState: (): Promise => + ipcRenderer.invoke(IPC.MCP_SERVER_TOKEN_STATE), + mcpShareServerToken: (): Promise => + ipcRenderer.invoke(IPC.MCP_SHARE_SERVER_TOKEN), + mcpRemoveServerToken: (): Promise => + ipcRenderer.invoke(IPC.MCP_REMOVE_SERVER_TOKEN), mcpGetInstructions: (): Promise => ipcRenderer.invoke(IPC.MCP_GET_INSTRUCTIONS), mcpSetInstructions: (next: string | null): Promise => diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 50bc983e0..e79d48648 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.7.0", - "commit": "9650f8d6ce0c87c94e11c61719aa5cfeb7ba7b4b", + "version": "0.7.1", + "commit": "91bb33b42a3d156c2e577762b1f7ce1feb02d586", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_darwin_arm64.tar.gz", - "sha256": "6272909db88987a6fda33f7e9686067ee15b514892ebda016d66f2b07af6e957" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_darwin_arm64.tar.gz", + "sha256": "cb0c90410fcd86972b4e62a24840bd0f5d2d82d403cfc556399e3c735b43651c" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_darwin_amd64.tar.gz", - "sha256": "bd7292fb6b24e81afc54fd5af3958aa4022f224cca3e0cba34208b306de4dd65" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_darwin_amd64.tar.gz", + "sha256": "c6df5efe8d18dfa598e1ce8698fdd1a2eccf76892d94533479838cdd9a3e5d37" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_linux_arm64.tar.gz", - "sha256": "943ca6a123c8e4a4fdd56f08e2fd8095b614e60ef6cfb0defe4b292b83ab3666" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_linux_arm64.tar.gz", + "sha256": "1151c6daed55825e881014873802c32c056dff68797e64ed83451b9fdfe3f0b9" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.0/zn_0.7.0_linux_amd64.tar.gz", - "sha256": "50d794e67125126fd176e40a49a0abc17fd0cd5888ddab609bad0d6af22ccc55" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_linux_amd64.tar.gz", + "sha256": "b61d145931c32b417eaf5f8bce379e7f94e327e3b5527f7868dcba3a3a178ac2" } } } diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 77dc09294..b8edcf006 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.68.0", + "version": "2.69.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 2c26faa62..988824be8 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.68.0", + "version": "2.69.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 ffc6a6c85..2a8e2640f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.68.0", + "version": "2.69.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 4362d6997..726507e12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.68.0", + "version": "2.69.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.68.0", + "version": "2.69.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.68.0", + "version": "2.69.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.68.0", + "version": "2.69.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.68.0", + "version": "2.69.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.68.0", + "version": "2.69.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16390,7 +16390,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.68.0", + "version": "2.69.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16478,14 +16478,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.68.0", + "version": "2.69.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.68.0", + "version": "2.69.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16499,7 +16499,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.68.0" + "version": "2.69.0" } } } diff --git a/package.json b/package.json index 2d072538c..4fab0b1a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.68.0", + "version": "2.69.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 71a2476a4..7d471b59a 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -223,6 +223,7 @@ host's stable vault token. - `createBrowseDatabase(host, directory?)` creates and opens an untitled database. Omitting the directory uses the configured database location, including the active note's folder. Explicit `''` selects the primary root; any other explicit directory must be an existing ordinary Browse folder. - `requestRenameBrowseDatabase(host, directory)` prompts for a database title and preserves host collision numbering. Names starting with a dot are rejected because vault scanners hide those directories. Case-only renames retain existing host behavior and can receive a numbered suffix on case-insensitive filesystems. - `requestCreateBrowseFolder(host, directory = '')` prompts for a child folder. +- `requestCreateBrowseNote(host, directory = '')` creates a note in an ordinary Browse folder (`''` is the primary root) and opens it with its title focused, without a prompt. A missing folder or a database is refused with `unavailable`. - `requestRenameBrowseFolder(host, directory)` prompts for an ordinary folder's leaf name. - `requestMoveBrowseDirectory(host, directory)` prompts for a new parent for an ordinary folder or an entire database, using the move-note prompt's `inbox[/path]` values. Only existing notes-area folders are offered, never the directory itself, its descendants, or a database. The leaf name, and so a database's `.base` suffix, is kept. A destination that already holds that name is blocked in the prompt, and the host still refuses to overwrite. The store carries open tabs (database tabs included), folder icons and colors, favorites, and manual order to the new path. Pins are host-owned: a host that pins folders re-keys them after `completed`. - `requestDeleteBrowseDirectory(host, directory)` confirms permanent deletion of an ordinary folder or an entire database, with the appropriate warning. diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 53a1e7224..76680b039 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.68.0", + "version": "2.69.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 75ba6f2b5..742fa087e 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -480,6 +480,11 @@ function App(): JSX.Element { useEffect(() => { return window.zen.onOpenSettings(() => { + // The macOS menu takes Mod+, before the page does. Asking for Settings + // while What's New is up replaces it, as the shortcut does below, + // instead of opening Settings underneath with the keyboard in it. + const state = useStore.getState() + if (state.whatsNewOpen) state.closeWhatsNew() setSettingsOpen(true) }) }, [setSettingsOpen]) @@ -720,6 +725,40 @@ function App(): JSX.Element { const state = useStore.getState() const overrides = state.keymapOverrides + // What's New opens by itself on the first launch after an update, so + // the keys people press out of habit land on it. No app shortcut acts + // behind it: Mod+N made a note under it and moved the keyboard there, + // Mod+P opened a search nobody could see. Its own buttons, Tab and + // Escape are its own. Asking for Settings replaces it, as the macOS + // menu does. + if (state.whatsNewOpen) { + if (matchesShortcut(e, overrides, 'global.openSettings')) { + e.preventDefault() + state.closeWhatsNew() + state.setSettingsOpen(true) + } + return + } + + // A modal, palette, menu, or Settings (with its keymap recorder) is + // open. Per the house rule for global key handlers, a shortcut that + // switches, reopens or creates the note must not act under it: it + // strands the user on a different note than they left, and a new note + // pulls the keyboard into a title field nobody can see. + const overlayOpen = + state.settingsOpen || + state.searchOpen || + state.vaultTextSearchOpen || + state.commandPaletteOpen || + state.bufferPaletteOpen || + state.templatePaletteOpen || + state.embedDrawingPaletteOpen || + state.outlinePaletteOpen || + !!document.querySelector('[data-ctx-menu]') || + !!document.querySelector('[data-prompt-modal]') || + !!document.querySelector('[data-confirm-modal]') || + !!document.querySelector('[data-cloud-conflict-dialog]') + if (isUndoShortcut(e)) { const assetUndoEntry = state.assetUndoStack.at(-1) if (assetUndoEntry) { @@ -751,6 +790,7 @@ function App(): JSX.Element { } if (matchesShortcut(e, overrides, 'global.newQuickNote')) { // ⇧⌘N — new quick note + if (overlayOpen) return e.preventDefault() const title = resolveQuickNoteTitle( state.notes, @@ -768,23 +808,8 @@ function App(): JSX.Element { } // Alt+1..9 (⌃1..9 on macOS): jump straight to tab N (#497). Position // counts across panes in the same order gt cycles through. Bails while - // a modal, palette, menu, or Settings (with its keymap recorder) is - // open, per the house rule for global key handlers: switching the tab - // under an overlay strands the user on a different note than they left. - const tabSelectBlocked = - state.settingsOpen || - state.searchOpen || - state.vaultTextSearchOpen || - state.commandPaletteOpen || - state.bufferPaletteOpen || - state.templatePaletteOpen || - state.embedDrawingPaletteOpen || - state.outlinePaletteOpen || - document.querySelector('[data-ctx-menu]') || - document.querySelector('[data-prompt-modal]') || - document.querySelector('[data-confirm-modal]') || - document.querySelector('[data-cloud-conflict-dialog]') - if (!tabSelectBlocked) { + // an overlay is open (the rule above). + if (!overlayOpen) { for (let i = 0; i < TAB_SELECT_KEYMAP_IDS.length; i += 1) { const id = TAB_SELECT_KEYMAP_IDS[i] if (!matchesShortcut(e, overrides, id)) continue @@ -800,13 +825,13 @@ function App(): JSX.Element { // The template pickers as plain shortcuts, so they no longer need Vim's // leader (#847). Same overlay rule as tab selection: a picker opened // under Settings or another palette would strand the user behind it. - if (!tabSelectBlocked && matchesShortcut(e, overrides, 'global.newNoteFromTemplate')) { + if (!overlayOpen && matchesShortcut(e, overrides, 'global.newNoteFromTemplate')) { e.preventDefault() state.setTemplatePaletteOpen(true) return } if ( - !tabSelectBlocked && + !overlayOpen && state.activeNote && matchesShortcut(e, overrides, 'global.insertTemplate') ) { @@ -847,16 +872,19 @@ function App(): JSX.Element { isEditableShortcutTarget(e.target) if (!isMacWordMotion && matchesShortcut(e, overrides, 'global.historyBack')) { // Back in note navigation history (works in any mode). + if (overlayOpen) return e.preventDefault() void state.jumpToPreviousNote() return } if (!isMacWordMotion && matchesShortcut(e, overrides, 'global.historyForward')) { + if (overlayOpen) return e.preventDefault() void state.jumpToNextNote() return } if (matchesShortcut(e, overrides, 'global.toggleRecentNote')) { + if (overlayOpen) return e.preventDefault() void state.toggleRecentNote() return @@ -877,6 +905,7 @@ function App(): JSX.Element { return } if (matchesShortcut(e, overrides, 'global.reopenClosedTab')) { + if (overlayOpen) return e.preventDefault() void state.reopenLastClosedTab() return @@ -1016,6 +1045,7 @@ function App(): JSX.Element { } if ( state.settingsOpen || + state.whatsNewOpen || state.searchOpen || state.vaultTextSearchOpen || state.commandPaletteOpen || diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts index 3488aaf71..f6b6e47b8 100644 --- a/packages/app-core/src/browse-actions.test.ts +++ b/packages/app-core/src/browse-actions.test.ts @@ -22,6 +22,7 @@ async function setup() { const createDatabase = vi.fn(async () => undefined) const renameDatabase = vi.fn(async () => {}) const refreshAssets = vi.fn(async () => {}) + const createAndOpen = vi.fn(async () => {}) useStore.setState({ vault: { root: '/test', name: 'Test' }, folders: ['Work', 'Work/Nested', 'People.base'].map((subpath) => ({ @@ -34,7 +35,8 @@ async function setup() { deleteFolder: remove, createDatabase, renameDatabase, - refreshAssets + refreshAssets, + createAndOpen }) const host = { isCurrent: () => true } const answer = (value: string | null) => { @@ -58,6 +60,7 @@ async function setup() { createDatabase, renameDatabase, refreshAssets, + createAndOpen, host, answer, confirm @@ -222,6 +225,29 @@ describe('public Browse actions', () => { s.answer(null) expect(await next).toBe('cancelled') }) + // zennotesandroid#108: the phones could only create a note at the top and + // move it, so Browse offers New note in the folder it is showing. + it('creates a note in the Browse directory and opens it on its title, without a prompt', async () => { + const s = await setup() + expect(await s.requestCreateBrowseNote(s.host, 'Work/Nested')).toBe('completed') + expect(s.createAndOpen).toHaveBeenCalledWith('inbox', 'Work/Nested', { focusTitle: true }) + expect(await s.requestCreateBrowseNote(s.host)).toBe('completed') + expect(s.createAndOpen).toHaveBeenLastCalledWith('inbox', '', { focusTitle: true }) + expect(s.getPromptRequest()).toBeNull() + }) + + it('creates no note in a missing folder, a database, or while a dialog is open', async () => { + const s = await setup() + for (const directory of ['Missing', 'People.base', 'People.base/pages']) + expect(await s.requestCreateBrowseNote(s.host, directory)).toBe('unavailable') + expect(await s.requestCreateBrowseNote({ isCurrent: () => false }, 'Work')).toBe('unavailable') + const prompt = s.promptApp({ title: 'Unrelated prompt' }) + expect(await s.requestCreateBrowseNote(s.host, 'Work')).toBe('unavailable') + s.answer(null) + await prompt + expect(s.createAndOpen).not.toHaveBeenCalled() + }) + it('creates an untitled database in the explicit Browse directory without a prompt', async () => { const s = await setup() expect(await s.createBrowseDatabase(s.host, 'Work')).toBe('completed') diff --git a/packages/app-core/src/browse.ts b/packages/app-core/src/browse.ts index 70c7a5bab..9cafb91fc 100644 --- a/packages/app-core/src/browse.ts +++ b/packages/app-core/src/browse.ts @@ -278,6 +278,7 @@ export { createBrowseDatabase, requestRenameBrowseDatabase, requestCreateBrowseFolder, + requestCreateBrowseNote, requestRenameBrowseFolder, requestMoveBrowseDirectory, requestDeleteBrowseDirectory, diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index f9d3c104b..33248d5ac 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -460,6 +460,69 @@ describe("CloudSettings", () => { expect(host.textContent).not.toContain("Cursor 7"); }); + it("shows an encrypted vault's name once this device unlocks, without reopening Settings", async () => { + const encrypted = { + id: "vault-e", + cursor: 1, + created_at: "2026-10-12T12:00:00.000Z", + updated_at: "2026-10-12T12:30:00.000Z", + encryption: { + scheme: "zne1" as const, + key_id: "0123456789abcdef0123456789abcdef", + key_version: 1, + vault_key_envelope: "sealed", + name: "sealed-name", + }, + }; + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.getCloudVaultLink.mockResolvedValue(null); + mocks.listCloudVaults + .mockResolvedValueOnce([{ ...encrypted, name: "Encrypted vault" }]) + .mockResolvedValue([{ ...encrypted, name: "Work notes" }]); + let unlocked = false; + const bridge = mocks as unknown as Record; + bridge.getCloudEncryptionStatus = vi.fn(async () => ({ available: true, keyset: true, unlocked })); + bridge.unlockCloudEncryption = vi.fn(async () => { + unlocked = true; + }); + try { + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + // The vault's placeholder name, not the panel's "Encrypted vaults sync…". + expect(host.textContent).toMatch(/Encrypted vault(?!s)/); + expect(mocks.listCloudVaults).toHaveBeenCalledTimes(1); + + const open = [...host.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Unlock this device", + ); + await act(async () => open!.click()); + const input = document.querySelector('input[type="password"]'); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "correct horse"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + const unlock = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Unlock", + ); + await act(async () => unlock!.click()); + + expect(bridge.unlockCloudEncryption).toHaveBeenCalledOnce(); + expect(mocks.listCloudVaults).toHaveBeenCalledTimes(2); + expect(host.textContent).toContain("Work notes"); + expect(host.textContent).not.toMatch(/Encrypted vault(?!s)/); + } finally { + delete bridge.getCloudEncryptionStatus; + delete bridge.unlockCloudEncryption; + } + }); + it("separates unlinking this device from permanently deleting the cloud vault", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); @@ -763,6 +826,57 @@ describe("CloudSettings", () => { }); }); + describe("a vault another device encrypted", () => { + const replaced = { + phase: "unlinked" as const, + removedVault: { vaultName: "Notes", reason: "encrypted" as const }, + }; + const renderSettings = () => + act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + + beforeEach(() => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.getCloudVaultLink.mockResolvedValue(null); + mocks.listCloudVaults.mockResolvedValue([]); + }); + + afterEach(() => { + delete (mocks as Record).getCloudEncryptionStatus; + }); + + it("says the vault was replaced, not deleted, and asks a locked device to unlock first", async () => { + useCloudSyncStatusStore.setState(replaced); + await renderSettings(); + + const notice = host.querySelector("[data-cloud-vault-removed]"); + expect(notice?.textContent).toContain("Cloud vault now encrypted"); + expect(notice?.textContent).toContain( + "“Notes” was replaced by an end-to-end encrypted Cloud vault, so this vault stopped syncing. Your notes on this device are untouched. Unlock this device under End-to-end encryption, then choose the encrypted vault.", + ); + expect(notice?.textContent).not.toContain("deleted"); + }); + + it("points an unlocked device straight at the encrypted vault", async () => { + Object.assign(mocks, { + getCloudEncryptionStatus: vi.fn(async () => ({ available: true, keyset: true, unlocked: true })), + }); + useCloudSyncStatusStore.setState(replaced); + await renderSettings(); + + expect(host.querySelector("[data-cloud-vault-removed]")?.textContent).toContain( + "Choose the encrypted vault to sync it again.", + ); + }); + }); + it("presents capacity rejections as queued uploads instead of reviewable conflicts", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); @@ -1932,6 +2046,79 @@ describe("CloudSettings", () => { expect(host.textContent).toContain("Linked to Notes"); }); + it.each([ + ["a phone, which cannot open the archive", {}, false], + ["the desktop app", { supportsEncryptedBackupArchive: true }, true], + ])("offers Save archive for an encrypted vault only where it works: %s", async (_host, capabilities, offered) => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue({ + ...serviceAccount, + features: { + ...serviceAccount.features, + backup: { active: true, limits: { max_snapshots: 30 } }, + }, + }); + mocks.listCloudVaults.mockResolvedValue([ + { + id: "vault-e", + name: "Work notes", + cursor: 3, + created_at: "2026-10-12T12:00:00.000Z", + updated_at: "2026-10-12T12:30:00.000Z", + encryption: { + scheme: "zne1", + key_id: "0123456789abcdef0123456789abcdef", + key_version: 1, + vault_key_envelope: "sealed", + name: "sealed-name", + }, + }, + ]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", + vault_id: "vault-e", + vault_name: "Work notes", + linked_at: "2026-10-12T12:00:00.000Z", + }); + mocks.listCloudBackups.mockResolvedValue([ + { + id: "backup-1", + label: "Before migration", + trigger: "manual", + status: "ready", + cursor: 3, + item_count: 8, + total_bytes: 2048, + archive_bytes: 1024, + expires_at: "2026-11-09T12:00:00.000Z", + created_at: "2026-10-12T12:00:00.000Z", + }, + ]); + const bridge = mocks as unknown as Record; + bridge.getCapabilities = vi.fn(() => capabilities); + try { + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + expect(host.textContent).toContain("Before migration"); + const saveArchive = [...host.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Save archive", + ); + expect(!!saveArchive).toBe(offered); + expect(!!host.querySelector("[data-cloud-archive-desktop-only]")).toBe(!offered); + // Browse and Restore work on every host. + expect(host.textContent).toContain("Browse notes"); + expect(host.textContent).toContain("Restore"); + } finally { + delete bridge.getCapabilities; + } + }); + it("creates and safely restores backups for a linked vault", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue({ diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index 33f031786..7f9e32887 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -94,6 +94,16 @@ const VAULT_SECTION_ACTIONS: ReadonlySet = new Set([ * again, so a burst of runs costs one read. */ const USAGE_READ_DELAY_MS = 3_000; +/** Whether this host can save a readable archive of an encrypted vault's + * backup (the desktop app can; the phones refuse). */ +function hostSavesEncryptedArchives(bridge: ZenBridge): boolean { + try { + return bridge.getCapabilities().supportsEncryptedBackupArchive === true; + } catch { + return false; + } +} + export function CloudSettings({ localVaultAvailable, localVaultName, @@ -181,6 +191,22 @@ export function CloudSettings({ [bridge], ); + // An encrypted vault's real name is sealed, and the host opens it only + // while this device's keys are unlocked; a locked device lists the + // placeholder ("Encrypted vault"). The list was read once, so unlocking + // here left the placeholder until Settings was opened again. Read it again + // whenever the device locks or unlocks (not on the first status, which + // arrives with the first load). + const lastUnlocked = useRef(null); + useEffect(() => { + const unlocked = encryptionStatus ? encryptionStatus.unlocked === true : null; + const before = lastUnlocked.current; + lastUnlocked.current = unlocked; + if (before !== null && unlocked !== null && before !== unlocked) { + void refreshCloudVaults(); + } + }, [encryptionStatus, refreshCloudVaults]); + // The link as last drawn, for a store change that arrives between renders: // deleting here drops the link from state just before it reports the // deletion, and the vault that went is still the drawn one. @@ -896,6 +922,11 @@ export function CloudSettings({ /> vault.id === activeLink.vault_id)?.encryption && + !hostSavesEncryptedArchives(bridge) + } backupIncluded={backupIncluded} backupLabel={backupLabel} backupNotes={backupNotes} @@ -1393,6 +1424,7 @@ function CloudVaultPanel({ 0} + encryptionReady={encryptionReady} onDismiss={onDismissRemoval} /> )} @@ -1876,6 +1908,7 @@ function useBackupNotes(bridge: ZenBridge): BackupNotes { function CloudBackupPanel({ action, + archiveDesktopOnly, backupIncluded, backupLabel, backupNotes, @@ -1896,6 +1929,8 @@ function CloudBackupPanel({ onScheduleChange, }: { action: CloudAction; + /** An encrypted vault on a host that cannot open its archive (the phones). */ + archiveDesktopOnly: boolean; backupIncluded: boolean; backupLabel: string; backupNotes: BackupNotes; @@ -1976,6 +2011,14 @@ function CloudBackupPanel({

{backupPolicy || "Create a recovery point from the synced vault."}

+ {archiveDesktopOnly && ( +

+ To save an archive of this encrypted vault, use Save archive in the ZenNotes desktop app. +

+ )} + {!archiveDesktopOnly && ( + + )} + ); + } else if (state.kind === "shared") { + summary = ( + <> + Assistants can reach {code(state.serverUrl)}: its auth token is + saved in {code(state.credentialsPath)}, where zn reads it too. + + ); + detail = + "Stop sharing removes it from that file. Forgetting the server in Settings \u2192 Vault removes it too, and a new token you save for the server in ZenNotes replaces it."; + action = ( + + ); + } else if (state.kind === "different") { + summary = ( + <> + {code(state.credentialsPath)} holds a different auth token for{" "} + {code(state.serverUrl)}, saved by {code("zn connect")} or before the + server{"\u2019"}s token changed. + + ); + detail = + "If assistants are turned away, replace it with the token ZenNotes connects with. zn reads the same file."; + action = ( + + ); + } else { + summary = <>{state.message}; + detail = + "ZenNotes won\u2019t write to it, so the tokens it holds stay safe."; + action = ( + + ); + } + body = ( +
+
+

{summary}

+

{detail}

+
+
{action}
+
+ ); + } + + return ( +
+ {body} + {error && ( + + Something went wrong: {error} + + )} +
+ ); +} + function AgentEditReviewSetting(): JSX.Element { const reviewAgentEdits = useStore((s) => s.reviewAgentEdits); const setReviewAgentEdits = useStore((s) => s.setReviewAgentEdits); diff --git a/packages/app-core/src/components/TasksCalendar.tsx b/packages/app-core/src/components/TasksCalendar.tsx index b7cf04899..48f737e75 100644 --- a/packages/app-core/src/components/TasksCalendar.tsx +++ b/packages/app-core/src/components/TasksCalendar.tsx @@ -27,6 +27,7 @@ import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { resolveWeekStartDay } from '../lib/week-start' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems } from '../lib/task-context-menu' +import { isOverlayOrDialogOpen } from '../lib/overlay-open' interface Props { tasks: VaultTask[] @@ -354,6 +355,12 @@ export function TasksCalendar({ // sidebar bindings (same trick TasksView's list mode uses). useEffect(() => { const handler = (e: KeyboardEvent): void => { + // A modal, menu or Settings owns the keyboard while open, as in the list + // and the board. This handler runs first (capture phase), so without it + // Space on a button in Settings or What's New marked the day's task + // done behind the dialog, Tab cycled the calendar's tasks instead of + // the dialog's buttons, and in Vim mode `dd` deleted a task. + if (isOverlayOrDialogOpen()) return // While the Vim hint overlay is open it owns the keyboard; yield to it. (#151) if (document.querySelector('[data-vim-hint-overlay]')) return const active = document.activeElement as HTMLElement | null diff --git a/packages/app-core/src/lib/browse-actions.ts b/packages/app-core/src/lib/browse-actions.ts index 155441765..cdea4e102 100644 --- a/packages/app-core/src/lib/browse-actions.ts +++ b/packages/app-core/src/lib/browse-actions.ts @@ -118,6 +118,29 @@ export async function requestCreateBrowseFolder( } } +/** + * Create a note in a primary-notes directory and open it on its title, the + * way the phones' create row makes one at the top (zennotesandroid#108). An + * empty directory means its root. There is no prompt: the note opens with its + * title focused, so naming it is the next thing typed either way. Database + * folders hold records rather than notes, so they are refused like a folder + * there is. + */ +export async function requestCreateBrowseNote( + host: BrowseActionHost, + directory = '' +): Promise { + if (formDirContaining(directory)) return 'unavailable' + const context = start(host, directory, true) + if (!context) return 'unavailable' + try { + await useStore.getState().createAndOpen('inbox', directory, { focusTitle: true }) + return context.isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + /** Prompt for an ordinary folder's leaf name. Database renaming uses its own feature. */ export async function requestRenameBrowseFolder( host: BrowseActionHost, diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index 97e1d1ea3..90cc88487 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -5,7 +5,10 @@ import type { CloudSyncWindowHandlers, } from "@zennotes/bridge-contract/cloud-sync"; import type { VaultChangeEvent } from "@shared/ipc"; -import { CLOUD_VAULT_REMOVED_MESSAGE } from "@zennotes/shared-domain/cloud-vault-availability"; +import { + CLOUD_VAULT_REMOVED_MESSAGE, + CLOUD_VAULT_SUPERSEDED_MESSAGE, +} from "@zennotes/shared-domain/cloud-vault-availability"; import { acknowledgeCloudConflictResolution, clearCloudSyncStatus, @@ -1243,6 +1246,55 @@ describe("a Cloud vault that went away", () => { expect(cloudVaultGoneReason("Cloud sync timed out.")).toBeNull(); }); + it("tells a vault another device encrypted from a deleted one, by what each host surfaces", () => { + expect(cloudVaultGoneReason(new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE))).toBe("encrypted"); + expect(cloudVaultGoneReason(new Error( + `Error invoking remote method 'cloud-vault:sync': Error: ${CLOUD_VAULT_SUPERSEDED_MESSAGE}`, + ))).toBe("encrypted"); + expect(cloudVaultGoneReason(Object.assign( + new Error("This vault was replaced by an end-to-end encrypted vault. Update ZenNotes on this device to keep syncing it."), + { status: 410, code: "VAULT_SUPERSEDED" }, + ))).toBe("encrypted"); + // A 410 without Cloud's own code says nothing about the vault. + expect(cloudVaultGoneReason( + Object.assign(new Error("Gone"), { status: 410, code: null }), + )).toBeNull(); + }); + + it("records a vault another device encrypted as encrypted, not deleted", async () => { + const replaced = { + syncCloudVault: async () => { + throw new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE); + }, + getCloudVaultLink: async () => null, + }; + await expect(syncCloudVaultWithStatus(replaced, "Notes")).rejects.toThrow(); + expect(useCloudSyncStatusStore.getState()).toMatchObject({ + phase: "unlinked", + removedVault: { vaultName: "Notes", reason: "encrypted" }, + }); + }); + + it("brings back a stored notice that the vault is now encrypted", () => { + vi.stubGlobal("localStorage", { + getItem: (key: string) => + key === "zen.cloud.removedVault./vaults/Notes" + ? JSON.stringify({ vaultName: "Notes", reason: "encrypted" }) + : null, + setItem: () => {}, + removeItem: () => {}, + }); + const host = setup(); + const runtime = startCloudAutoSync(host.bridge, host.environment, { onError: vi.fn() }, "/vaults/Notes"); + try { + expect(useCloudSyncStatusStore.getState().removedVault).toEqual({ + vaultName: "Notes", reason: "encrypted", + }); + } finally { + runtime.stop(); + } + }); + it("records a refusal to this account as unavailable, and keeps the first name it read", async () => { const refused = { syncCloudVault: async () => { @@ -1276,6 +1328,11 @@ describe("a Cloud vault that went away", () => { expect(cloudVaultRemovalMessage({ vaultName: null, reason: "deleted" })).toBe( "The cloud vault this vault synced with was deleted from ZenNotes Cloud, so this vault stopped syncing. Your notes on this device are untouched.", ); + expect(cloudVaultRemovalLabel({ vaultName: "Notes", reason: "encrypted" })) + .toBe("Cloud vault now encrypted"); + expect(cloudVaultRemovalMessage({ vaultName: "Notes", reason: "encrypted" })).toBe( + "“Notes” was replaced by an end-to-end encrypted Cloud vault, so this vault stopped syncing. Your notes on this device are untouched.", + ); }); it("says at once that this device deleted its own Cloud vault", () => { diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index e77793d6b..c81866a89 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -20,7 +20,10 @@ import { cloudSyncPathKey, shouldSyncVaultPath, } from "@zennotes/shared-domain/cloud-sync"; -import { CLOUD_VAULT_REMOVED_MESSAGE } from "@zennotes/shared-domain/cloud-vault-availability"; +import { + CLOUD_VAULT_REMOVED_MESSAGE, + CLOUD_VAULT_SUPERSEDED_MESSAGE, +} from "@zennotes/shared-domain/cloud-vault-availability"; import { vaultSettingsValueEqual } from "@zennotes/shared-domain/vault-settings-conflict"; import { useToastStore } from "./toast"; @@ -69,9 +72,11 @@ export type CloudSyncPhase = /** * Why a Cloud vault stopped being this device's to sync. Cloud answers "not * found" for a vault that no longer exists; "unavailable" is a refusal that - * names the account instead. + * names the account instead; "encrypted" is a readable vault another device + * replaced with an end-to-end encrypted one, which this device can open once + * it unlocks encryption. */ -export type CloudVaultRemovalReason = "deleted" | "unavailable"; +export type CloudVaultRemovalReason = "deleted" | "unavailable" | "encrypted"; export interface CloudVaultRemoval { /** The name the link carried, read before the link went away. Null only @@ -514,8 +519,9 @@ async function refreshRemovedCloudLink( const vaultName = useCloudSyncStatusStore.getState().vaultName; markCloudSyncUnlinked(syncFailureMessage(error)); // The hosts drop a link after a failed request only once Cloud has answered - // "not found" for its vault, so a link found gone after a failure means a - // deleted vault unless the error names the account instead. + // that its vault is gone ("not found") or was replaced by an encrypted one, + // so a link found gone after a failure means a deleted vault unless the + // error says it was replaced or names the account instead. recordRemovedCloudVault({ vaultName, reason: cloudVaultGoneReason(error) ?? "deleted", @@ -525,9 +531,10 @@ async function refreshRemovedCloudLink( /** * What a refusal says about the vault it concerned: gone ("deleted"), refused - * to this account ("unavailable"), or nothing at all (null). Desktop errors - * cross IPC as text alone, so the hosts' own sentences count beside the - * status and code an in-process host keeps on the error. + * to this account ("unavailable"), replaced by an encrypted vault + * ("encrypted"), or nothing at all (null). Desktop errors cross IPC as text + * alone, so the hosts' own sentences count beside the status and code an + * in-process host keeps on the error. */ export function cloudVaultGoneReason( error: unknown, @@ -546,6 +553,12 @@ export function cloudVaultGoneReason( ) { return "unavailable"; } + if ( + (response.status === 410 && response.code === "VAULT_SUPERSEDED") || + message.startsWith(ENCRYPTED_VAULT_SENTENCE) + ) { + return "encrypted"; + } if ( (response.status === 404 && response.code === "NOT_FOUND") || message.startsWith(REMOVED_VAULT_SENTENCE) @@ -555,13 +568,17 @@ export function cloudVaultGoneReason( return null; } +const firstSentence = (message: string): string => + message.slice(0, message.indexOf(".") + 1); + /** "This Cloud vault is no longer available.": the first sentence of the * hosts' message for a link they dropped. The advice after it is free to * change. */ -const REMOVED_VAULT_SENTENCE = CLOUD_VAULT_REMOVED_MESSAGE.slice( - 0, - CLOUD_VAULT_REMOVED_MESSAGE.indexOf(".") + 1, -); +const REMOVED_VAULT_SENTENCE = firstSentence(CLOUD_VAULT_REMOVED_MESSAGE); + +/** "This Cloud vault is now end-to-end encrypted.": the same for a link they + * dropped because another device replaced the vault with an encrypted one. */ +const ENCRYPTED_VAULT_SENTENCE = firstSentence(CLOUD_VAULT_SUPERSEDED_MESSAGE); /** * This device deleted the Cloud vault it was linked to. The host has already @@ -589,11 +606,15 @@ export function hasCloudVaultRemovalNotice( return state.phase === "unlinked" && state.removedVault !== null; } +const REMOVAL_LABELS: Record = { + deleted: "Cloud vault deleted", + unavailable: "Cloud vault unavailable", + encrypted: "Cloud vault now encrypted", +}; + /** The status row's words for it, and the heading of the Settings notice. */ export function cloudVaultRemovalLabel(removal: CloudVaultRemoval): string { - return removal.reason === "deleted" - ? "Cloud vault deleted" - : "Cloud vault unavailable"; + return REMOVAL_LABELS[removal.reason]; } /** @@ -604,11 +625,12 @@ export function cloudVaultRemovalMessage(removal: CloudVaultRemoval): string { const vault = removal.vaultName ? `“${removal.vaultName}”` : "The cloud vault this vault synced with"; - const happened = - removal.reason === "deleted" - ? `${vault} was deleted from ZenNotes Cloud` - : `${vault} is no longer available to this ZenNotes Cloud account`; - return `${happened}, so this vault stopped syncing. Your notes on this device are untouched.`; + const happened: Record = { + deleted: `${vault} was deleted from ZenNotes Cloud`, + unavailable: `${vault} is no longer available to this ZenNotes Cloud account`, + encrypted: `${vault} was replaced by an end-to-end encrypted Cloud vault`, + }; + return `${happened[removal.reason]}, so this vault stopped syncing. Your notes on this device are untouched.`; } function recordRemovedCloudVault(next: CloudVaultRemoval): void { @@ -633,7 +655,7 @@ function restoreRemovedCloudVault(vaultKey: string | undefined): void { if (parsed && typeof parsed === "object") { const { vaultName, reason } = parsed as Record; if ( - (reason === "deleted" || reason === "unavailable") && + (reason === "deleted" || reason === "unavailable" || reason === "encrypted") && (typeof vaultName === "string" || vaultName === null) ) { removal = { vaultName, reason }; diff --git a/packages/app-core/src/lib/help-rich-text.test.ts b/packages/app-core/src/lib/help-rich-text.test.ts new file mode 100644 index 000000000..3781a0352 --- /dev/null +++ b/packages/app-core/src/lib/help-rich-text.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import { act, createElement, Fragment, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { + HELP_CLI, + HELP_CORE_CONCEPTS, + HELP_HOW_TO_GUIDES, + HELP_QUICK_START, + HELP_SETTINGS, + HELP_SHORTCUT_SECTIONS, + HELP_VIM_COMMANDS, +} from "./help"; +import { helpSearchText, renderHelpRichText } from "./help-rich-text"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function rendered(node: ReactNode): HTMLDivElement { + const host = document.createElement("div"); + const root = createRoot(host); + act(() => root.render(createElement(Fragment, null, node))); + return host; +} + +const paragraphs = (host: HTMLElement) => + [...host.children].filter((child) => child.matches("span.block")); + +/** Every string the manual holds, whatever field it sits in. */ +function manualStrings(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(manualStrings); + if (value && typeof value === "object") return Object.values(value).flatMap(manualStrings); + return []; +} + +describe("the manual's rich text", () => { + it("renders **bold** as strong text, keeping the words and dropping the markers", () => { + const host = rendered(renderHelpRichText( + "In the desktop app, **Save archive** under Backups saves a readable archive.", + "text-sm", + )); + expect(host.querySelector("strong")?.textContent).toBe("Save archive"); + expect(host.textContent).toBe( + "In the desktop app, Save archive under Backups saves a readable archive.", + ); + }); + + it("starts a paragraph at a blank line, and leaves a single paragraph inline as before", () => { + const two = rendered(renderHelpRichText("First, with **bold**.\n\nSecond.", "text-sm")); + expect(paragraphs(two).map((p) => p.textContent)).toEqual(["First, with bold.", "Second."]); + + const one = rendered(renderHelpRichText("Just one, with `code`.", "text-sm")); + expect(paragraphs(one)).toEqual([]); + expect(one.textContent).toBe("Just one, with code."); + }); + + it("keeps code literal: short spans inline, long or multi-line ones as a block", () => { + const host = rendered(renderHelpRichText( + "Match `inbox/**/*.md` or `**not bold**`, then start it:\n`docker run -d \\\n -e TOKEN=1 \\\n image`\nand open it.", + "text-sm", + )); + const code = [...host.querySelectorAll("code")]; + expect(code.map((c) => c.textContent)).toEqual([ + "inbox/**/*.md", + "**not bold**", + "docker run -d \\\n -e TOKEN=1 \\\n image", + ]); + expect(code.map((c) => c.classList.contains("block"))).toEqual([false, false, true]); + expect(host.querySelector("strong")).toBeNull(); + // Single newlines around a code block are not paragraph breaks. + expect(paragraphs(host)).toEqual([]); + }); + + it("gives a code span between blank lines a paragraph of its own", () => { + const host = rendered(renderHelpRichText("Run this:\n\n`npm run build`\n\nThen open it.", "text-sm")); + expect(paragraphs(host).map((p) => p.textContent)).toEqual([ + "Run this:", + "npm run build", + "Then open it.", + ]); + }); + + it("leaves the manual's wikilink, tag and link examples as typed", () => { + const text = "Type [[ to link, add #task to a note, or write [Paper](zotero://open-pdf/library/items/X)."; + const host = rendered(renderHelpRichText(text, "text-sm")); + expect(host.textContent).toBe(text); + expect(host.querySelector("a, strong, code")).toBeNull(); + }); + + it("shows no bold markers anywhere in the manual outside code", () => { + const strings = manualStrings([ + HELP_QUICK_START, + HELP_HOW_TO_GUIDES, + HELP_CORE_CONCEPTS, + HELP_SHORTCUT_SECTIONS, + HELP_VIM_COMMANDS, + HELP_SETTINGS, + HELP_CLI, + ]); + expect(strings.some((text) => text.includes("**"))).toBe(true); + for (const text of strings) { + const host = rendered(renderHelpRichText(text, "text-sm")); + host.querySelectorAll("code").forEach((code) => code.remove()); + expect(host.textContent, text.slice(0, 80)).not.toContain("**"); + } + }); + + it("renders the encryption guide as two paragraphs with its four control names in bold", () => { + const guide = HELP_HOW_TO_GUIDES.find((card) => card.title === "Encrypt a Cloud vault end to end")!; + const host = rendered(renderHelpRichText(guide.body, "text-sm")); + expect(paragraphs(host)).toHaveLength(2); + expect([...host.querySelectorAll("strong")].map((s) => s.textContent)).toEqual([ + "End-to-end encryption", + "End-to-end encrypt this vault", + "Encrypt this vault", + "Save archive", + ]); + }); + + it("searches what the reader sees", () => { + expect(helpSearchText("In the desktop app, **Save archive** under Backups")).toBe( + "In the desktop app, Save archive under Backups", + ); + expect(helpSearchText("Match `inbox/**/*.md` with a glob")).toBe("Match inbox/**/*.md with a glob"); + expect(helpSearchText("First.\n\nSecond.")).toBe("First. Second."); + }); +}); diff --git a/packages/app-core/src/lib/help-rich-text.tsx b/packages/app-core/src/lib/help-rich-text.tsx new file mode 100644 index 000000000..bf486d8b4 --- /dev/null +++ b/packages/app-core/src/lib/help-rich-text.tsx @@ -0,0 +1,94 @@ +/** + * The little Markdown the manual's strings use (lib/help.ts): backtick code + * spans, **bold** for the names of buttons and settings, and a blank line + * between paragraphs. Nothing else is markup. The manual's prose mentions + * [[ wikilinks, #tags and Markdown links as things to type, so the task + * cards' inline-markdown renderer, which brings all three to life, is the + * wrong one here. + */ +import type { ReactNode } from 'react' + +/** A code span. Its body is literal: `inbox/**` is a glob, not bold. */ +const CODE_SPAN = /(`[^`]+`)/g +/** Bold needs text that does not start or end with a space, so a lone + * `**` in prose stays as typed. */ +const BOLD = /(\*\*[^\s*](?:[^*\n]*[^\s*])?\*\*)/g +const PARAGRAPH_BREAK = /\n{2,}/ + +/** + * Short code spans become inline code; long or multi-line spans become a code + * block so shell commands stay readable instead of wrapping through prose. + * `codeSize` keeps inline code a step below the text around it. Paragraphs + * are block spans rather than

, because a shortcut section's description + * already sits inside a

; a string with one paragraph renders inline, + * exactly as before paragraphs existed. + */ +export function renderHelpRichText(text: string, codeSize: 'text-xs' | 'text-sm'): ReactNode { + const paragraphs: ReactNode[][] = [[]] + text.split(CODE_SPAN).forEach((segment, index) => { + if (index % 2 === 1) { + paragraphs[paragraphs.length - 1]!.push(codeNode(segment.slice(1, -1), codeSize, `code-${index}`)) + return + } + segment.split(PARAGRAPH_BREAK).forEach((prose, part) => { + if (part > 0) paragraphs.push([]) + paragraphs[paragraphs.length - 1]!.push(...boldNodes(prose, `text-${index}-${part}`)) + }) + }) + const filled = paragraphs.filter((nodes) => nodes.length > 0) + if (filled.length < 2) return filled[0] ?? null + return filled.map((nodes, index) => ( + + {nodes} + + )) +} + +/** + * What a reader sees of a manual string, for the search: the backticks and + * bold markers this renderer drops are gone, and whitespace runs read as one + * space, so a phrase still matches across a bold name or a code span. + */ +export function helpSearchText(text: string): string { + return text + .split(CODE_SPAN) + .map((segment, index) => + index % 2 === 1 ? segment.slice(1, -1) : segment.replace(BOLD, (bold) => bold.slice(2, -2)) + ) + .join('') + .replace(/\s+/g, ' ') +} + +function codeNode(code: string, codeSize: 'text-xs' | 'text-sm', key: string): ReactNode { + if (code.includes('\n') || code.length > 60) { + return ( + + {code} + + ) + } + return ( + + {code} + + ) +} + +function boldNodes(prose: string, key: string): ReactNode[] { + return prose.split(BOLD).flatMap((part, index) => { + if (index % 2 === 1) { + return [ + + {part.slice(2, -2)} + + ] + } + return part ? [part] : [] + }) +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 7192bfe41..76b01367c 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -199,7 +199,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Encrypt a Cloud vault end to end', body: - 'End-to-end encryption keeps a Cloud vault\'s notes, attachments, file and folder names, and the vault\'s own name unreadable to ZenNotes Cloud: your devices encrypt everything before it is uploaded, with a key only you hold. Settings → Cloud shows **End-to-end encryption** (beta) once it is available for your account. Set it up there: choose a passphrase of at least 12 characters (four or more unrelated words work well), then save the 24-word recovery key it shows and type three of its words back. Either one unlocks encryption on your other devices, once per device. ZenNotes never receives them and cannot reset them; if you lose both, the encrypted Cloud copy cannot be read, but the notes on your devices are untouched.\n\nTo encrypt a new vault, tick **End-to-end encrypt this vault** when you create it. To move an existing one, choose **Encrypt this vault** in Settings → Cloud: ZenNotes makes a new encrypted vault with the same name, uploads this device\'s copy, then deletes the readable Cloud copy along with its version history and backups, so download a backup first if you want one. Other devices linked to the old vault say it is now end-to-end encrypted; unlock encryption on them and open the encrypted vault from Settings → Cloud. Publishing a note from an encrypted vault still makes a readable public copy until you unpublish it. The website cannot export an encrypted vault as a ZIP, but **Download** under Backups saves a readable archive. Change the passphrase, make a new recovery key, or lock this device from the same section; zennotes.org/security lists what ZenNotes can and cannot see.' + 'End-to-end encryption keeps a Cloud vault\'s notes, attachments, file and folder names, and the vault\'s own name unreadable to ZenNotes Cloud: your devices encrypt everything before it is uploaded, with a key only you hold. Settings → Cloud shows **End-to-end encryption** (beta) once it is available for your account. Set it up there: choose a passphrase of at least 12 characters (four or more unrelated words work well), then save the 24-word recovery key it shows and type three of its words back. Either one unlocks encryption on your other devices, once per device. ZenNotes never receives them and cannot reset them; if you lose both, the encrypted Cloud copy cannot be read, but the notes on your devices are untouched.\n\nTo encrypt a new vault, tick **End-to-end encrypt this vault** when you create it. To move an existing one, choose **Encrypt this vault** in Settings → Cloud: ZenNotes makes a new encrypted vault with the same name, uploads this device\'s copy, then deletes the readable Cloud copy along with its version history and backups, so download a backup first if you want one. Other devices linked to the old vault stop syncing it; unlock encryption on each one, then open the encrypted vault from Settings → Cloud. Publishing a note from an encrypted vault still makes a readable public copy until you unpublish it. The website cannot export an encrypted vault as a ZIP. In the desktop app, **Save archive** under Backups saves a readable archive; the phone apps send you there. Change the passphrase, make a new recovery key, or lock this device from the same section; zennotes.org/security lists what ZenNotes can and cannot see.' }, { title: 'Customize the look: themes vs. overrides', @@ -581,7 +581,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space p', action: 'Note outline', detail: 'Jump to any heading in the active note via a searchable overlay.' }, { keys: 'Space v', action: 'Switch vault', detail: 'Open the command palette directly to the local vault switcher.' }, { keys: 'Space a', action: 'Open workflows', detail: 'Open the Workflows view, where saved pipelines over your notes are built and run. Workflows are off by default; turn them on under Settings → Workflows first.' }, - { keys: 'Space r', action: 'Review Cloud conflicts', detail: 'Open whatever Cloud sync is waiting on: the queue of files two devices changed at once, one decision at a time, or the vault settings question, which lists each setting that differs with this device’s value beside the cloud’s and lets you pick a side per setting. When the open note is one of the waiting files, the queue starts on it. A waiting note also says so across its top, in Edit and Preview alike: sync is paused for it until you choose what to keep, and its Review button opens the queue on it. A notification with Review appears once when a new conflict arrives. When the Cloud vault this vault syncs with is deleted, on this device, another device or the website, the status bar says Cloud vault deleted and the binding opens Settings → Cloud instead, where This vault says what happened until you choose a cloud vault, start a new one, or dismiss the notice. The binding and the command palette entry appear only while something is waiting, and open the same place as Review in the status bar.' }, + { keys: 'Space r', action: 'Review Cloud conflicts', detail: 'Open whatever Cloud sync is waiting on: the queue of files two devices changed at once, one decision at a time, or the vault settings question, which lists each setting that differs with this device’s value beside the cloud’s and lets you pick a side per setting. When the open note is one of the waiting files, the queue starts on it. A waiting note also says so across its top, in Edit and Preview alike: sync is paused for it until you choose what to keep, and its Review button opens the queue on it. A notification with Review appears once when a new conflict arrives. When the Cloud vault this vault syncs with is deleted, on this device, another device or the website, the status bar says Cloud vault deleted and the binding opens Settings → Cloud instead, where This vault says what happened until you choose a cloud vault, start a new one, or dismiss the notice. If another device moved it into an end-to-end encrypted vault, the status bar says Cloud vault now encrypted instead, and This vault asks you to unlock this device if needed, then choose the encrypted vault. The binding and the command palette entry appear only while something is waiting, and open the same place as Review in the status bar.' }, { keys: 'Space g', action: 'Open atlas', detail: 'Open the Atlas view: the whole vault drawn as a map of notes and links.' }, { keys: 'Space q', action: 'Quick capture window', detail: 'Open the floating, always-on-top capture window, same as the global hotkey.' }, { keys: 'Space i', action: 'Insert template into note', detail: 'Pick a template and insert it at the cursor of the active note, instead of creating a new note from it. Without Vim mode, give “Insert template into current note” a key under Settings → Keymap.' }, @@ -1261,9 +1261,9 @@ export const HELP_CLI: HelpCard[] = [ 'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { - title: 'CLI 0.7.0 in desktop 2.68.0', + title: 'CLI 0.7.1 in desktop 2.69.0', body: - 'On macOS and Linux, desktop 2.68.0 bundles CLI 0.7.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.69.0 bundles CLI 0.7.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'Updates arrive without a ZenNotes update', @@ -1318,7 +1318,7 @@ export const HELP_CLI: HelpCard[] = [ { title: 'MCP for AI agents', body: - '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. Once `zn` is installed, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves. The server works on the vault the app has open: a folder on this machine, or a self-hosted ZenNotes server you connected from Settings → Vault. `vault_info` says which. Switch vaults in the app and the server follows: the assistant\'s next tool call is refused with a note naming the old and the new vault, and once `vault_info` confirms the switch it carries on in the new one, so nothing planned in one vault lands in the other. A server that requires a token needs one the MCP server can read, because the app keeps its own copy in the OS secret store: with `zn` installed, run `zn connect --no-default` once in a terminal (it saves the token for `zn` and `zn mcp` without changing which vault they follow), or set `ZENNOTES_REMOTE_TOKEN` in the MCP client\'s environment. To point an agent somewhere else, give the command the same flags every other `zn` command takes: `zn mcp --vault work` or `zn mcp --server home --token ` in the client\'s config, or `ZENNOTES_SERVER` / `ZENNOTES_VAULT` in its environment. Beyond reading and writing notes, the server can hold a review with you through comments: ask the assistant to read a note\'s comments and answer them, and its replies land in the Comments panel under yours, signed with its name (`list_comments`, `add_comment`, `reply_to_comment`, `resolve_comment`).' + '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. The Claude desktop app\'s Code tab runs Claude Code, so it reads the **Claude Code (CLI + Desktop Code tab)** entry in Settings → MCP; **Claude Desktop (Chat)** reaches only its Chat tab. Once `zn` is installed from Settings → CLI, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves; otherwise, a `zn` installed some other way included, they run the server bundled with the app. The server works on the vault the app has open: a folder on this machine, or a self-hosted ZenNotes server you connected from Settings → Vault. `vault_info` says which. Switch vaults in the app and the server follows: the assistant\'s next tool call is refused with a note naming the old and the new vault, and once `vault_info` confirms the switch it carries on in the new one, so nothing planned in one vault lands in the other. A server that requires a token needs one the MCP server can read, because the app keeps its own copy in the OS secret store. Settings → MCP → Server token → **Let assistants use it** saves it to `credentials.toml` in the config folder, the file `zn connect` writes and both servers read (`zn mcp` and the bundled one), and the assistant\'s next call uses it. Any program running as you can read that file, so it is your choice: **Stop sharing** removes the token, forgetting the server in Settings → Vault removes it too, and a new token you save for the server replaces it. From a terminal, `zn connect --no-default` saves it the same way without changing which vault `zn` follows, and `ZENNOTES_REMOTE_TOKEN` in the MCP client\'s environment works as well. To point an agent somewhere else, give the command the same flags every other `zn` command takes: `zn mcp --vault work` or `zn mcp --server home --token ` in the client\'s config, or `ZENNOTES_SERVER` / `ZENNOTES_VAULT` in its environment. Beyond reading and writing notes, the server can hold a review with you through comments: ask the assistant to read a note\'s comments and answer them, and its replies land in the Comments panel under yours, signed with its name (`list_comments`, `add_comment`, `reply_to_comment`, `resolve_comment`).' }, { title: 'Review an assistant’s edits', diff --git a/packages/app-core/src/lib/keymaps.test.ts b/packages/app-core/src/lib/keymaps.test.ts index 4b39d232f..f8a6063ba 100644 --- a/packages/app-core/src/lib/keymaps.test.ts +++ b/packages/app-core/src/lib/keymaps.test.ts @@ -10,6 +10,7 @@ import { getKeymapDisplay, getSequenceTokens, isKeymapUnbound, + keymapAvailableOnHost, labelWithShortcut, matchesSequenceToken, matchesShortcut, @@ -514,3 +515,27 @@ describe('unbound keymaps (an override of "")', () => { expect(labelWithShortcut('Go back', '')).toBe('Go back') }) }) + +describe('keymapAvailableOnHost', () => { + const ids = getKeymapDefinitions().map((definition) => definition.id) + const review = ids.filter((id) => /^(editor|vim)\.review/.test(id)) + const harper = ids.filter((id) => id.startsWith('vim.harper')) + + it('leaves out the review and Harper keys where those features are missing', () => { + expect(review).toHaveLength(8) + expect(harper.length).toBeGreaterThan(0) + for (const id of [...review, ...harper]) expect(keymapAvailableOnHost(id, {})).toBe(false) + }) + + it('keeps each one on a host that has its feature', () => { + for (const id of review) expect(keymapAvailableOnHost(id, { supportsAgentEditReview: true })).toBe(true) + for (const id of harper) expect(keymapAvailableOnHost(id, { supportsHarper: true })).toBe(true) + expect(keymapAvailableOnHost(review[0], { supportsHarper: true })).toBe(false) + }) + + it('keeps every other key everywhere', () => { + const rest = ids.filter((id) => !review.includes(id) && !harper.includes(id)) + expect(rest.length).toBeGreaterThan(50) + for (const id of rest) expect(keymapAvailableOnHost(id, {})).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 3772ea995..1491dcac7 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -2030,6 +2030,24 @@ export function describeCurrentBinding( return getKeymapDisplay(overrides, id); } +/** + * Whether a shortcut belongs to a feature this host has. Agent edit review + * needs `supportsAgentEditReview` and Harper needs `supportsHarper`; where + * they are absent (the web client, the phones and iPads) the shortcuts can + * never fire, so Settings → Keymap leaves them out instead of offering keys + * for a review or a grammar check that does not exist there. + */ +export function keymapAvailableOnHost( + id: KeymapId, + capabilities: { supportsAgentEditReview?: boolean; supportsHarper?: boolean }, +): boolean { + if (id.startsWith("editor.review") || id.startsWith("vim.review")) { + return capabilities.supportsAgentEditReview === true; + } + if (id.startsWith("vim.harper")) return capabilities.supportsHarper === true; + return true; +} + export function getKeymapDefinitionsByGroup(): Array<{ group: KeymapGroup; label: string; diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index c7c13efbe..b4e7cca90 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,31 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.69.0', + highlights: [ + { + title: 'Assistants reach your server', + body: 'Settings → MCP → Server token lets Claude, Codex and other assistants use a ZenNotes Server that needs a token, with no terminal step.' + }, + { + title: 'Which Claude, which tab', + body: 'The MCP integrations say which part of the Claude app they reach: its Code tab uses the Claude Code entry.' + }, + { + title: 'Setup for a server vault', + body: 'Connecting to a ZenNotes Server during setup skips the layout step: the server’s vault already has one.' + }, + { + title: 'Clearer keys for a new note', + body: 'The New note form in search names the keys that work in the field you are in.' + }, + { + title: 'Encrypted vaults say so', + body: 'A Cloud vault that another device encrypted end to end reads as encrypted, not deleted.' + } + ] + }, { version: '2.68.0', highlights: [ diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 040189534..50d4b759e 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.68.0", + "version": "2.69.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index d6dd26b53..bbc496de4 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -92,7 +92,8 @@ import type { McpClientId, McpClientStatus, McpInstructionsPayload, - McpServerRuntime + McpServerRuntime, + McpServerTokenState } from './mcp-clients.js' import type { AppConfigPortable } from './app-config.js' import type { ExternalUrlResult } from './application-links.js' @@ -131,6 +132,11 @@ export interface ZenCapabilities { * be reviewed. Absent where records are not reachable yet (the web client * and the phones), which hides the review. */ supportsAgentEditReview?: boolean + /** The host can save a readable archive of an end-to-end encrypted vault's + * backup: the desktop app opens the service's archive item by item. The + * phones refuse it, so where this is absent Settings says where to save + * one instead of offering a button that can only fail. */ + supportsEncryptedBackupArchive?: boolean } export interface ZenAppInfo { @@ -514,6 +520,14 @@ export interface ZenBridge { mcpGetStatuses(): Promise mcpInstall(id: McpClientId): Promise mcpUninstall(id: McpClientId): Promise + /** Desktop only: whether assistants can use the token of the server the app + * is on (#830). Optional so the web and phone bridges need no stub. */ + mcpGetServerTokenState?(): Promise + /** Desktop only: saves the app's token for that server where zn and the MCP + * servers look. */ + mcpShareServerToken?(): Promise + /** Desktop only: removes it again, only when it is still the app's token. */ + mcpRemoveServerToken?(): Promise mcpGetInstructions(): Promise mcpSetInstructions(next: string | null): Promise cliGetStatus(): Promise diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 5c73f0cc1..5a0b56dbf 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -186,6 +186,9 @@ export const IPC = { MCP_INSTALL: 'mcp:install', MCP_UNINSTALL: 'mcp:uninstall', MCP_RUNTIME: 'mcp:runtime', + MCP_SERVER_TOKEN_STATE: 'mcp:server-token-state', + MCP_SHARE_SERVER_TOKEN: 'mcp:share-server-token', + MCP_REMOVE_SERVER_TOKEN: 'mcp:remove-server-token', MCP_GET_INSTRUCTIONS: 'mcp:get-instructions', MCP_SET_INSTRUCTIONS: 'mcp:set-instructions', CLI_GET_STATUS: 'cli:get-status', diff --git a/packages/bridge-contract/src/mcp-clients.ts b/packages/bridge-contract/src/mcp-clients.ts index 848ac99b9..7f1cb2b7c 100644 --- a/packages/bridge-contract/src/mcp-clients.ts +++ b/packages/bridge-contract/src/mcp-clients.ts @@ -34,6 +34,26 @@ export interface McpServerRuntime { unavailableReason?: string } +/** + * Settings → MCP → Server token (#830): whether assistants can authenticate + * to the ZenNotes Server the app is connected to, through the token file + * `zn connect` writes. The main process works it out from its own config; + * it never carries the token itself. + */ +export type McpServerTokenState = + /** Nothing to share: the vault is on this machine, or the server needs no + * token. */ + | { kind: 'none'; reason: 'local' | 'no-token' } + /** The server needs a token and the file has none for it. */ + | { kind: 'missing'; serverUrl: string; credentialsPath: string } + /** The file holds the token the app connects with. */ + | { kind: 'shared'; serverUrl: string; credentialsPath: string } + /** The file holds another token for this server (`zn connect`, or the + * server's token changed since). */ + | { kind: 'different'; serverUrl: string; credentialsPath: string } + /** The file is not zn's format; ZenNotes will not write to it. */ + | { kind: 'unreadable'; serverUrl: string; credentialsPath: string; message: string } + /** * Shape returned when the renderer asks for the current server-side * instructions. `defaultValue` is the compiled default; `current` is diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 9f88d630d..14cf40ac6 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.68.0", + "version": "2.69.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/cloud-sync-host-service.test.ts b/packages/shared-domain/src/cloud-sync-host-service.test.ts index f58f2988b..8ad493a8d 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.test.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.test.ts @@ -16,6 +16,7 @@ import { type CloudSyncHostPersistence, type CloudSyncHostVault } from './cloud-sync-host-service' +import { CLOUD_VAULT_SUPERSEDED_MESSAGE, isCloudVaultSuperseded } from './cloud-vault-availability' function textContent(data: string): CloudSyncContent { return { @@ -723,3 +724,57 @@ describe('CloudSyncHostService deleted vault recovery (#791)', () => { expect(await service.linkedVault(hostVault)).toEqual(link) }) }) + +describe('CloudSyncHostService vault replaced by an encrypted one', () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', name: 'Notes', cursor: 0, + created_at: '2026-10-13T12:00:00.000Z', updated_at: '2026-10-13T12:00:00.000Z' + } + // What Cloud answers a device still linked to a readable vault that another + // device moved into an encrypted one. + const superseded = () => Object.assign( + new Error('This vault was replaced by an end-to-end encrypted vault. Update ZenNotes on this device to keep syncing it.'), + { name: 'CloudServiceRequestError', status: 410, code: 'VAULT_SUPERSEDED', details: { successor_vault_id: 'vault-2' } } + ) + + it('retires the link and its cursor on the first 410, and says the vault is now encrypted', async () => { + const { service, client, persistence, hostVault, retiredStates } = setup([remoteVault]) + await service.link(hostVault, remoteVault.id) + await service.sync(hostVault) + const state = structuredClone(await persistence.loadState(hostVault.key, 'https://zennotes.org', remoteVault.id)) + const apply = vi.spyOn(hostVault.repository, 'apply') + client.changes.mockRejectedValue(superseded()) + client.manifest.mockRejectedValue(superseded()) + + await expect(service.sync(hostVault)).rejects.toThrow(CLOUD_VAULT_SUPERSEDED_MESSAGE) + + expect(await service.linkedVault(hostVault)).toBeNull() + expect(retiredStates).toEqual([{ vaultKey: hostVault.key, baseUrl: 'https://zennotes.org', vaultId: remoteVault.id, state }]) + expect(apply).not.toHaveBeenCalled() + expect(client.deleteVault).not.toHaveBeenCalled() + }) + + it('says the same when the check for remote changes meets the 410 first', async () => { + const { service, client, hostVault } = setup([remoteVault]) + await service.link(hostVault, remoteVault.id) + await service.sync(hostVault) + client.manifest.mockRejectedValue(superseded()) + + await expect(service.hasRemoteChanges(hostVault)).rejects.toThrow(CLOUD_VAULT_SUPERSEDED_MESSAGE) + + expect(await service.linkedVault(hostVault)).toBeNull() + client.manifest.mockClear() + expect(await service.hasRemoteChanges(hostVault)).toBe(false) + expect(client.manifest).not.toHaveBeenCalled() + }) + + it('counts only Cloud\'s own 410 VAULT_SUPERSEDED', () => { + expect(isCloudVaultSuperseded(superseded())).toBe(true) + expect(isCloudVaultSuperseded(Object.assign(new Error('Gone'), { + name: 'CloudServiceRequestError', status: 410, code: null + }))).toBe(false) + expect(isCloudVaultSuperseded(Object.assign(new Error('Gone'), { status: 410, code: 'VAULT_SUPERSEDED' }))).toBe(false) + expect(isCloudVaultSuperseded(new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE))).toBe(false) + expect(isCloudVaultSuperseded(null)).toBe(false) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-host-service.ts b/packages/shared-domain/src/cloud-sync-host-service.ts index 8ba28c4a4..aea82c01e 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.ts @@ -20,7 +20,14 @@ import type { import type { CloudSyncApiClient } from './cloud-sync-api' import { cloudBackupCreateError, cloudBackupItemsPage, restoreCloudBackup } from './cloud-backup' import { normalizeCloudSyncPath } from './cloud-sync' -import { CLOUD_VAULT_REMOVED_MESSAGE, confirmCloudVaultMissing, isCloudResourceMissing, sameCloudVaultLink } from './cloud-vault-availability' +import { + CLOUD_VAULT_REMOVED_MESSAGE, + CLOUD_VAULT_SUPERSEDED_MESSAGE, + confirmCloudVaultMissing, + isCloudResourceMissing, + isCloudVaultSuperseded, + sameCloudVaultLink +} from './cloud-vault-availability' import { CloudSyncCoordinator, type CloudSyncRepository, @@ -246,6 +253,9 @@ export class CloudSyncHostService { const manifest = await client.manifest(link.vault_id, { includeContent: false, perPage: 1 }) return manifest.cursor !== state.cursor } catch (error) { + if (isCloudVaultSuperseded(error) && await this.retireMissingLink(vault, link)) { + throw new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE) + } if (isCloudResourceMissing(error) && await this.retireMissingLink(vault, link)) { throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) } @@ -304,6 +314,11 @@ export class CloudSyncHostService { legacy_conflict_copies: result.legacyConflictCopies } } catch (error) { + // Another device moved this vault into an encrypted one. The link has + // to go like a deleted vault's, or every later sync meets the same 410. + if (isCloudVaultSuperseded(error) && await this.retireMissingLink(vault, link)) { + throw new Error(CLOUD_VAULT_SUPERSEDED_MESSAGE) + } if (await confirmCloudVaultMissing(client, link.vault_id, error) && await this.retireMissingLink(vault, link)) { throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) diff --git a/packages/shared-domain/src/cloud-vault-availability.ts b/packages/shared-domain/src/cloud-vault-availability.ts index ea54bf6ee..7c16d782b 100644 --- a/packages/shared-domain/src/cloud-vault-availability.ts +++ b/packages/shared-domain/src/cloud-vault-availability.ts @@ -4,12 +4,29 @@ import type { CloudSyncApiClient } from './cloud-sync-api' export const CLOUD_VAULT_REMOVED_MESSAGE = 'This Cloud vault is no longer available. This device has been unlinked; your local notes are unchanged.' +/** What the hosts say after dropping the link to a vault another device + * replaced with an end-to-end encrypted one. The app tells it apart from a + * deleted vault by its first sentence. */ +export const CLOUD_VAULT_SUPERSEDED_MESSAGE = + 'This Cloud vault is now end-to-end encrypted. Unlock encryption in Settings, then open the encrypted vault on this device.' + export function isCloudResourceMissing(error: unknown): boolean { if (!error || typeof error !== 'object') return false const value = error as { name?: unknown; status?: unknown; code?: unknown } return value.name === 'CloudServiceRequestError' && value.status === 404 && value.code === 'NOT_FOUND' } +/** + * Cloud's answer, on every sync, backup and restore route, for a readable + * vault that was replaced by an encrypted one and deleted. Unlike a 404 it + * always concerns the vault itself, so it needs no second request to prove. + */ +export function isCloudVaultSuperseded(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const value = error as { name?: unknown; status?: unknown; code?: unknown } + return value.name === 'CloudServiceRequestError' && value.status === 410 && value.code === 'VAULT_SUPERSEDED' +} + /** Item/revision 404s and paginated vault lists cannot prove a vault is gone. */ export async function confirmCloudVaultMissing( client: Pick, diff --git a/packages/shared-domain/src/mcp-clients.ts b/packages/shared-domain/src/mcp-clients.ts index bddba8c7a..fadce0ba7 100644 --- a/packages/shared-domain/src/mcp-clients.ts +++ b/packages/shared-domain/src/mcp-clients.ts @@ -11,6 +11,7 @@ export type { McpClientId, McpClientStatus, McpServerRuntime, + McpServerTokenState, McpInstructionsPayload } from '@zennotes/bridge-contract/mcp-clients' @@ -40,19 +41,22 @@ export interface McpClientDescriptor { export const MCP_SERVER_KEY = 'zennotes' export const MCP_CLIENTS: McpClientDescriptor[] = [ + // Two entries for one Claude app on purpose: the desktop app's Code tab runs + // Claude Code and reads ~/.claude.json, while claude_desktop_config.json + // only reaches its Chat tab. The labels say which tab each one covers. { id: 'claude-code', - label: 'Claude Code', + label: 'Claude Code (CLI + Desktop Code tab)', description: - 'Anthropic\u2019s CLI. Installs as a user-scope MCP server in ~/.claude.json so every Claude Code session can reach your vault.', + 'Claude Code in the terminal and in the Claude desktop app\u2019s Code tab. Installs as a user-scope MCP server in ~/.claude.json, which both read, so every Claude Code session can reach your vault.', format: 'json', serverKey: MCP_SERVER_KEY }, { id: 'claude-desktop', - label: 'Claude Desktop', + label: 'Claude Desktop (Chat)', description: - 'The Claude desktop app. Registers the server in claude_desktop_config.json. Requires a full restart of Claude Desktop to take effect.', + 'The Chat tab of the Claude desktop app. Registers the server in claude_desktop_config.json; quit and reopen Claude Desktop to load it. Its Code tab uses the Claude Code entry instead.', format: 'json', serverKey: MCP_SERVER_KEY }, diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index fc57368d9..b66e2b216 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.68.0", + "version": "2.69.0", "type": "module", "exports": { ".": "./src/index.ts" diff --git a/tooling/scripts/test-app-core-package.mjs b/tooling/scripts/test-app-core-package.mjs index f89d410df..cdeb64246 100644 --- a/tooling/scripts/test-app-core-package.mjs +++ b/tooling/scripts/test-app-core-package.mjs @@ -193,6 +193,7 @@ function Browse() { + {rows.folders.map(row =>