diff --git a/apps/desktop/src/main/deep-links.test.ts b/apps/desktop/src/main/deep-links.test.ts index 45561bc5..cf1f6508 100644 --- a/apps/desktop/src/main/deep-links.test.ts +++ b/apps/desktop/src/main/deep-links.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseOpenNoteDeepLink } from './deep-links' +import { parseAuthDeepLink, parseOpenNoteDeepLink } from './deep-links' describe('parseOpenNoteDeepLink', () => { it('parses encoded vault-relative paths', () => { @@ -40,4 +40,45 @@ describe('parseOpenNoteDeepLink', () => { expect(parseOpenNoteDeepLink('zennotes://open?path=notes%2F..%2Fsecret.md')).toBeNull() expect(parseOpenNoteDeepLink('zennotes://open?path=C%3A%2FUsers%2Fnote.md')).toBeNull() }) + + it('ignores auth deep links', () => { + expect(parseOpenNoteDeepLink('zennotes://auth?code=abc&state=xyz')).toBeNull() + }) +}) + +describe('parseAuthDeepLink', () => { + it('parses code and state from auth links', () => { + expect(parseAuthDeepLink('zennotes://auth?code=abc123&state=xyz789')).toEqual({ + code: 'abc123', + state: 'xyz789' + }) + }) + + it('parses single-slash auth links', () => { + expect(parseAuthDeepLink('zennotes:/auth?code=abc&state=xyz')).toEqual({ + code: 'abc', + state: 'xyz' + }) + }) + + it('decodes url-encoded values', () => { + expect(parseAuthDeepLink('zennotes://auth?code=a%2Bb&state=s%20t')).toEqual({ + code: 'a+b', + state: 's t' + }) + }) + + it('rejects links missing code or state', () => { + expect(parseAuthDeepLink('zennotes://auth?code=abc')).toBeNull() + expect(parseAuthDeepLink('zennotes://auth?state=xyz')).toBeNull() + expect(parseAuthDeepLink('zennotes://auth?code=&state=xyz')).toBeNull() + expect(parseAuthDeepLink('zennotes://auth')).toBeNull() + }) + + it('rejects other schemes and actions', () => { + expect(parseAuthDeepLink('https://auth?code=abc&state=xyz')).toBeNull() + expect(parseAuthDeepLink('zennotes://open?path=note.md')).toBeNull() + expect(parseAuthDeepLink('')).toBeNull() + expect(parseAuthDeepLink('not a url')).toBeNull() + }) }) diff --git a/apps/desktop/src/main/deep-links.ts b/apps/desktop/src/main/deep-links.ts index f65bad13..753fb09c 100644 --- a/apps/desktop/src/main/deep-links.ts +++ b/apps/desktop/src/main/deep-links.ts @@ -35,6 +35,39 @@ export function parseOpenNoteDeepLink(rawUrl: string): OpenNoteDeepLinkRequest | return notePath ? { target, path: notePath } : null } +export interface AuthDeepLinkRequest { + code: string + state: string +} + +/** + * Parse a `zennotes://auth?code=…&state=…` deep link sent back by the + * share-server connect page. Returns null for anything else; the caller + * falls through to the open-note parser. + */ +export function parseAuthDeepLink(rawUrl: string): AuthDeepLinkRequest | null { + const trimmed = rawUrl.trim() + if (!trimmed) return null + + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + return null + } + + if (parsed.protocol !== `${ZENNOTES_DEEP_LINK_SCHEME}:`) return null + + const action = parsed.hostname || parsed.pathname.replace(/^\/+/, '') + if (action !== 'auth') return null + + const code = parsed.searchParams.get('code')?.trim() ?? '' + const state = parsed.searchParams.get('state')?.trim() ?? '' + if (!code || !state) return null + + return { code, state } +} + export function normalizeDeepLinkNotePath(rawPath: string | null | undefined): string | null { const trimmed = rawPath?.trim() if (!trimmed || trimmed.includes('\0')) return null diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d6d159b2..0b1f9148 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -33,6 +33,7 @@ import type { RemoteWorkspaceProfile, RemoteWorkspaceProfileInput, ServerCapabilities, + SharePublishRequest, VaultSettings, VaultChangeEvent, VaultInfo, @@ -109,6 +110,17 @@ import { VaultWatcher } from './watcher' import { WindowVaultRegistry } from './window-vaults' import { renderTikz } from './tikz' import { RemoteServerClient } from './remote/server-client' +import { + beginShareConnect, + completeShareConnect, + disconnectShareAccount, + getShareAccount, + getShareServerUrl, + setShareServerUrl, + requireShareClient, + type ShareAccountDeps +} from './share/share-account' +import { publishShare, readLocalVaultAsset } from './share/share-service' import { getMcpClientStatuses, getMcpServerRuntime, @@ -141,6 +153,7 @@ import { } from '../mcp/instructions-store' import { recordMainPerf } from './perf' import { + parseAuthDeepLink, parseOpenNoteDeepLink, ZENNOTES_DEEP_LINK_SCHEME } from './deep-links' @@ -314,7 +327,44 @@ async function flushPendingFloatingNoteRequests(): Promise { } } +const shareAccountDeps: ShareAccountDeps = { + getUserDataPath: () => app.getPath('userData'), + getClientVersion: () => app.getVersion(), + openExternal: (url) => shell.openExternal(url) +} + +function broadcastShareAuthResult(result: { + ok: boolean + account?: unknown + error?: string +}): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(IPC.SHARE_ON_AUTH_RESULT, result) + } +} + +function handleShareAuthDeepLink(rawUrl: string): boolean { + const request = parseAuthDeepLink(rawUrl) + if (!request) return false + + void (async () => { + try { + const account = await completeShareConnect(shareAccountDeps, request.code, request.state) + broadcastShareAuthResult({ ok: true, account }) + } catch (error) { + broadcastShareAuthResult({ + ok: false, + error: error instanceof Error ? error.message : 'Connecting your account failed.' + }) + } + if (mainWindow && !mainWindow.isDestroyed()) focusWindow(mainWindow) + })() + + return true +} + function handleExternalOpenUrl(rawUrl: string): boolean { + if (handleShareAuthDeepLink(rawUrl)) return true const request = parseOpenNoteDeepLink(rawUrl) if (!request) return false if (request.target === 'window') queueFloatingNoteRequest(request.path) @@ -2528,6 +2578,42 @@ function registerIpc(): void { return { ok: false, error: result.error } }) + handle(IPC.SHARE_GET_ACCOUNT, async () => getShareAccount(shareAccountDeps)) + handle(IPC.SHARE_BEGIN_CONNECT, async () => beginShareConnect(shareAccountDeps)) + handle(IPC.SHARE_SUBMIT_CODE, async (_e, code: string) => + completeShareConnect(shareAccountDeps, code, null) + ) + handle(IPC.SHARE_DISCONNECT, async () => disconnectShareAccount(shareAccountDeps)) + handle(IPC.SHARE_GET_SERVER_URL, async () => getShareServerUrl(shareAccountDeps)) + handle(IPC.SHARE_SET_SERVER_URL, async (_e, url: string) => + setShareServerUrl(shareAccountDeps, url) + ) + handle(IPC.SHARE_PUBLISH, async (_e, request: SharePublishRequest) => { + const vault = requireVault() + const remote = isRemoteWorkspaceActive() ? requireRemoteWorkspaceClient() : null + return publishShare( + { + ...shareAccountDeps, + readAssetBytes: async (vaultRelPath: string) => { + if (remote) { + const response = await remote.fetchAssetResponse(vaultRelPath) + return new Uint8Array(await response.arrayBuffer()) + } + return readLocalVaultAsset(vault.root, vaultRelPath) + } + }, + request + ) + }) + handle(IPC.SHARE_UNPUBLISH, async (_e, shareId: number) => { + const client = await requireShareClient(shareAccountDeps) + await client.deleteShare(shareId) + }) + handle(IPC.SHARE_LIST, async () => { + const client = await requireShareClient(shareAccountDeps) + return client.listShares() + }) + handle(IPC.MCP_RUNTIME, async () => await getMcpServerRuntime()) handle(IPC.MCP_STATUS, async () => await getMcpClientStatuses()) handle(IPC.MCP_INSTALL, async (_e, id: McpClientId) => await installMcpForClient(id)) diff --git a/apps/desktop/src/main/share/share-account.test.ts b/apps/desktop/src/main/share/share-account.test.ts new file mode 100644 index 00000000..4d6de67d --- /dev/null +++ b/apps/desktop/src/main/share/share-account.test.ts @@ -0,0 +1,167 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const secretStore = vi.hoisted(() => { + const secrets = new Map() + return { + secrets, + getRemoteWorkspaceSecret: vi.fn(async (id: string) => secrets.get(id) ?? null), + setRemoteWorkspaceSecret: vi.fn(async (id: string, secret: string | null) => { + if (secret) secrets.set(id, secret) + else secrets.delete(id) + return Boolean(secret) + }), + deleteRemoteWorkspaceSecret: vi.fn(async (id: string) => { + secrets.delete(id) + }) + } +}) + +vi.mock('../secret-store', () => secretStore) + +import { + beginShareConnect, + completeShareConnect, + disconnectShareAccount, + getShareAccount, + getShareServerUrl, + resetShareAccountStateForTests, + setShareServerUrl, + type ShareAccountDeps +} from './share-account' + +const fetchMock = vi.fn() +const openExternal = vi.fn(async () => {}) + +function makeDeps(): ShareAccountDeps { + const dir = mkdtempSync(path.join(tmpdir(), 'zen-share-account-')) + return { + getUserDataPath: () => dir, + getClientVersion: () => '0.0.0-test', + openExternal + } +} + +let deps: ShareAccountDeps + +beforeEach(() => { + resetShareAccountStateForTests() + secretStore.secrets.clear() + fetchMock.mockReset() + openExternal.mockClear() + vi.stubGlobal('fetch', fetchMock) + deps = makeDeps() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function exchangeSucceedsWith(token = 'tok-1'): void { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ token, user: { name: 'Adib', email: 'adib@test.dev' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) +} + +describe('share account connect flow', () => { + it('begins a connect by opening the browser with a state nonce', async () => { + const pending = await beginShareConnect(deps) + + expect(pending.state).toMatch(/^[0-9a-f-]{36}$/) + expect(pending.url).toBe( + `https://zennotes.org/app/connect?state=${encodeURIComponent(pending.state)}` + ) + expect(openExternal).toHaveBeenCalledWith(pending.url) + }) + + it('completes the flow when the deep link state matches', async () => { + exchangeSucceedsWith('tok-deep') + const pending = await beginShareConnect(deps) + + const account = await completeShareConnect(deps, 'code-1', pending.state) + + expect(account).toMatchObject({ + connected: true, + name: 'Adib', + email: 'adib@test.dev' + }) + expect(secretStore.secrets.get('share-account-token')).toBe('tok-deep') + }) + + it('completes the flow with a manually pasted code (no explicit state)', async () => { + exchangeSucceedsWith() + await beginShareConnect(deps) + + const account = await completeShareConnect(deps, ' code-2 ', null) + + expect(account.connected).toBe(true) + // The exchange used the pending nonce. + const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string) + expect(body.code).toBe('code-2') + expect(body.state).toMatch(/^[0-9a-f-]{36}$/) + }) + + it('rejects a state mismatch', async () => { + await beginShareConnect(deps) + + await expect(completeShareConnect(deps, 'code', 'wrong-state')).rejects.toThrow( + /does not match the pending connect attempt/ + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects when no connect attempt is pending', async () => { + await expect(completeShareConnect(deps, 'code', null)).rejects.toThrow( + /No connect attempt is in progress/ + ) + }) + + it('consumes the pending nonce on success', async () => { + exchangeSucceedsWith() + const pending = await beginShareConnect(deps) + await completeShareConnect(deps, 'code', pending.state) + + await expect(completeShareConnect(deps, 'code', pending.state)).rejects.toThrow( + /No connect attempt is in progress/ + ) + }) + + it('disconnect clears the token and identity but keeps the server url', async () => { + exchangeSucceedsWith() + await setShareServerUrl(deps, 'http://zennotes.test') + const pending = await beginShareConnect(deps) + await completeShareConnect(deps, 'code', pending.state) + + const account = await disconnectShareAccount(deps) + + expect(account).toEqual({ + connected: false, + name: null, + email: null, + serverUrl: 'http://zennotes.test' + }) + expect(secretStore.secrets.has('share-account-token')).toBe(false) + }) + + it('reports a disconnected account by default', async () => { + expect(await getShareAccount(deps)).toEqual({ + connected: false, + name: null, + email: null, + serverUrl: 'https://zennotes.org' + }) + }) + + it('normalizes and persists the server url override', async () => { + expect(await setShareServerUrl(deps, 'zennotes.test/')).toBe('https://zennotes.test') + expect(await getShareServerUrl(deps)).toBe('https://zennotes.test') + + // Empty resets to the default. + expect(await setShareServerUrl(deps, ' ')).toBe('https://zennotes.org') + }) +}) diff --git a/apps/desktop/src/main/share/share-account.ts b/apps/desktop/src/main/share/share-account.ts new file mode 100644 index 00000000..63caa8e3 --- /dev/null +++ b/apps/desktop/src/main/share/share-account.ts @@ -0,0 +1,194 @@ +import { randomUUID } from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { DEFAULT_SHARE_SERVER_URL, type ShareAccount, type ShareConnectPending } from '@shared/ipc' +import { + deleteRemoteWorkspaceSecret, + getRemoteWorkspaceSecret, + setRemoteWorkspaceSecret +} from '../secret-store' +import { normalizeShareServerUrl, ShareServerClient } from './share-client' + +/** Keychain id (piggybacks on the existing remote-workspace store). */ +const TOKEN_SECRET_ID = 'share-account-token' +const ACCOUNT_FILE = 'share-account.json' +/** How long a begin-connect nonce stays valid. */ +const PENDING_TTL_MS = 10 * 60 * 1000 + +interface ShareAccountFile { + name: string | null + email: string | null + serverUrl: string +} + +export interface ShareAccountDeps { + getUserDataPath(): string + getClientVersion(): string + openExternal(url: string): Promise +} + +interface PendingConnect { + state: string + createdAt: number +} + +let pending: PendingConnect | null = null +let cachedAccount: ShareAccountFile | null = null + +function accountFilePath(deps: ShareAccountDeps): string { + return path.join(deps.getUserDataPath(), ACCOUNT_FILE) +} + +async function loadAccountFile(deps: ShareAccountDeps): Promise { + if (cachedAccount) return cachedAccount + try { + const raw = await fs.readFile(accountFilePath(deps), 'utf8') + const parsed = JSON.parse(raw) as Partial + cachedAccount = { + name: typeof parsed.name === 'string' ? parsed.name : null, + email: typeof parsed.email === 'string' ? parsed.email : null, + serverUrl: + typeof parsed.serverUrl === 'string' && parsed.serverUrl.trim() + ? normalizeShareServerUrl(parsed.serverUrl) + : DEFAULT_SHARE_SERVER_URL + } + } catch { + cachedAccount = { name: null, email: null, serverUrl: DEFAULT_SHARE_SERVER_URL } + } + return cachedAccount +} + +async function saveAccountFile(deps: ShareAccountDeps, next: ShareAccountFile): Promise { + cachedAccount = next + await fs.mkdir(path.dirname(accountFilePath(deps)), { recursive: true }) + await fs.writeFile(accountFilePath(deps), JSON.stringify(next, null, 2), 'utf8') +} + +export async function getShareToken(): Promise { + return getRemoteWorkspaceSecret(TOKEN_SECRET_ID) +} + +export async function getShareAccount(deps: ShareAccountDeps): Promise { + const file = await loadAccountFile(deps) + const token = await getShareToken() + return { + connected: Boolean(token), + name: token ? file.name : null, + email: token ? file.email : null, + serverUrl: file.serverUrl + } +} + +export async function getShareServerUrl(deps: ShareAccountDeps): Promise { + const override = process.env.ZENNOTES_SHARE_SERVER_URL?.trim() + if (override) return normalizeShareServerUrl(override) + return (await loadAccountFile(deps)).serverUrl +} + +export async function setShareServerUrl(deps: ShareAccountDeps, url: string): Promise { + const file = await loadAccountFile(deps) + const serverUrl = url.trim() ? normalizeShareServerUrl(url) : DEFAULT_SHARE_SERVER_URL + await saveAccountFile(deps, { ...file, serverUrl }) + return serverUrl +} + +/** + * Start the browser connect handoff: mint a state nonce, remember it, + * and open the server's authorize page in the default browser. + */ +export async function beginShareConnect(deps: ShareAccountDeps): Promise { + const state = randomUUID() + pending = { state, createdAt: Date.now() } + + const serverUrl = await getShareServerUrl(deps) + const url = `${serverUrl}/app/connect?state=${encodeURIComponent(state)}` + await deps.openExternal(url) + + return { state, url } +} + +function currentPending(): PendingConnect | null { + if (!pending) return null + if (Date.now() - pending.createdAt > PENDING_TTL_MS) { + pending = null + return null + } + return pending +} + +/** + * Complete the connect flow with a one-time code — either delivered via + * the zennotes://auth deep link (which carries the state to verify) or + * pasted manually into Settings (state implied by the pending nonce). + */ +export async function completeShareConnect( + deps: ShareAccountDeps, + code: string, + state: string | null +): Promise { + const current = currentPending() + if (!current) { + throw new Error( + 'No connect attempt is in progress. Use "Connect via browser" first, then paste the code.' + ) + } + if (state !== null && current.state !== state) { + throw new Error( + 'This sign-in link does not match the pending connect attempt. Start the connection again from Settings → Sharing.' + ) + } + + const trimmedCode = code.trim() + if (!trimmedCode) { + throw new Error('Paste the one-time code shown in the browser.') + } + + const client = new ShareServerClient({ + baseUrl: await getShareServerUrl(deps), + clientVersion: deps.getClientVersion() + }) + const result = await client.exchange(trimmedCode, current.state) + + pending = null + await setRemoteWorkspaceSecret(TOKEN_SECRET_ID, result.token) + + const file = await loadAccountFile(deps) + await saveAccountFile(deps, { + ...file, + name: result.user?.name ?? null, + email: result.user?.email ?? null + }) + + return getShareAccount(deps) +} + +export async function disconnectShareAccount(deps: ShareAccountDeps): Promise { + pending = null + await deleteRemoteWorkspaceSecret(TOKEN_SECRET_ID) + + const file = await loadAccountFile(deps) + await saveAccountFile(deps, { ...file, name: null, email: null }) + + return getShareAccount(deps) +} + +/** Build an authenticated client, or explain how to connect first. */ +export async function requireShareClient(deps: ShareAccountDeps): Promise { + const token = await getShareToken() + if (!token) { + throw new Error( + 'Connect your ZenNotes account first: Settings → Sharing → Connect via browser.' + ) + } + return new ShareServerClient({ + baseUrl: await getShareServerUrl(deps), + token, + clientVersion: deps.getClientVersion() + }) +} + +/** Test hook: clear module state between specs. */ +export function resetShareAccountStateForTests(): void { + pending = null + cachedAccount = null +} diff --git a/apps/desktop/src/main/share/share-client.test.ts b/apps/desktop/src/main/share/share-client.test.ts new file mode 100644 index 00000000..eced3efa --- /dev/null +++ b/apps/desktop/src/main/share/share-client.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + normalizeShareServerUrl, + ShareRequestError, + ShareServerClient, + type ShareUploadBody +} from './share-client' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' } + }) +} + +function uploadBody(overrides: Partial = {}): ShareUploadBody { + return { + notePath: 'inbox/Note.md', + title: 'Note', + markdown: '# Hi', + tikzSvgs: [{ hash: 'abc', svg: '' }], + assets: [ + { ref: 'media/photo.png', bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png' }, + { ref: 'song.mp3', bytes: new Uint8Array([4, 5]), mimeType: 'audio/mpeg' } + ], + ...overrides + } +} + +const fetchMock = vi.fn() + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function client(token: string | null = 'tok-123'): ShareServerClient { + return new ShareServerClient({ + baseUrl: 'https://zennotes.test', + token, + clientVersion: '9.9.9' + }) +} + +describe('ShareServerClient', () => { + it('sends bearer and client headers', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: [] })) + + await client().listShares() + + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://zennotes.test/api/v1/shares') + const headers = init.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer tok-123') + expect(headers.get('X-ZenNotes-Client')).toBe('9.9.9') + expect(headers.get('Accept')).toBe('application/json') + }) + + it('exchanges a code without a bearer token', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ token: 'fresh', user: { name: 'Adib', email: 'a@b.c' } }) + ) + + const result = await client(null).exchange('code-1', 'state-1') + + expect(result.token).toBe('fresh') + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://zennotes.test/api/v1/app/exchange') + expect(init.method).toBe('POST') + expect((init.headers as Headers).get('Authorization')).toBeNull() + expect(JSON.parse(init.body as string)).toEqual({ code: 'code-1', state: 'state-1' }) + }) + + it('creates shares with a multipart body whose refs match asset order', async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: 7, slug: 'abc', url: 'https://x/s/abc' }, 201)) + + const record = await client().createShare(uploadBody()) + + expect(record).toMatchObject({ id: 7, slug: 'abc', url: 'https://x/s/abc' }) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://zennotes.test/api/v1/shares') + expect(init.method).toBe('POST') + + const form = init.body as FormData + expect(form).toBeInstanceOf(FormData) + + const payload = JSON.parse(form.get('payload') as string) + expect(payload.note_path).toBe('inbox/Note.md') + expect(payload.tikz_svgs).toEqual([{ hash: 'abc', svg: '' }]) + expect(payload.asset_refs).toEqual(['media/photo.png', 'song.mp3']) + + const files = form.getAll('assets[]') as File[] + expect(files).toHaveLength(2) + // Part filenames are basenames (the server zips by index, not name). + expect(files[0]!.name).toBe('photo.png') + expect(files[1]!.name).toBe('song.mp3') + expect(files[0]!.type).toBe('image/png') + + // Content-Type must be left to fetch so the boundary is set. + const headers = init.headers as Headers + expect(headers.get('Content-Type')).toBeNull() + }) + + it('updates shares via PUT to the share id', async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: 7, slug: 'abc', url: 'https://x/s/abc' })) + + await client().updateShare(7, uploadBody()) + + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://zennotes.test/api/v1/shares/7') + expect(init.method).toBe('PUT') + }) + + it('treats 204 as success for delete', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + await expect(client().deleteShare(7)).resolves.toBeUndefined() + + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://zennotes.test/api/v1/shares/7') + expect(init.method).toBe('DELETE') + }) + + it('maps error statuses to friendly ShareRequestErrors', async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: 'nope' }, 404)) + + const error = await client() + .deleteShare(9) + .catch((err: unknown) => err) + + expect(error).toBeInstanceOf(ShareRequestError) + expect((error as ShareRequestError).status).toBe(404) + }) + + it('explains connection failures', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed')) + + await expect(client().listShares()).rejects.toThrow(/Could not reach the share server/) + }) +}) + +describe('normalizeShareServerUrl', () => { + it('defaults to https and strips trailing slashes', () => { + expect(normalizeShareServerUrl('zennotes.org/')).toBe('https://zennotes.org') + expect(normalizeShareServerUrl('http://zennotes.test//')).toBe('http://zennotes.test') + expect(normalizeShareServerUrl(' https://x.dev ')).toBe('https://x.dev') + }) +}) diff --git a/apps/desktop/src/main/share/share-client.ts b/apps/desktop/src/main/share/share-client.ts new file mode 100644 index 00000000..50808e8f --- /dev/null +++ b/apps/desktop/src/main/share/share-client.ts @@ -0,0 +1,231 @@ +import type { ShareRecord } from '@shared/ipc' + +export interface ShareServerClientOptions { + baseUrl: string + token?: string | null + /** Sent as X-ZenNotes-Client so the server can reason about versions. */ + clientVersion?: string | null +} + +export interface ShareExchangeResult { + token: string + user: { name: string; email: string } +} + +/** One uploadable asset: the markdown ref plus its raw bytes. */ +export interface ShareUploadAsset { + ref: string + bytes: Uint8Array + mimeType?: string | null +} + +export interface ShareUploadBody { + notePath: string + title: string + markdown: string + tikzSvgs: { hash: string; svg: string }[] + assets: ShareUploadAsset[] +} + +interface ShareRecordWire { + id: number + slug: string + url: string + title?: string | null + note_path?: string | null + view_count?: number + updated_at?: string | null +} + +type JsonRequestInit = Omit & { body?: unknown } + +/** + * HTTP client for the zennotes.org share API. Modeled on + * `RemoteServerClient` — native fetch, Bearer auth, friendly errors — + * plus multipart uploads for publish/republish. + */ +export class ShareServerClient { + readonly baseUrl: string + readonly token: string | null + readonly clientVersion: string | null + + constructor(options: ShareServerClientOptions) { + this.baseUrl = normalizeShareServerUrl(options.baseUrl) + this.token = options.token?.trim() || null + this.clientVersion = options.clientVersion?.trim() || null + } + + /** Exchange a one-time connect code for a personal access token. */ + async exchange(code: string, state: string): Promise { + return this.jsonRequest('/api/v1/app/exchange', { + method: 'POST', + body: { code, state } + }) + } + + async createShare(body: ShareUploadBody): Promise { + const record = await this.multipartRequest('/api/v1/shares', 'POST', body) + return toShareRecord(record) + } + + async updateShare(shareId: number, body: ShareUploadBody): Promise { + const record = await this.multipartRequest( + `/api/v1/shares/${shareId}`, + 'PUT', + body + ) + return toShareRecord(record) + } + + async deleteShare(shareId: number): Promise { + await this.jsonRequest(`/api/v1/shares/${shareId}`, { method: 'DELETE' }) + } + + async listShares(): Promise { + const response = await this.jsonRequest<{ data: ShareRecordWire[] }>('/api/v1/shares') + return (response.data ?? []).map(toShareRecord) + } + + private buildForm(body: ShareUploadBody): FormData { + const form = new FormData() + form.append( + 'payload', + JSON.stringify({ + note_path: body.notePath, + title: body.title, + markdown: body.markdown, + tikz_svgs: body.tikzSvgs, + // Ordered to match the assets[] parts below — the server zips + // them by index because multipart filenames get basenamed. + asset_refs: body.assets.map((asset) => asset.ref) + }) + ) + for (const asset of body.assets) { + const filename = asset.ref.split('/').pop() || 'asset' + const buffer = asset.bytes.buffer.slice( + asset.bytes.byteOffset, + asset.bytes.byteOffset + asset.bytes.byteLength + ) as ArrayBuffer + form.append( + 'assets[]', + new File([buffer], filename, { type: asset.mimeType ?? 'application/octet-stream' }) + ) + } + return form + } + + private async multipartRequest( + path: string, + method: 'POST' | 'PUT', + body: ShareUploadBody + ): Promise { + const headers = this.baseHeaders() + // No explicit Content-Type: fetch derives the multipart boundary. + const response = await this.send(path, { method, headers, body: this.buildForm(body) }) + return (await response.json()) as T + } + + private async jsonRequest(path: string, init?: JsonRequestInit): Promise { + const headers = this.baseHeaders(init?.headers) + const hasBody = init?.body !== undefined + if (hasBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + headers.set('Accept', 'application/json') + + const response = await this.send(path, { + ...init, + headers, + body: hasBody ? JSON.stringify(init!.body) : undefined + }) + if (response.status === 204) return undefined as T + return (await response.json()) as T + } + + private baseHeaders(extra?: RequestInit['headers']): Headers { + const headers = new Headers(extra) + if (this.token && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${this.token}`) + } + if (this.clientVersion) { + headers.set('X-ZenNotes-Client', this.clientVersion) + } + if (!headers.has('Accept')) headers.set('Accept', 'application/json') + return headers + } + + private async send(path: string, init: RequestInit): Promise { + let response: Response + try { + response = await fetch(`${this.baseUrl}${path}`, init) + } catch (error) { + const message = + error instanceof Error && error.message ? ` (${error.message})` : '' + throw new ShareRequestError( + `Could not reach the share server at ${this.baseUrl}.${message} Check the server URL in Settings → Sharing and your connection.`, + 0 + ) + } + + if (!response.ok) { + throw new ShareRequestError(await describeFailure(response), response.status) + } + + return response + } +} + +export class ShareRequestError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'ShareRequestError' + } +} + +async function describeFailure(response: Response): Promise { + const detail = await response + .json() + .then((body: unknown) => { + if (body && typeof body === 'object' && 'message' in body) { + const message = (body as { message?: unknown }).message + return typeof message === 'string' ? message : '' + } + return '' + }) + .catch(() => '') + + if (response.status === 401) { + return 'The share server rejected your token. Reconnect your account in Settings → Sharing.' + } + if (response.status === 404) { + return detail || 'That share no longer exists on the server.' + } + if (response.status === 422) { + return detail || 'The share server rejected the note (validation failed).' + } + if (response.status === 429) { + return 'The share server is rate limiting requests. Try again in a minute.' + } + return detail || `Share server request failed (${response.status} ${response.statusText}).` +} + +function toShareRecord(wire: ShareRecordWire): ShareRecord { + return { + id: wire.id, + slug: wire.slug, + url: wire.url, + title: wire.title ?? null, + notePath: wire.note_path ?? null, + viewCount: wire.view_count, + updatedAt: wire.updated_at ?? null + } +} + +export function normalizeShareServerUrl(value: string): string { + const trimmed = value.trim() + const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + return normalized.replace(/\/+$/, '') +} diff --git a/apps/desktop/src/main/share/share-service.ts b/apps/desktop/src/main/share/share-service.ts new file mode 100644 index 00000000..09907a4a --- /dev/null +++ b/apps/desktop/src/main/share/share-service.ts @@ -0,0 +1,102 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import type { SharePublishRequest, ShareRecord } from '@shared/ipc' +import { renderTikz, tikzHash } from '../tikz' +import type { ShareAccountDeps } from './share-account' +import { requireShareClient } from './share-account' +import { ShareRequestError, type ShareUploadAsset, type ShareUploadBody } from './share-client' + +const MIME_BY_EXTENSION: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.aac': 'audio/aac', + '.flac': 'audio/flac', + '.ogg': 'audio/ogg', + '.wav': 'audio/wav', + '.mp4': 'video/mp4', + '.m4v': 'video/mp4', + '.mov': 'video/quicktime', + '.ogv': 'video/ogg', + '.webm': 'video/webm', + '.pdf': 'application/pdf' +} + +export interface SharePublishContext extends ShareAccountDeps { + /** Read an asset's bytes by vault-relative path (local fs or remote). */ + readAssetBytes(vaultRelPath: string): Promise +} + +/** + * Read asset bytes from a local vault, refusing paths that escape the + * vault root (defense in depth — refs were resolved renderer-side). + */ +export async function readLocalVaultAsset( + vaultRoot: string, + vaultRelPath: string +): Promise { + const absolute = path.resolve(vaultRoot, vaultRelPath) + const rootPrefix = path.resolve(vaultRoot) + path.sep + if (!absolute.startsWith(rootPrefix)) { + throw new Error(`Asset path escapes the vault: ${vaultRelPath}`) + } + return fs.readFile(absolute) +} + +/** + * Publish (or re-publish) a note. Pre-renders the TikZ sources with the + * shared WASM renderer, reads asset bytes, uploads everything in one + * multipart request, and falls back to a fresh create when the share + * was deleted server-side (PUT → 404). + */ +export async function publishShare( + context: SharePublishContext, + request: SharePublishRequest +): Promise { + const client = await requireShareClient(context) + + const tikzSvgs: { hash: string; svg: string }[] = [] + for (const source of request.tikzSources) { + const rendered = await renderTikz(source) + if (rendered.ok && rendered.svg) { + tikzSvgs.push({ hash: tikzHash(source), svg: rendered.svg }) + } + // Failed renders are dropped — the viewer shows the raw source. + } + + const assets: ShareUploadAsset[] = [] + for (const asset of request.assets) { + try { + const bytes = await context.readAssetBytes(asset.vaultRelPath) + const extension = path.posix.extname(asset.vaultRelPath).toLowerCase() + assets.push({ ref: asset.ref, bytes, mimeType: MIME_BY_EXTENSION[extension] ?? null }) + } catch { + // A missing file shouldn't sink the publish; the viewer simply + // renders that ref as a broken embed, same as the app would. + } + } + + const body: ShareUploadBody = { + notePath: request.notePath, + title: request.title, + markdown: request.markdown, + tikzSvgs, + assets + } + + if (request.existingShareId != null) { + try { + return await client.updateShare(request.existingShareId, body) + } catch (error) { + // The share was revoked from the website — publish a fresh one. + if (!(error instanceof ShareRequestError && error.status === 404)) throw error + } + } + + return client.createShare(body) +} diff --git a/apps/desktop/src/main/tikz-hash.test.ts b/apps/desktop/src/main/tikz-hash.test.ts new file mode 100644 index 00000000..12590494 --- /dev/null +++ b/apps/desktop/src/main/tikz-hash.test.ts @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { tikzHash } from './tikz' + +describe('tikzHash', () => { + it('is sha1-hex of the raw fence body (the viewer-side contract)', () => { + const source = '\\begin{tikzpicture}\\draw (0,0) -- (1,1);\\end{tikzpicture}' + + expect(tikzHash(source)).toBe(createHash('sha1').update(source).digest('hex')) + expect(tikzHash(source)).toMatch(/^[0-9a-f]{40}$/) + }) + + it('does not trim or normalize the source', () => { + expect(tikzHash(' a ')).not.toBe(tikzHash('a')) + expect(tikzHash('a\n')).not.toBe(tikzHash('a')) + }) +}) diff --git a/apps/desktop/src/main/tikz.ts b/apps/desktop/src/main/tikz.ts index 62a23b93..856b2d7f 100644 --- a/apps/desktop/src/main/tikz.ts +++ b/apps/desktop/src/main/tikz.ts @@ -49,6 +49,16 @@ const inFlight = new Map>(); const CACHE_LIMIT = 200; function cacheKey(source: string): string { + return tikzHash(source); +} + +/** + * Content hash of a raw ```tikz fence body. This is the shared contract + * between share publishing (main pre-renders SVGs keyed by this hash) + * and the share viewer (which hashes `data-tikz-source` to look them + * up), so it must stay sha1-hex of the unmodified source. + */ +export function tikzHash(source: string): string { return createHash("sha1").update(source).digest("hex"); } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b7f7e871..1cea1773 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -36,6 +36,11 @@ import type { RemoteWorkspaceProfileInput, ServerCapabilities, ServerSessionStatus, + ShareAccount, + ShareAuthResult, + ShareConnectPending, + SharePublishRequest, + ShareRecord, VaultChangeEvent, VaultDemoTourResult, VaultInfo, @@ -459,7 +464,27 @@ const api: ZenBridge = { raycastInstall: (): Promise => ipcRenderer.invoke(IPC.RAYCAST_INSTALL), clipboardWriteText: (text: string): void => clipboard.writeText(text), - clipboardReadText: (): string => clipboard.readText() + clipboardReadText: (): string => clipboard.readText(), + + shareGetAccount: (): Promise => ipcRenderer.invoke(IPC.SHARE_GET_ACCOUNT), + shareBeginConnect: (): Promise => + ipcRenderer.invoke(IPC.SHARE_BEGIN_CONNECT), + shareSubmitCode: (code: string): Promise => + ipcRenderer.invoke(IPC.SHARE_SUBMIT_CODE, code), + shareDisconnect: (): Promise => ipcRenderer.invoke(IPC.SHARE_DISCONNECT), + shareGetServerUrl: (): Promise => ipcRenderer.invoke(IPC.SHARE_GET_SERVER_URL), + shareSetServerUrl: (url: string): Promise => + ipcRenderer.invoke(IPC.SHARE_SET_SERVER_URL, url), + sharePublish: (request: SharePublishRequest): Promise => + ipcRenderer.invoke(IPC.SHARE_PUBLISH, request), + shareUnpublish: (shareId: number): Promise => + ipcRenderer.invoke(IPC.SHARE_UNPUBLISH, shareId), + shareList: (): Promise => ipcRenderer.invoke(IPC.SHARE_LIST), + onShareAuthResult: (cb: (result: ShareAuthResult) => void): (() => void) => { + const listener = (_: unknown, result: ShareAuthResult): void => cb(result) + ipcRenderer.on(IPC.SHARE_ON_AUTH_RESULT, listener) + return () => ipcRenderer.removeListener(IPC.SHARE_ON_AUTH_RESULT, listener) + } } export type ZenApi = ZenBridge diff --git a/apps/share-viewer/index.html b/apps/share-viewer/index.html new file mode 100644 index 00000000..78401821 --- /dev/null +++ b/apps/share-viewer/index.html @@ -0,0 +1,26 @@ + + + + + + ZenNotes Share Viewer — dev harness + + + + +
Loading…
+ + + diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json new file mode 100644 index 00000000..a16e5b4b --- /dev/null +++ b/apps/share-viewer/package.json @@ -0,0 +1,66 @@ +{ + "name": "@zennotes/share-viewer", + "private": true, + "version": "2.0.8", + "type": "module", + "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", + "homepage": "https://zennotes.org", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "build:nocheck": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "sync": "node scripts/sync-viewer.mjs" + }, + "dependencies": { + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } +} diff --git a/apps/share-viewer/postcss.config.js b/apps/share-viewer/postcss.config.js new file mode 100644 index 00000000..2b75bd8a --- /dev/null +++ b/apps/share-viewer/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/apps/share-viewer/scripts/sync-viewer.mjs b/apps/share-viewer/scripts/sync-viewer.mjs new file mode 100644 index 00000000..5b106dfe --- /dev/null +++ b/apps/share-viewer/scripts/sync-viewer.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +/** + * Copy the built share-viewer bundle into the Laravel website repo. + * + * npm run build -w @zennotes/share-viewer + * npm run sync -w @zennotes/share-viewer -- --out /path/to/laravel/public/vendor/share-viewer + * + * The target can also come from ZENNOTES_LARAVEL_PUBLIC (pointing at the + * Laravel repo's public/ dir or directly at .../vendor/share-viewer). + * Writes a manifest.json with the package version for cache busting. + */ +import { createHash } from 'node:crypto' +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const appRoot = path.resolve(here, '..') +const dist = path.join(appRoot, 'dist') + +function resolveTarget() { + const argIndex = process.argv.indexOf('--out') + if (argIndex !== -1 && process.argv[argIndex + 1]) { + return path.resolve(process.argv[argIndex + 1]) + } + const env = process.env.ZENNOTES_LARAVEL_PUBLIC + if (env) { + const base = path.resolve(env) + return base.endsWith(path.join('vendor', 'share-viewer')) + ? base + : path.join(base, 'vendor', 'share-viewer') + } + return null +} + +const target = resolveTarget() +if (!target) { + console.error( + 'No target. Pass --out or set ZENNOTES_LARAVEL_PUBLIC to the Laravel public/ directory.' + ) + process.exit(1) +} + +if (!existsSync(path.join(dist, 'share-viewer.js'))) { + console.error(`No build found at ${dist}. Run: npm run build -w @zennotes/share-viewer`) + process.exit(1) +} + +// Suffix the version with a content hash so the Blade page's ?v= +// query changes on every rebuild, not just on version bumps. +const packageVersion = JSON.parse(readFileSync(path.join(appRoot, 'package.json'), 'utf8')).version +const contentHash = createHash('sha256') + .update(readFileSync(path.join(dist, 'share-viewer.js'))) + .update(readFileSync(path.join(dist, 'share-viewer.css'))) + .digest('hex') + .slice(0, 8) +const version = `${packageVersion}-${contentHash}` + +rmSync(target, { recursive: true, force: true }) +mkdirSync(target, { recursive: true }) + +for (const entry of ['share-viewer.js', 'share-viewer.css', 'assets']) { + const source = path.join(dist, entry) + if (existsSync(source)) { + cpSync(source, path.join(target, entry), { recursive: true }) + } +} + +writeFileSync( + path.join(target, 'manifest.json'), + JSON.stringify({ version, syncedAt: new Date().toISOString() }, null, 2) +) + +console.log(`share-viewer ${version} → ${target}`) diff --git a/apps/share-viewer/src/env.d.ts b/apps/share-viewer/src/env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/apps/share-viewer/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/share-viewer/src/main.tsx b/apps/share-viewer/src/main.tsx new file mode 100644 index 00000000..c2788b32 --- /dev/null +++ b/apps/share-viewer/src/main.tsx @@ -0,0 +1,144 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import type { AssetMeta, ImportedAssetKind, NoteContent, VaultInfo } from '@shared/ipc' +import { readSharePagePayload, type SharePagePayload } from './payload' +import { installShareViewerBridge } from './shim' +// The full app stylesheet (prose, themes, KaTeX, highlight, diagram +// chrome) — the same file the PDF export window ships wholesale. +import '@renderer/styles/index.css' + +const payload = readSharePagePayload() +if (payload) { + // The bridge must exist before any app-core module runs. + installShareViewerBridge(payload) + void boot(payload) +} else { + console.error('zen-share-data payload missing or malformed; leaving fallback markup in place.') +} + +async function boot(data: SharePagePayload): Promise { + applyTheme() + + // Imported lazily so the shim is installed before app-core touches + // window.zen, and so the store never boots on malformed pages. + const [{ useStore }, { LazyPreview }] = await Promise.all([ + import('@renderer/store'), + import('@renderer/components/LazyPreview') + ]) + + const notePath = 'shared-note.md' + const note: NoteContent = { + path: notePath, + title: data.title, + folder: 'inbox', + siblingOrder: 0, + createdAt: data.published_at ? Date.parse(data.published_at) : Date.now(), + updatedAt: data.updated_at ? Date.parse(data.updated_at) : Date.now(), + size: data.markdown.length, + tags: [], + wikilinks: [], + hasAttachments: Object.keys(data.assets).length > 0, + excerpt: '', + body: data.markdown + } + + // Asset refs double as vault-relative paths: the publisher uploaded + // each asset under the literal markdown ref, so an identity mapping + // makes app-core's resolver land on exactly those keys. + const assetFiles: AssetMeta[] = Object.keys(data.assets).map((ref, index) => ({ + path: ref, + name: ref.split('/').pop() ?? ref, + kind: assetKindOf(ref), + siblingOrder: index, + size: 0, + updatedAt: 0 + })) + + useStore.setState({ + vault: { root: '/shared', name: 'Shared note' } satisfies VaultInfo, + notes: [], + assetFiles, + selectedPath: notePath, + activeNote: note + }) + + const root = document.getElementById('zen-share-root') + if (!root) return + root.textContent = '' + + ReactDOM.createRoot(root).render( + +
+ +
+
+ ) +} + +/** Fixed light theme, flipping to the dark twin with the OS preference. */ +function applyTheme(): void { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const apply = (): void => { + const html = document.documentElement + html.dataset.theme = media.matches ? 'github-dark' : 'github-light' + html.setAttribute('data-opaque', '') + html.style.colorScheme = media.matches ? 'dark' : 'light' + } + apply() + media.addEventListener('change', apply) + + const style = document.createElement('style') + style.textContent = ` + /* The app stylesheet treats the document as a fixed-viewport app + shell (height: 100%, overflow: hidden, user-select: none). A + public page is a normal scrolling document — undo all three, + same as the PDF export window does. */ + html, body { + height: auto !important; + min-height: 100vh; + margin: 0; + overflow: visible !important; + user-select: text !important; + background: rgb(var(--z-bg)); + } + .zen-share-note { padding: 8px 0 48px; } + .zen-share-note .prose-zen a.wikilink, + .zen-share-note .prose-zen a.wikilink.broken, + .zen-share-note .prose-zen a.hashtag { + color: rgb(var(--z-grey-1)); + border-bottom: 1px dashed rgb(var(--z-grey-dim)); + text-decoration: none; + pointer-events: none; + cursor: default; + } + .zen-share-note .prose-zen input[type="checkbox"] { + pointer-events: none; + } + ` + document.head.appendChild(style) +} + +/** + * Wikilinks, hashtags, and task checkboxes act on the vault in-app; on + * a public page they are inert text. CSS removes the affordances; this + * pass drops the zen:// hrefs and locks checkboxes for good measure. + */ +function neutralizeAppOnlyInteractions(): void { + const root = document.getElementById('zen-share-root') + if (!root) return + for (const anchor of root.querySelectorAll('a.wikilink, a.hashtag')) { + anchor.removeAttribute('href') + } + for (const checkbox of root.querySelectorAll('input[type="checkbox"]')) { + checkbox.disabled = true + } +} + +function assetKindOf(ref: string): ImportedAssetKind { + const ext = ref.toLowerCase().split('.').pop() ?? '' + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'apng'].includes(ext)) return 'image' + if (ext === 'pdf') return 'pdf' + if (['mp3', 'm4a', 'aac', 'flac', 'ogg', 'wav'].includes(ext)) return 'audio' + if (['mp4', 'm4v', 'mov', 'ogv', 'webm'].includes(ext)) return 'video' + return 'file' +} diff --git a/apps/share-viewer/src/payload.ts b/apps/share-viewer/src/payload.ts new file mode 100644 index 00000000..a191d39e --- /dev/null +++ b/apps/share-viewer/src/payload.ts @@ -0,0 +1,35 @@ +/** The JSON document the Laravel share page embeds in #zen-share-data. */ +export interface SharePagePayload { + title: string + markdown: string + /** Markdown ref (decoded) → absolute public URL. */ + assets: Record + /** sha1(raw tikz fence body) → pre-rendered SVG. */ + tikz: Record + published_at: string | null + updated_at: string | null +} + +export function readSharePagePayload(): SharePagePayload | null { + const el = document.getElementById('zen-share-data') + if (!el?.textContent) return null + try { + const parsed = JSON.parse(el.textContent) as Partial + if (typeof parsed.markdown !== 'string') return null + return { + title: typeof parsed.title === 'string' ? parsed.title : 'Untitled', + markdown: parsed.markdown, + assets: isStringRecord(parsed.assets) ? parsed.assets : {}, + tikz: isStringRecord(parsed.tikz) ? parsed.tikz : {}, + published_at: typeof parsed.published_at === 'string' ? parsed.published_at : null, + updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : null + } + } catch { + return null + } +} + +function isStringRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + return Object.values(value).every((entry) => typeof entry === 'string') +} diff --git a/apps/share-viewer/src/shim.ts b/apps/share-viewer/src/shim.ts new file mode 100644 index 00000000..2ccde0ba --- /dev/null +++ b/apps/share-viewer/src/shim.ts @@ -0,0 +1,175 @@ +import DOMPurify from 'dompurify' +import type { ZenAppInfo, ZenBridge, ZenCapabilities } from '@bridge-contract/bridge' +import type { TikzRenderResponse } from '@shared/ipc' +import appPackage from '../package.json' +import type { SharePagePayload } from './payload' + +const VIEWER_CAPABILITIES: ZenCapabilities = { + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: false, + supportsRemoteWorkspace: false, + supportsCliInstall: false, + supportsCustomTemplates: false +} + +const VIEWER_APP_INFO: ZenAppInfo = { + name: 'zennotes-share-viewer', + productName: 'ZenNotes', + version: appPackage.version, + description: 'Read-only viewer for shared ZenNotes', + homepage: 'https://zennotes.org', + runtime: 'web' +} + +/** + * sha1 hex matching Node's createHash('sha1') output. WebCrypto when + * available; plain-JS fallback because crypto.subtle only exists in + * secure contexts and local dev serves over plain http (zennotes.test). + */ +async function sha1Hex(input: string): Promise { + if (typeof crypto !== 'undefined' && crypto.subtle) { + const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(input)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') + } + return sha1HexSync(input) +} + +function sha1HexSync(input: string): string { + const bytes = new TextEncoder().encode(input) + const byteLength = bytes.length + const totalLength = Math.ceil((byteLength + 9) / 64) * 64 + const padded = new Uint8Array(totalLength) + padded.set(bytes) + padded[byteLength] = 0x80 + const view = new DataView(padded.buffer) + view.setUint32(totalLength - 8, Math.floor((byteLength * 8) / 0x100000000)) + view.setUint32(totalLength - 4, (byteLength * 8) >>> 0) + + let h0 = 0x67452301 + let h1 = 0xefcdab89 + let h2 = 0x98badcfe + let h3 = 0x10325476 + let h4 = 0xc3d2e1f0 + const words = new Uint32Array(80) + const rotl = (x: number, n: number): number => ((x << n) | (x >>> (32 - n))) >>> 0 + + for (let offset = 0; offset < totalLength; offset += 64) { + for (let i = 0; i < 16; i += 1) words[i] = view.getUint32(offset + i * 4) + for (let i = 16; i < 80; i += 1) { + words[i] = rotl(words[i - 3]! ^ words[i - 8]! ^ words[i - 14]! ^ words[i - 16]!, 1) + } + let a = h0 + let b = h1 + let c = h2 + let d = h3 + let e = h4 + for (let i = 0; i < 80; i += 1) { + let f: number + let k: number + if (i < 20) { + f = (b & c) | (~b & d) + k = 0x5a827999 + } else if (i < 40) { + f = b ^ c ^ d + k = 0x6ed9eba1 + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d) + k = 0x8f1bbcdc + } else { + f = b ^ c ^ d + k = 0xca62c1d6 + } + const next = (rotl(a, 5) + (f >>> 0) + e + k + words[i]!) >>> 0 + e = d + d = c + c = rotl(b, 30) + b = a + a = next + } + h0 = (h0 + a) >>> 0 + h1 = (h1 + b) >>> 0 + h2 = (h2 + c) >>> 0 + h3 = (h3 + d) >>> 0 + h4 = (h4 + e) >>> 0 + } + + return [h0, h1, h2, h3, h4].map((part) => part.toString(16).padStart(8, '0')).join('') +} + +function decodeRef(href: string): string { + const cleaned = href.split('#')[0]?.split('?')[0] ?? href + try { + return decodeURIComponent(cleaned) + } catch { + return cleaned + } +} + +function lookupAsset(payload: SharePagePayload, href: string): string | null { + const direct = payload.assets[href] + if (direct) return direct + const decoded = payload.assets[decodeRef(href)] + return decoded ?? null +} + +/** + * Install a minimal `window.zen` so app-core's Preview pipeline renders + * a shared note exactly like the app does: + * + * - `renderTikz` substitutes the pre-rendered (and sanitized) SVG the + * publisher uploaded, keyed by sha1 of the fence body. + * - asset URL resolution maps markdown refs onto the share's public + * asset URLs. + * - everything else is inert — this is a read-only page. + */ +export function installShareViewerBridge(payload: SharePagePayload): void { + const overrides: Partial = { + getCapabilities: () => VIEWER_CAPABILITIES, + getAppInfo: () => VIEWER_APP_INFO, + platformSync: () => 'linux' as NodeJS.Platform, + platform: async () => 'linux' as NodeJS.Platform, + + renderTikz: async (source: string): Promise => { + const svg = payload.tikz[await sha1Hex(source)] + if (!svg) { + return { ok: false, error: 'This TikZ diagram is not available on the shared page.' } + } + const sanitized = DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true } + }) + return { ok: true, svg: sanitized } + }, + + resolveVaultAssetUrl: (_vaultRoot: string, assetPath: string): string | null => + lookupAsset(payload, assetPath), + resolveLocalAssetUrl: (_vaultRoot: string, _notePath: string, href: string): string | null => + lookupAsset(payload, href), + getPathForFile: () => null, + + clipboardWriteText: (text: string): void => { + void navigator.clipboard?.writeText(text) + }, + clipboardReadText: (): string => '' + } + + const inert = (name: PropertyKey): unknown => { + // Unknown bridge calls resolve harmlessly; the viewer never mutates. + return () => { + console.warn(`zen.${String(name)} is not available on shared pages`) + return Promise.resolve(undefined) + } + } + + const bridge = new Proxy(overrides as ZenBridge, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + if (value !== undefined) return value + if (prop === 'then') return undefined + return inert(prop) + } + }) + + window.zen = bridge +} diff --git a/apps/share-viewer/tailwind.config.js b/apps/share-viewer/tailwind.config.js new file mode 100644 index 00000000..9538f269 --- /dev/null +++ b/apps/share-viewer/tailwind.config.js @@ -0,0 +1,50 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + paper: { + 50: 'rgb(var(--z-bg-softer) / )', + 100: 'rgb(var(--z-bg) / )', + 200: 'rgb(var(--z-bg-1) / )', + 300: 'rgb(var(--z-bg-2) / )', + 400: 'rgb(var(--z-bg-3) / )', + 500: 'rgb(var(--z-bg-4) / )' + }, + ink: { + 900: 'rgb(var(--z-fg) / )', + 800: 'rgb(var(--z-fg-1) / )', + 700: 'rgb(var(--z-fg-2) / )', + 600: 'rgb(var(--z-grey-2) / )', + 500: 'rgb(var(--z-grey-1) / )', + 400: 'rgb(var(--z-grey-0) / )', + 300: 'rgb(var(--z-grey-dim) / )' + }, + accent: { + DEFAULT: 'rgb(var(--z-accent) / )', + soft: 'rgb(var(--z-accent-soft) / )', + muted: 'rgb(var(--z-accent-muted) / )' + } + }, + fontFamily: { + sans: [ + '-apple-system', + 'BlinkMacSystemFont', + '"SF Pro Text"', + '"Inter"', + 'system-ui', + 'sans-serif' + ], + serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], + mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] + }, + boxShadow: { + panel: + '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', + float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' + } + } + }, + plugins: [] +} diff --git a/apps/share-viewer/tsconfig.json b/apps/share-viewer/tsconfig.json new file mode 100644 index 00000000..eb0fa1d9 --- /dev/null +++ b/apps/share-viewer/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "useDefineForClassFields": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@renderer/*": ["../../packages/app-core/src/*"], + "@shared/*": ["../../packages/shared-domain/src/*"], + "@bridge-contract/*": ["../../packages/bridge-contract/src/*"], + "@zennotes/app-core/*": ["../../packages/app-core/src/*"], + "@zennotes/bridge-contract/*": ["../../packages/bridge-contract/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/share-viewer/vite.config.ts b/apps/share-viewer/vite.config.ts new file mode 100644 index 00000000..149ee94c --- /dev/null +++ b/apps/share-viewer/vite.config.ts @@ -0,0 +1,77 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +function viewerManualChunk(id: string): string | undefined { + if (!id.includes('node_modules')) return undefined + + if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/zustand/')) { + return 'vendor-react' + } + if ( + id.includes('/remark-') || + id.includes('/rehype-') || + id.includes('/unified/') || + id.includes('/unist-util-visit/') || + id.includes('/gray-matter/') || + id.includes('/katex/') + ) { + return 'vendor-markdown' + } + if (id.includes('/highlight.js/')) { + return 'vendor-highlight' + } + if (id.includes('/mermaid/') || id.includes('/cytoscape/') || id.includes('/dagre/')) { + return 'vendor-mermaid' + } + if (id.includes('/jsxgraph/')) { + return 'vendor-jsxgraph' + } + if (id.includes('/function-plot/')) { + return 'vendor-function-plot' + } + if (id.includes('/d3')) { + return 'vendor-d3' + } + return undefined +} + +// The Laravel share page references exactly two stable filenames — +// share-viewer.js and share-viewer.css (cache-busted by ?v=). Lazy +// chunks keep content hashes and load relative to the entry module. +export default defineConfig({ + root: __dirname, + base: './', + resolve: { + alias: [ + { find: '@renderer', replacement: resolve(__dirname, '../../packages/app-core/src') }, + { find: '@shared', replacement: resolve(__dirname, '../../packages/shared-domain/src') }, + { + find: '@bridge-contract', + replacement: resolve(__dirname, '../../packages/bridge-contract/src') + } + ] + }, + server: { + port: 5179 + }, + plugins: [react()], + build: { + outDir: 'dist', + emptyOutDir: true, + chunkSizeWarningLimit: 3500, + sourcemap: false, + // One stylesheet for the whole viewer (lazy chunks included) so the + // Blade page only ever links share-viewer.css. + cssCodeSplit: false, + rollupOptions: { + output: { + manualChunks: viewerManualChunk, + entryFileNames: 'share-viewer.js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: (info) => + info.name?.endsWith('.css') ? 'share-viewer.css' : 'assets/[name]-[hash][extname]' + } + } + } +}) diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 9917a04d..b405d934 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -48,6 +48,11 @@ import type { RemoteWorkspaceProfileInput, ServerCapabilities, ServerSessionStatus, + ShareAccount, + ShareAuthResult, + ShareConnectPending, + SharePublishRequest, + ShareRecord, VaultSettings, TikzRenderResponse, VaultChangeEvent, @@ -58,6 +63,7 @@ import type { VaultTextSearchMatch, VaultTextSearchToolPaths } from '@shared/ipc' +import { DEFAULT_SHARE_SERVER_URL } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' import type { McpClientId, @@ -1042,6 +1048,51 @@ function clipboardReadText(): string { return '' } +// -------------------------------------------------------------------- +// Note sharing — desktop only in v1; the web build reports disconnected +// and rejects every mutating call. +// -------------------------------------------------------------------- + +async function shareGetAccount(): Promise { + return { connected: false, name: null, email: null, serverUrl: DEFAULT_SHARE_SERVER_URL } +} + +async function shareBeginConnect(): Promise { + return notImplemented('shareBeginConnect') +} + +async function shareSubmitCode(_code: string): Promise { + return notImplemented('shareSubmitCode') +} + +async function shareDisconnect(): Promise { + return notImplemented('shareDisconnect') +} + +async function shareGetServerUrl(): Promise { + return DEFAULT_SHARE_SERVER_URL +} + +async function shareSetServerUrl(_url: string): Promise { + return notImplemented('shareSetServerUrl') +} + +async function sharePublish(_request: SharePublishRequest): Promise { + return notImplemented('sharePublish') +} + +async function shareUnpublish(_shareId: number): Promise { + notImplemented('shareUnpublish') +} + +async function shareList(): Promise { + return [] +} + +function onShareAuthResult(_cb: (result: ShareAuthResult) => void): () => void { + return () => {} +} + // -------------------------------------------------------------------- // Assemble the `zen` API object // -------------------------------------------------------------------- @@ -1165,7 +1216,18 @@ export const httpBridge: ZenBridge = { raycastGetStatus, raycastInstall, clipboardWriteText, - clipboardReadText + clipboardReadText, + + shareGetAccount, + shareBeginConnect, + shareSubmitCode, + shareDisconnect, + shareGetServerUrl, + shareSetServerUrl, + sharePublish, + shareUnpublish, + shareList, + onShareAuthResult } export function installBridge(): void { diff --git a/package-lock.json b/package-lock.json index f94de17e..665aec24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,77 @@ "name": "@zennotes/server", "version": "2.0.8" }, + "apps/share-viewer": { + "name": "@zennotes/share-viewer", + "version": "2.0.8", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } + }, + "apps/share-viewer/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "apps/share-viewer/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "apps/web": { "name": "@zennotes/web", "version": "2.0.8", @@ -4139,6 +4210,10 @@ "resolved": "apps/server", "link": true }, + "node_modules/@zennotes/share-viewer": { + "resolved": "apps/share-viewer", + "link": true + }, "node_modules/@zennotes/shared-domain": { "resolved": "packages/shared-domain", "link": true diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index c5bb905e..38f03158 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -314,6 +314,25 @@ function App(): JSX.Element { }) }, []) + // Browser connect handoff: a zennotes://auth deep link completed (or + // failed) the share-account exchange in the main process. + useEffect(() => { + void useStore.getState().refreshShareAccount() + return window.zen.onShareAuthResult((result) => { + void useStore.getState().refreshShareAccount() + if (result.ok) { + const email = result.account?.email + window.alert( + email + ? `ZenNotes account connected as ${email}. You can now share notes with :share.` + : 'ZenNotes account connected. You can now share notes with :share.' + ) + } else if (result.error) { + window.alert(result.error) + } + }) + }, []) + useEffect(() => { window.zen.notifyRendererReady() }, []) diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 5ebd16e5..393a7ee9 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -742,6 +742,18 @@ function registerVimNoteCommands(): void { void useStore.getState().openTrashView() }) + // Note sharing — publish (or update) the active note, copy its public + // link, or take it down. Sharing is desktop-only in v1. + Vim.defineEx('share', 'share', () => { + void useStore.getState().shareActiveNote() + }) + Vim.defineEx('unshare', 'unshare', () => { + void useStore.getState().unshareActiveNote() + }) + Vim.defineEx('sharelink', 'sharelink', () => { + void useStore.getState().copyShareLink() + }) + // Heading fold helpers — wrap CodeMirror's commands so they work on // whichever pane currently owns the editor. `:fold` / `:unfold` act // on the current heading; `:foldall` / `:unfoldall` cover the whole @@ -840,7 +852,10 @@ const MANUAL_EX_NAMES = new Set([ 'wall', 'wa', 'help', - 'h' + 'h', + 'share', + 'unshare', + 'sharelink' ]) function commandIdToExName(id: string): string { diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index d644f393..736d1daa 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -12,6 +12,7 @@ import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { ResizeHandle } from './ResizeHandle' import { confirmMoveToTrash } from '../lib/confirm-trash' import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' +import { readShareFrontmatter } from '../lib/note-frontmatter' import { extractTags } from '../lib/tags' import { setDragPayload } from '../lib/dnd' import { promptApp } from '../lib/prompt-requests' @@ -239,6 +240,30 @@ export function NoteList(): JSX.Element { if (canRevealInFileManager) { items.push({ label: 'Reveal in File Manager', onSelect: onReveal }) } + + // Note sharing (desktop-only in v1). Shared state is read from the + // note body when it's loaded; the share actions re-read from disk + // either way, so acting on a never-opened note works too. + if (n.folder !== 'trash' && window.zen.getAppInfo().runtime === 'desktop') { + const body = useStore.getState().noteContents[n.path]?.body + const shared = !!body && readShareFrontmatter(body).shareId !== null + items.push({ kind: 'separator' }) + items.push({ + label: shared ? 'Update Shared Note…' : 'Share Note…', + onSelect: async () => useStore.getState().shareActiveNote(n.path) + }) + if (shared) { + items.push({ + label: 'Copy Share Link', + onSelect: async () => useStore.getState().copyShareLink(n.path) + }) + items.push({ + label: 'Stop Sharing', + danger: true, + onSelect: async () => useStore.getState().unshareActiveNote(n.path) + }) + } + } items.push({ kind: 'separator' }) if (n.folder === 'inbox' || n.folder === 'quick') { diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index dbfa5430..7aa3cd4f 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -58,6 +58,7 @@ type SettingsCategoryId = | 'typography' | 'vault' | 'templates' + | 'sharing' | 'mcp' | 'cli' | 'about' @@ -1880,6 +1881,38 @@ export function SettingsModal(): JSX.Element { ) }, + { + id: 'sharing', + title: 'Sharing', + description: 'Publish notes to zennotes.org and manage the connected account.', + keywords: [ + 'share', + 'sharing', + 'publish', + 'public', + 'link', + 'url', + 'account', + 'connect', + 'zennotes.org', + 'server' + ], + searchItems: [ + { + id: 'sharing-account', + title: 'ZenNotes account', + description: 'Connect the account that shared notes are published under.', + keywords: ['share', 'account', 'connect', 'login', 'disconnect'] + }, + { + id: 'sharing-server', + title: 'Share server', + description: 'Where shared notes are published (self-hosters can point elsewhere).', + keywords: ['share', 'server', 'url', 'self-host'] + } + ], + content: + }, { id: 'mcp', title: 'MCP', @@ -3327,6 +3360,214 @@ function SegmentedRow({ ) } +function SharingSettings(): JSX.Element { + const shareAccount = useStore((s) => s.shareAccount) + const refreshShareAccount = useStore((s) => s.refreshShareAccount) + const connectShareAccount = useStore((s) => s.connectShareAccount) + const disconnectShareAccount = useStore((s) => s.disconnectShareAccount) + const setShareServerUrl = useStore((s) => s.setShareServerUrl) + + const isDesktop = window.zen.getAppInfo().runtime === 'desktop' + const [code, setCode] = useState('') + const [serverUrl, setServerUrl] = useState('') + const [serverUrlLoaded, setServerUrlLoaded] = useState(false) + const [busy, setBusy] = useState(false) + const [waitingForBrowser, setWaitingForBrowser] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!isDesktop) return + void refreshShareAccount() + void window.zen.shareGetServerUrl().then((url) => { + setServerUrl(url) + setServerUrlLoaded(true) + }) + }, [isDesktop, refreshShareAccount]) + + // The browser handoff completes out-of-band; reflect it live. + useEffect(() => { + if (!isDesktop) return + return window.zen.onShareAuthResult((result) => { + setWaitingForBrowser(false) + if (result.ok) { + setError(null) + setCode('') + } else if (result.error) { + setError(result.error) + } + }) + }, [isDesktop]) + + if (!isDesktop) { + return ( +
+
+ Note sharing is available in the ZenNotes desktop app. +
+
+ ) + } + + const connected = shareAccount?.connected ?? false + const chip = connected + ? { label: 'Connected', tone: 'ok' as const } + : { label: 'Not connected', tone: 'off' as const } + + const onConnect = async (): Promise => { + setError(null) + setWaitingForBrowser(true) + await connectShareAccount() + } + + const onSubmitCode = async (): Promise => { + const trimmed = code.trim() + if (!trimmed) return + setBusy(true) + setError(null) + try { + await window.zen.shareSubmitCode(trimmed) + await refreshShareAccount() + setCode('') + setWaitingForBrowser(false) + } catch (err) { + setError( + ((err as Error).message ?? '').replace( + /^Error invoking remote method '[^']+':\s*(?:Error:\s*)?/, + '' + ) + ) + } finally { + setBusy(false) + } + } + + const onSaveServerUrl = async (): Promise => { + setBusy(true) + try { + await setShareServerUrl(serverUrl) + const next = await window.zen.shareGetServerUrl() + setServerUrl(next) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+
+
+
+ + {connected ? (shareAccount?.name ?? 'Connected') : 'zennotes.org'} + + + {chip.label} + +
+
+ {connected + ? (shareAccount?.email ?? 'Your account is connected.') + : 'Connect in the browser — sign in (or create an account), approve the app, and you land back here.'} +
+ {error &&
{error}
} +
+
+ {connected ? ( + + ) : ( + + )} +
+
+ + {!connected && waitingForBrowser && ( +
+ setCode(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void onSubmitCode() + }} + placeholder="If the app didn't open, paste the one-time code here" + className="min-w-0 flex-1 rounded-xl border border-paper-300/70 bg-paper-50/75 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:border-accent/45" + /> + +
+ )} +
+
+ +
+
+ setServerUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void onSaveServerUrl() + }} + placeholder="https://zennotes.org" + disabled={!serverUrlLoaded} + className="min-w-0 flex-1 rounded-xl border border-paper-300/70 bg-paper-50/75 px-3 py-2 font-mono text-xs text-ink-900 outline-none placeholder:text-ink-400 focus:border-accent/45" + /> + +
+
+
+ ) +} + function CliSettings(): JSX.Element { const [status, setStatus] = useState(null) const [busy, setBusy] = useState(false) diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index 99149024..ae3220ad 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -1,8 +1,10 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { useStore } from '../store' -import type { NoteContent, NoteMeta } from '@shared/ipc' +import type { NoteContent, NoteMeta, ShareRecord } from '@shared/ipc' import { backlinksForNote } from '../lib/wikilinks' import { countWords } from '../lib/word-count' +import { readShareFrontmatter } from '../lib/note-frontmatter' /** * Footer strip showing quick stats for the active note: backlinks, @@ -28,11 +30,14 @@ export function StatusBar({ note }: { note: NoteContent }): JSX.Element { return backlinksForNote(notes as NoteMeta[], note).length }, [note, notes]) + const share = useMemo(() => readShareFrontmatter(note.body), [note.body]) + return (
+ {share.shareId !== null && } {backlinks} {backlinks === 1 ? 'backlink' : 'backlinks'} @@ -45,6 +50,239 @@ export function StatusBar({ note }: { note: NoteContent }): JSX.Element { ) } +/** + * Status-bar chip for shared notes. Clicking it opens a popover with + * the public link, live server stats (views, last publish), and the + * share actions — the mouse-friendly twin of :share / :sharelink / + * :unshare. + */ +function SharedChip({ + note, + shareId, + shareUrl +}: { + note: NoteContent + shareId: string + shareUrl: string | null +}): JSX.Element { + const [open, setOpen] = useState(false) + const chipRef = useRef(null) + + return ( + <> + + {open && ( + setOpen(false)} + /> + )} + + ) +} + +function SharePopover({ + anchor, + note, + shareId, + shareUrl, + onClose +}: { + anchor: HTMLButtonElement | null + note: NoteContent + shareId: string + shareUrl: string | null + onClose: () => void +}): JSX.Element { + const ref = useRef(null) + const [record, setRecord] = useState(null) + const [serverState, setServerState] = useState<'loading' | 'loaded' | 'missing' | 'error'>( + 'loading' + ) + const [copied, setCopied] = useState(false) + + // Anchor above the chip (the status bar sits at the bottom edge). + const position = useMemo(() => { + const rect = anchor?.getBoundingClientRect() + if (!rect) return { right: 16, bottom: 40 } + return { + right: Math.max(8, window.innerWidth - rect.right), + bottom: Math.max(8, window.innerHeight - rect.top + 8) + } + }, [anchor]) + + // Pull live stats for this share from the server, tolerating offline. + useEffect(() => { + let cancelled = false + const numericId = Number(shareId) + void window.zen + .shareList() + .then((records) => { + if (cancelled) return + const match = records.find((entry) => entry.id === numericId) ?? null + setRecord(match) + setServerState(match ? 'loaded' : 'missing') + }) + .catch(() => { + if (!cancelled) setServerState('error') + }) + return () => { + cancelled = true + } + }, [shareId]) + + useEffect(() => { + const onDown = (e: MouseEvent): void => { + if (ref.current && !ref.current.contains(e.target as Node) && e.target !== anchor) { + onClose() + } + } + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + window.addEventListener('mousedown', onDown) + window.addEventListener('keydown', onKey, true) + window.addEventListener('blur', onClose) + return () => { + window.removeEventListener('mousedown', onDown) + window.removeEventListener('keydown', onKey, true) + window.removeEventListener('blur', onClose) + } + }, [anchor, onClose]) + + const url = record?.url ?? shareUrl + + const copyLink = (): void => { + if (!url) return + window.zen.clipboardWriteText(url) + setCopied(true) + window.setTimeout(() => setCopied(false), 1200) + } + + const runAndClose = (action: (path?: string) => Promise): void => { + onClose() + void action(note.path) + } + + const state = useStore.getState() + + return createPortal( +
+
+
+ + Shared note + + + {serverState === 'loading' && '…'} + {serverState === 'loaded' && + `${(record?.viewCount ?? 0).toLocaleString()} ${record?.viewCount === 1 ? 'view' : 'views'}`} + +
+ {url && ( + + )} +
+ {serverState === 'loaded' && record?.updatedAt && ( + <>Last published {relativeTime(record.updatedAt)} + )} + {serverState === 'missing' && ( + + Not on the server anymore — Update Share republishes it. + + )} + {serverState === 'error' && 'Could not reach the share server for stats.'} +
+
+ +
+ + + runAndClose(state.openShareInBrowser)} + /> + runAndClose(state.shareActiveNote)} /> +
+ runAndClose(state.unshareActiveNote)} + /> +
, + document.body + ) +} + +function PopoverAction({ + label, + danger, + onSelect +}: { + label: string + danger?: boolean + onSelect: () => void +}): JSX.Element { + return ( + + ) +} + +/** Compact "3m ago" / "2h ago" formatter for the popover stats line. */ +function relativeTime(iso: string): string { + const then = Date.parse(iso) + if (!Number.isFinite(then)) return '' + const seconds = Math.max(0, Math.round((Date.now() - then) / 1000)) + if (seconds < 60) return 'just now' + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.round(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.round(hours / 24) + if (days < 30) return `${days}d ago` + return new Date(then).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} + function Stat({ children }: { children: React.ReactNode }): JSX.Element { return {children} } diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 1de21cd0..bc02320a 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -9,6 +9,7 @@ import { isTagsViewActive, isTasksViewActive, isTrashViewActive, useStore } from '../store' import { confirmApp } from './confirm-requests' import { promptApp } from './prompt-requests' +import { readShareFrontmatter } from './note-frontmatter' import { buildMoveNotePrompt, parseMoveNoteTarget } from './move-note' import { focusPaneInDirection } from './pane-nav' import { findLeaf } from './pane-layout' @@ -71,6 +72,12 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const openExternal = (url: string): void => { window.open(url, '_blank') } + // Sharing publishes through the main process; desktop-only in v1. + const sharingAvailable = (): boolean => window.zen.getAppInfo().runtime === 'desktop' + const activeNoteIsShared = (): boolean => { + const body = getState().activeNote?.body + return !!body && readShareFrontmatter(body).shareId !== null + } const cmds: Command[] = [] /* ---------------- Note actions ---------------- */ @@ -253,6 +260,38 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma await getState().exportActiveNotePdf() } }, + { + id: 'share.publish', + title: 'Share Note…', + category: 'Note', + keywords: 'publish share link public web url', + when: () => !!getState().activeNote && sharingAvailable(), + run: () => getState().shareActiveNote() + }, + { + id: 'share.copy-link', + title: 'Copy Share Link', + category: 'Note', + keywords: 'share url clipboard public', + when: () => activeNoteIsShared() && sharingAvailable(), + run: () => getState().copyShareLink() + }, + { + id: 'share.open', + title: 'Open Share in Browser', + category: 'Note', + keywords: 'share web public view', + when: () => activeNoteIsShared() && sharingAvailable(), + run: () => getState().openShareInBrowser() + }, + { + id: 'share.unpublish', + title: 'Stop Sharing Note', + category: 'Note', + keywords: 'unshare unpublish delete share private', + when: () => activeNoteIsShared() && sharingAvailable(), + run: () => getState().unshareActiveNote() + }, { id: 'note.copy-path', title: 'Copy Note Path', diff --git a/packages/app-core/src/lib/local-assets.ts b/packages/app-core/src/lib/local-assets.ts index c44b4dc7..a2c86abd 100644 --- a/packages/app-core/src/lib/local-assets.ts +++ b/packages/app-core/src/lib/local-assets.ts @@ -99,7 +99,21 @@ export function resolveAssetVaultRelativePath( notePath: string | null | undefined, href: string ): string | null { - if (!vaultRoot || !notePath) return null + if (!vaultRoot) return null + return resolveAssetVaultRelativePathIn(useStore.getState().assetFiles, notePath, href) +} + +/** + * Pure variant of `resolveAssetVaultRelativePath` that resolves against + * an explicit asset list instead of the store. Used by code that runs + * outside the live app state (share payload assembly, tests). + */ +export function resolveAssetVaultRelativePathIn( + assets: ReadonlyArray<{ path: string }>, + notePath: string | null | undefined, + href: string +): string | null { + if (!notePath) return null const trimmed = href.trim() if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) return null @@ -114,7 +128,6 @@ export function resolveAssetVaultRelativePath( target = posixNormalize(target) if (target.startsWith('../') || target === '..') return null - const assets = useStore.getState().assetFiles if (assets.some((asset) => asset.path === target)) return target const targetBase = target.split('/').filter(Boolean).pop()?.toLowerCase() @@ -131,6 +144,16 @@ export function resolveAssetVaultRelativePath( return null } +/** + * Decode a markdown asset href into the canonical form used as a share + * asset ref: query/hash stripped and percent-escapes decoded. The share + * viewer applies the same decoding to rendered `src`/`href` attributes + * so refs match on both sides. + */ +export function canonicalAssetRef(href: string): string { + return decodeHrefPath(href.trim()) +} + function localAssetLabel(href: string, fallback: string): string { const clean = href.split('#')[0]?.split('?')[0] ?? href const parts = clean.split('/').filter(Boolean) diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index b026ced2..b74e400b 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -69,7 +69,9 @@ function sanitizeRenderedHtml(html: string): string { }) } -function remarkWikilinks() { +// Exported so share-payload assembly can parse with the exact same +// wikilink semantics the preview renders with. +export function remarkWikilinks() { function buildWikilinkNode(bang: string, target: string, label: string): AnyNode { const assetKind = classifyLocalAssetHref(target) if (bang === '!' && assetKind === 'image') { diff --git a/packages/app-core/src/lib/note-frontmatter.test.ts b/packages/app-core/src/lib/note-frontmatter.test.ts new file mode 100644 index 00000000..7471e9b0 --- /dev/null +++ b/packages/app-core/src/lib/note-frontmatter.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { + readShareFrontmatter, + removeShareFrontmatter, + stripFrontmatter, + upsertShareFrontmatter +} from './note-frontmatter' + +const SHARE = { shareId: '42', shareUrl: 'https://zennotes.org/s/abc123def456' } + +describe('readShareFrontmatter', () => { + it('returns nulls when there is no frontmatter', () => { + expect(readShareFrontmatter('# Hello\n')).toEqual({ shareId: null, shareUrl: null }) + }) + + it('reads share keys from an existing block', () => { + const body = '---\ntitle: Note\nshare_id: 42\nshare_url: https://x.test/s/a\n---\n# Hi\n' + expect(readShareFrontmatter(body)).toEqual({ + shareId: '42', + shareUrl: 'https://x.test/s/a' + }) + }) + + it('unquotes quoted values', () => { + const body = '---\nshare_id: "42"\nshare_url: \'https://x.test/s/a\'\n---\nBody\n' + expect(readShareFrontmatter(body)).toEqual({ + shareId: '42', + shareUrl: 'https://x.test/s/a' + }) + }) + + it('ignores share-like keys outside the leading block', () => { + const body = '# Title\n\n---\nshare_id: 42\n---\n' + expect(readShareFrontmatter(body)).toEqual({ shareId: null, shareUrl: null }) + }) +}) + +describe('upsertShareFrontmatter', () => { + it('creates a frontmatter block when the note has none', () => { + const next = upsertShareFrontmatter('# Hello\n\nBody.\n', SHARE) + expect(next).toBe( + '---\nshare_id: 42\nshare_url: https://zennotes.org/s/abc123def456\n---\n# Hello\n\nBody.\n' + ) + }) + + it('appends keys to an existing block without touching other lines', () => { + const body = '---\ntitle: My Note\ntags: [a, b]\n---\n# Hello\n' + const next = upsertShareFrontmatter(body, SHARE) + expect(next).toBe( + '---\ntitle: My Note\ntags: [a, b]\nshare_id: 42\nshare_url: https://zennotes.org/s/abc123def456\n---\n# Hello\n' + ) + }) + + it('replaces existing share keys in place', () => { + const body = '---\nshare_id: old\ntitle: Keep\nshare_url: https://old.test\n---\nBody\n' + const next = upsertShareFrontmatter(body, SHARE) + expect(next).toBe( + '---\nshare_id: 42\ntitle: Keep\nshare_url: https://zennotes.org/s/abc123def456\n---\nBody\n' + ) + }) + + it('is idempotent', () => { + const once = upsertShareFrontmatter('# Hi\n', SHARE) + expect(upsertShareFrontmatter(once, SHARE)).toBe(once) + }) + + it('preserves CRLF line endings', () => { + const body = '---\r\ntitle: Win\r\n---\r\n# Hello\r\n' + const next = upsertShareFrontmatter(body, SHARE) + expect(next).toBe( + '---\r\ntitle: Win\r\nshare_id: 42\r\nshare_url: https://zennotes.org/s/abc123def456\r\n---\r\n# Hello\r\n' + ) + }) + + it('preserves a BOM', () => { + const body = '# Hello\n' + const next = upsertShareFrontmatter(body, SHARE) + expect(next.startsWith('---\n')).toBe(true) + expect(next.endsWith('# Hello\n')).toBe(true) + }) + + it('strips injected newlines from values', () => { + const next = upsertShareFrontmatter('# Hi\n', { + shareId: '42\nevil: true', + shareUrl: 'https://x.test' + }) + expect(next).toContain('share_id: 42 evil: true\n') + expect(readShareFrontmatter(next).shareId).toBe('42 evil: true') + }) +}) + +describe('removeShareFrontmatter', () => { + it('removes only the share keys', () => { + const body = '---\ntitle: Keep\nshare_id: 42\nshare_url: https://x.test\n---\nBody\n' + expect(removeShareFrontmatter(body)).toBe('---\ntitle: Keep\n---\nBody\n') + }) + + it('drops the whole block when share keys were its only content', () => { + const body = '---\nshare_id: 42\nshare_url: https://x.test\n---\n# Hello\n' + expect(removeShareFrontmatter(body)).toBe('# Hello\n') + }) + + it('leaves notes without share keys untouched', () => { + const body = '---\ntitle: Keep\n---\nBody\n' + expect(removeShareFrontmatter(body)).toBe(body) + expect(removeShareFrontmatter('# Plain\n')).toBe('# Plain\n') + }) +}) + +describe('stripFrontmatter', () => { + it('removes a leading frontmatter block', () => { + expect(stripFrontmatter('---\ntitle: X\n---\n# Hello\n')).toBe('# Hello\n') + }) + + it('returns the body unchanged when there is no block', () => { + expect(stripFrontmatter('# Hello\n')).toBe('# Hello\n') + }) + + it('handles an unterminated block as plain content', () => { + const body = '---\ntitle: X\n# Hello\n' + expect(stripFrontmatter(body)).toBe(body) + }) + + it('handles CRLF blocks', () => { + expect(stripFrontmatter('---\r\ntitle: X\r\n---\r\n# Hello\r\n')).toBe('# Hello\r\n') + }) +}) diff --git a/packages/app-core/src/lib/note-frontmatter.ts b/packages/app-core/src/lib/note-frontmatter.ts new file mode 100644 index 00000000..554b9357 --- /dev/null +++ b/packages/app-core/src/lib/note-frontmatter.ts @@ -0,0 +1,183 @@ +// Surgical YAML frontmatter helpers for the share feature. +// +// gray-matter is deliberately avoided: it reserializes the whole YAML +// block (reordering keys, normalizing quotes), while these helpers only +// touch the two share keys and leave every other byte of the note as +// the user wrote it. + +export const SHARE_ID_KEY = 'share_id' +export const SHARE_URL_KEY = 'share_url' + +export interface ShareFrontmatter { + shareId: string | null + shareUrl: string | null +} + +interface FrontmatterBlock { + /** Byte offset where the block starts (after any BOM). */ + start: number + /** Offset just past the closing delimiter line (including its EOL). */ + end: number + /** Inner lines between the delimiters, without EOLs. */ + lines: string[] + /** The dominant line ending inside the file. */ + eol: '\n' | '\r\n' +} + +function dominantEol(body: string): '\n' | '\r\n' { + return body.includes('\r\n') ? '\r\n' : '\n' +} + +function bomOf(body: string): string { + return body.startsWith('\uFEFF') ? '\uFEFF' : '' +} + +/** + * Locate the leading YAML frontmatter block. YAML frontmatter is only + * valid at the very start of the file (after an optional BOM), which + * matches how remark-frontmatter parses notes for rendering. + */ +function findFrontmatterBlock(body: string): FrontmatterBlock | null { + const bom = bomOf(body) + const text = body.slice(bom.length) + const eol = dominantEol(text) + + if (!(text.startsWith('---\n') || text.startsWith('---\r\n'))) return null + + const firstLineEnd = text.indexOf('\n') + 1 + const lines: string[] = [] + let cursor = firstLineEnd + + while (cursor <= text.length) { + const nextBreak = text.indexOf('\n', cursor) + const rawLine = nextBreak === -1 ? text.slice(cursor) : text.slice(cursor, nextBreak) + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + + if (line.trimEnd() === '---') { + const end = nextBreak === -1 ? text.length : nextBreak + 1 + return { start: bom.length, end: bom.length + end, lines, eol } + } + + if (nextBreak === -1) break + lines.push(line) + cursor = nextBreak + 1 + } + + return null +} + +function matchShareKey(line: string): { key: string; value: string } | null { + const match = /^(share_id|share_url)\s*:\s*(.*)$/.exec(line) + if (!match) return null + return { key: match[1]!, value: match[2]!.trim() } +} + +function unquote(value: string): string { + if (value.length >= 2) { + const first = value[0] + const last = value[value.length - 1] + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1) + } + } + return value +} + +/** Read the share keys from a note body's frontmatter, if present. */ +export function readShareFrontmatter(body: string): ShareFrontmatter { + const block = findFrontmatterBlock(body) + const result: ShareFrontmatter = { shareId: null, shareUrl: null } + if (!block) return result + + for (const line of block.lines) { + const entry = matchShareKey(line) + if (!entry) continue + const value = unquote(entry.value) + if (entry.key === SHARE_ID_KEY && result.shareId === null) { + result.shareId = value || null + } else if (entry.key === SHARE_URL_KEY && result.shareUrl === null) { + result.shareUrl = value || null + } + } + + return result +} + +function sanitizeValue(value: string): string { + // Share values are single-line YAML scalars; never let an injected + // newline rewrite the rest of the block. + return value.replace(/[\r\n]+/g, ' ').trim() +} + +/** + * Insert or update the share keys in a note body, preserving every + * other byte (other frontmatter keys, ordering, EOL style, BOM). + */ +export function upsertShareFrontmatter( + body: string, + share: { shareId: string; shareUrl: string } +): string { + const shareId = sanitizeValue(share.shareId) + const shareUrl = sanitizeValue(share.shareUrl) + const block = findFrontmatterBlock(body) + + if (!block) { + const bom = bomOf(body) + const rest = body.slice(bom.length) + const eol = dominantEol(rest || '\n') + const blockText = `---${eol}${SHARE_ID_KEY}: ${shareId}${eol}${SHARE_URL_KEY}: ${shareUrl}${eol}---${eol}` + return `${bom}${blockText}${rest}` + } + + const lines = [...block.lines] + let replacedId = false + let replacedUrl = false + + for (let index = 0; index < lines.length; index += 1) { + const entry = matchShareKey(lines[index]!) + if (!entry) continue + if (entry.key === SHARE_ID_KEY && !replacedId) { + lines[index] = `${SHARE_ID_KEY}: ${shareId}` + replacedId = true + } else if (entry.key === SHARE_URL_KEY && !replacedUrl) { + lines[index] = `${SHARE_URL_KEY}: ${shareUrl}` + replacedUrl = true + } + } + + if (!replacedId) lines.push(`${SHARE_ID_KEY}: ${shareId}`) + if (!replacedUrl) lines.push(`${SHARE_URL_KEY}: ${shareUrl}`) + + return replaceBlockLines(body, block, lines) +} + +/** Remove the share keys; drops the whole block if that empties it. */ +export function removeShareFrontmatter(body: string): string { + const block = findFrontmatterBlock(body) + if (!block) return body + + const lines = block.lines.filter((line) => matchShareKey(line) === null) + if (lines.length === block.lines.length) return body + + if (lines.every((line) => line.trim() === '')) { + return body.slice(0, block.start) + body.slice(block.end) + } + + return replaceBlockLines(body, block, lines) +} + +/** The note body with any leading frontmatter block removed. */ +export function stripFrontmatter(body: string): string { + const block = findFrontmatterBlock(body) + if (!block) return body + return body.slice(0, block.start) + body.slice(block.end) +} + +function replaceBlockLines(body: string, block: FrontmatterBlock, lines: string[]): string { + const { eol } = block + const inner = lines.map((line) => `${line}${eol}`).join('') + const blockText = `---${eol}${inner}---${eol}` + const hadTrailingEol = body.slice(block.start, block.end).endsWith('\n') + const text = hadTrailingEol ? blockText : blockText.slice(0, -eol.length) + return body.slice(0, block.start) + text + body.slice(block.end) +} diff --git a/packages/app-core/src/lib/share-payload.test.ts b/packages/app-core/src/lib/share-payload.test.ts new file mode 100644 index 00000000..ac11627c --- /dev/null +++ b/packages/app-core/src/lib/share-payload.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import { buildSharePayload } from './share-payload' + +const ASSETS = [ + { path: 'media/photo.png' }, + { path: 'media/Pasted Image.png' }, + { path: 'inbox/diagram.svg' }, + { path: 'docs/manual.pdf' }, + { path: 'inbox/local.png' } +] + +function payloadFor(body: string, notePath = 'inbox/Note.md') { + return buildSharePayload({ path: notePath, title: 'Note', body }, ASSETS) +} + +describe('buildSharePayload', () => { + it('strips frontmatter from the public markdown', () => { + const result = payloadFor('---\nshare_id: 1\n---\n# Hello\n') + expect(result.markdown).toBe('# Hello\n') + expect(result.notePath).toBe('inbox/Note.md') + expect(result.title).toBe('Note') + }) + + it('collects tikz fences in order, deduped', () => { + const body = [ + '```tikz', + '\\begin{tikzpicture}A\\end{tikzpicture}', + '```', + '', + '```tikz', + '\\begin{tikzpicture}B\\end{tikzpicture}', + '```', + '', + '```tikz', + '\\begin{tikzpicture}A\\end{tikzpicture}', + '```', + '', + '```mermaid', + 'graph TD', + '```' + ].join('\n') + + expect(payloadFor(body).tikzSources).toEqual([ + '\\begin{tikzpicture}A\\end{tikzpicture}', + '\\begin{tikzpicture}B\\end{tikzpicture}' + ]) + }) + + it('collects standard image refs with resolved vault paths', () => { + const result = payloadFor('![photo](media/photo.png)\n') + expect(result.assets).toEqual([{ ref: 'media/photo.png', vaultRelPath: 'media/photo.png' }]) + }) + + it('collects wikilink image embeds', () => { + const result = payloadFor('![[photo.png]]\n') + expect(result.assets).toEqual([{ ref: 'photo.png', vaultRelPath: 'media/photo.png' }]) + }) + + it('decodes percent-escaped refs to their canonical form', () => { + const result = payloadFor('![pasted](media/Pasted%20Image.png)\n') + expect(result.assets).toEqual([ + { ref: 'media/Pasted Image.png', vaultRelPath: 'media/Pasted Image.png' } + ]) + }) + + it('collects embeddable link assets like pdfs', () => { + const result = payloadFor('[the manual](docs/manual.pdf)\n') + expect(result.assets).toEqual([{ ref: 'docs/manual.pdf', vaultRelPath: 'docs/manual.pdf' }]) + }) + + it('skips external urls, svg assets, unresolved files, and plain note links', () => { + const body = [ + '![remote](https://example.com/pic.png)', + '![vector](diagram.svg)', + '![missing](not-in-vault.png)', + '[readme](README.md)', + '[[Another Note]]' + ].join('\n\n') + + expect(payloadFor(body).assets).toEqual([]) + }) + + it('dedupes repeated refs', () => { + const body = '![a](media/photo.png)\n\n![b](media/photo.png)\n' + expect(payloadFor(body).assets).toHaveLength(1) + }) + + it('resolves note-dir-relative refs', () => { + const result = payloadFor('![local](local.png)', 'inbox/Note.md') + expect(result.assets).toEqual([{ ref: 'local.png', vaultRelPath: 'inbox/local.png' }]) + }) +}) diff --git a/packages/app-core/src/lib/share-payload.ts b/packages/app-core/src/lib/share-payload.ts new file mode 100644 index 00000000..a5c23915 --- /dev/null +++ b/packages/app-core/src/lib/share-payload.ts @@ -0,0 +1,106 @@ +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkFrontmatter from 'remark-frontmatter' +import { visit } from 'unist-util-visit' +import type { SharePublishAsset } from '@shared/ipc' +import { remarkWikilinks } from './markdown' +import { + canonicalAssetRef, + classifyLocalAssetHref, + resolveAssetVaultRelativePathIn, + type LocalAssetKind +} from './local-assets' +import { stripFrontmatter } from './note-frontmatter' + +export interface SharePayloadNote { + /** Vault-relative POSIX path of the note. */ + path: string + title: string + /** Raw markdown body, frontmatter included. */ + body: string +} + +export interface SharePayload { + notePath: string + title: string + /** Frontmatter-stripped markdown — what the public page renders. */ + markdown: string + /** Raw ```tikz fence bodies, deduped, in document order. */ + tikzSources: string[] + /** Local assets the note references, resolved against the vault. */ + assets: SharePublishAsset[] +} + +/** Link-node asset kinds that render as embeds and therefore upload. */ +const EMBEDDABLE_LINK_KINDS = new Set(['pdf', 'audio', 'video']) + +/** + * Collect everything a share upload needs from a note: the public + * markdown, the TikZ sources main pre-renders to SVG, and the local + * asset refs with their resolved vault paths. + * + * Parsing reuses the production wikilink plugin so `![[embeds]]` + * surface exactly as the preview renders them. + */ +export function buildSharePayload( + note: SharePayloadNote, + assetFiles: ReadonlyArray<{ path: string }> +): SharePayload { + const processor = unified() + .use(remarkParse) + .use(remarkFrontmatter, ['yaml', 'toml']) + .use(remarkWikilinks) + const tree = processor.runSync(processor.parse(note.body)) + + const tikzSources: string[] = [] + const seenTikz = new Set() + const assets: SharePublishAsset[] = [] + const seenRefs = new Set() + + const addAsset = (url: string | null | undefined, allowed: ReadonlySet): void => { + if (!url) return + const kind = classifyLocalAssetHref(url) + if (!kind || !allowed.has(kind)) return + + const ref = canonicalAssetRef(url) + if (!ref || seenRefs.has(ref)) return + // The server rejects SVG uploads (script-bearing when served raw); + // TikZ SVGs travel separately inside the JSON payload. + if (ref.toLowerCase().endsWith('.svg')) return + + const vaultRelPath = resolveAssetVaultRelativePathIn(assetFiles, note.path, url) + if (!vaultRelPath) return + + seenRefs.add(ref) + assets.push({ ref, vaultRelPath }) + } + + visit(tree, (node) => { + if (node.type === 'code') { + const code = node as { lang?: string | null; value?: string } + if ((code.lang ?? '').trim().toLowerCase() === 'tikz') { + const source = String(code.value ?? '') + if (source && !seenTikz.has(source)) { + seenTikz.add(source) + tikzSources.push(source) + } + } + return + } + if (node.type === 'image') { + addAsset((node as { url?: string }).url, new Set(['image'])) + return + } + if (node.type === 'link') { + addAsset((node as { url?: string }).url, EMBEDDABLE_LINK_KINDS) + } + }) + + return { + notePath: note.path, + title: note.title, + markdown: stripFrontmatter(note.body), + tikzSources, + assets + } +} diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 5941c0ea..a218c5ec 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -15,6 +15,7 @@ import type { RemoteWorkspaceProfile, RemoteWorkspaceProfileInput, ServerCapabilities, + ShareAccount, VaultSettings, VaultTextSearchBackendPreference, VaultChangeEvent, @@ -42,8 +43,15 @@ import { import { DEFAULT_THEME_ID, THEMES, type ThemeFamily, type ThemeMode } from './lib/themes' import { formatMarkdown } from './lib/format-markdown' import { confirmMoveToTrash } from './lib/confirm-trash' +import { confirmApp } from './lib/confirm-requests' import { pickServerDirectoryApp } from './lib/server-directory-picker-requests' import { promptApp } from './lib/prompt-requests' +import { + readShareFrontmatter, + removeShareFrontmatter, + upsertShareFrontmatter +} from './lib/note-frontmatter' +import { buildSharePayload } from './lib/share-payload' import { buildNoteDestinationPrompt, buildTemplateDestinationPrompt, @@ -1611,6 +1619,17 @@ interface Store { archiveActive: () => Promise unarchiveActive: () => Promise exportActiveNotePdf: () => Promise + /** Connected zennotes.org account state (null until first refresh). */ + shareAccount: ShareAccount | null + refreshShareAccount: () => Promise + connectShareAccount: () => Promise + disconnectShareAccount: () => Promise + setShareServerUrl: (url: string) => Promise + /** Publish the note (or update its existing share). Defaults to the active note. */ + shareActiveNote: (path?: string) => Promise + copyShareLink: (path?: string) => Promise + unshareActiveNote: (path?: string) => Promise + openShareInBrowser: (path?: string) => Promise setSearchOpen: (open: boolean) => void setVaultTextSearchOpen: (open: boolean) => void setCommandPaletteOpen: (open: boolean, mode?: CommandPaletteInitialMode) => void @@ -2106,6 +2125,47 @@ async function prefetchInitialVisibleNotes(state: Store): Promise { scheduleBackgroundPrefetch() } +/** Strip Electron's "Error invoking remote method …" prefix from IPC errors. */ +function shareErrorMessage(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err) + return raw.replace(/^Error invoking remote method '[^']+':\s*(?:Error:\s*)?/, '') +} + +/** + * Load the freshest body for a note being shared: persists in-memory + * edits first when the note is open, otherwise reads straight from the + * vault (context menus can share notes that were never opened). + */ +async function loadShareNoteBody( + get: () => Store, + path: string, + options: { persist?: boolean } = {} +): Promise<{ title: string; body: string }> { + const open = get().noteContents[path] + if (open) { + if (options.persist !== false) { + await get().persistNote(path) + if (get().noteDirty[path]) { + throw new Error('Could not save the note before sharing it.') + } + } + const current = get().noteContents[path] ?? open + return { title: current.title, body: current.body } + } + const note = await window.zen.readNote(path) + return { title: note.title, body: note.body } +} + +/** Write a share-frontmatter change back, through the editor when open. */ +async function writeShareNoteBody(get: () => Store, path: string, body: string): Promise { + if (get().noteContents[path]) { + get().updateNoteBody(path, body) + await get().persistNote(path) + } else { + await window.zen.writeNote(path, body) + } +} + export const useStore = create((set, get) => { const selectNoteImpl = async ( relPath: string | null, @@ -2474,6 +2534,7 @@ export const useStore = create((set, get) => { noteForwardstack: [], pendingJumpLocation: null, loadingNote: false, + shareAccount: null, searchOpen: false, vaultTextSearchOpen: false, commandPaletteOpen: false, @@ -3633,6 +3694,172 @@ export const useStore = create((set, get) => { } }, + refreshShareAccount: async () => { + try { + set({ shareAccount: await window.zen.shareGetAccount() }) + } catch { + set({ shareAccount: null }) + } + }, + + connectShareAccount: async () => { + try { + await window.zen.shareBeginConnect() + } catch (err) { + window.alert(shareErrorMessage(err)) + } + }, + + disconnectShareAccount: async () => { + const confirmed = await confirmApp({ + title: 'Disconnect your ZenNotes account?', + description: + 'Already-shared notes stay published; you just need to reconnect before sharing or updating notes again.', + confirmLabel: 'Disconnect', + danger: true + }) + if (!confirmed) return + try { + set({ shareAccount: await window.zen.shareDisconnect() }) + } catch (err) { + window.alert(shareErrorMessage(err)) + } + }, + + setShareServerUrl: async (url) => { + try { + await window.zen.shareSetServerUrl(url) + await get().refreshShareAccount() + } catch (err) { + window.alert(shareErrorMessage(err)) + } + }, + + shareActiveNote: async (pathArg) => { + const path = pathArg ?? get().selectedPath + if (!path) return + + try { + const account = await window.zen.shareGetAccount() + if (!account.connected) { + const connect = await confirmApp({ + title: 'Connect your ZenNotes account', + description: `Sharing publishes notes to ${account.serverUrl}. Connect your account in the browser first — then run Share again.`, + confirmLabel: 'Connect via Browser' + }) + if (connect) await get().connectShareAccount() + return + } + + const note = await loadShareNoteBody(get, path) + const existing = readShareFrontmatter(note.body) + const numericShareId = existing.shareId !== null ? Number(existing.shareId) : NaN + const isUpdate = Number.isFinite(numericShareId) + + const confirmed = await confirmApp({ + title: isUpdate ? 'Update the shared note?' : 'Share this note publicly?', + description: isUpdate + ? 'The public page will be replaced with the current contents of this note.' + : `Anyone with the link can read “${note.title}”. It will be published to ${account.serverUrl}.`, + confirmLabel: isUpdate ? 'Update Share' : 'Share Note' + }) + if (!confirmed) return + + const payload = buildSharePayload( + { path, title: note.title, body: note.body }, + get().assetFiles + ) + const record = await window.zen.sharePublish({ + notePath: payload.notePath, + title: payload.title, + markdown: payload.markdown, + tikzSources: payload.tikzSources, + assets: payload.assets, + existingShareId: isUpdate ? numericShareId : null + }) + + const nextBody = upsertShareFrontmatter(note.body, { + shareId: String(record.id), + shareUrl: record.url + }) + if (nextBody !== note.body) { + await writeShareNoteBody(get, path, nextBody) + } + + window.zen.clipboardWriteText(record.url) + const openNow = await confirmApp({ + title: isUpdate ? 'Share updated' : 'Note shared', + description: `${record.url}\n\nThe link has been copied to your clipboard.`, + confirmLabel: 'Open in Browser', + cancelLabel: 'Done' + }) + if (openNow) window.open(record.url, '_blank') + } catch (err) { + console.error('shareActiveNote failed', err) + window.alert(shareErrorMessage(err)) + } + }, + + copyShareLink: async (pathArg) => { + const path = pathArg ?? get().selectedPath + if (!path) return + try { + const note = await loadShareNoteBody(get, path, { persist: false }) + const { shareUrl } = readShareFrontmatter(note.body) + if (!shareUrl) { + await get().shareActiveNote(path) + return + } + window.zen.clipboardWriteText(shareUrl) + } catch (err) { + window.alert(shareErrorMessage(err)) + } + }, + + unshareActiveNote: async (pathArg) => { + const path = pathArg ?? get().selectedPath + if (!path) return + + try { + const note = await loadShareNoteBody(get, path) + const { shareId } = readShareFrontmatter(note.body) + if (shareId === null) return + + const confirmed = await confirmApp({ + title: 'Stop sharing this note?', + description: 'The public link will stop working immediately.', + confirmLabel: 'Stop Sharing', + danger: true + }) + if (!confirmed) return + + const numericShareId = Number(shareId) + if (Number.isFinite(numericShareId)) { + await window.zen.shareUnpublish(numericShareId) + } + + const nextBody = removeShareFrontmatter(note.body) + if (nextBody !== note.body) { + await writeShareNoteBody(get, path, nextBody) + } + } catch (err) { + console.error('unshareActiveNote failed', err) + window.alert(shareErrorMessage(err)) + } + }, + + openShareInBrowser: async (pathArg) => { + const path = pathArg ?? get().selectedPath + if (!path) return + try { + const note = await loadShareNoteBody(get, path, { persist: false }) + const { shareUrl } = readShareFrontmatter(note.body) + if (shareUrl) window.open(shareUrl, '_blank') + } catch (err) { + window.alert(shareErrorMessage(err)) + } + }, + setSearchOpen: (open) => set({ searchOpen: open, diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 94791772..ae7a7c95 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -23,6 +23,11 @@ import type { RemoteWorkspaceProfileInput, ServerCapabilities, ServerSessionStatus, + ShareAccount, + ShareAuthResult, + ShareConnectPending, + SharePublishRequest, + ShareRecord, VaultSettings, TikzRenderResponse, VaultChangeEvent, @@ -208,6 +213,21 @@ export interface ZenBridge { raycastInstall(): Promise clipboardWriteText(text: string): void clipboardReadText(): string + + shareGetAccount(): Promise + /** Open the browser connect handoff; resolves with the pending state nonce. */ + shareBeginConnect(): Promise + /** Complete the connect flow with a manually pasted one-time code. */ + shareSubmitCode(code: string): Promise + shareDisconnect(): Promise + shareGetServerUrl(): Promise + shareSetServerUrl(url: string): Promise + /** Publish (or, with existingShareId, re-publish) a note. */ + sharePublish(request: SharePublishRequest): Promise + shareUnpublish(shareId: number): Promise + shareList(): Promise + /** Fires when a zennotes://auth deep link completes (or fails) the connect flow. */ + onShareAuthResult(cb: (result: ShareAuthResult) => void): () => void } let installedBridge: ZenBridge | null = null diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 1438645c..dfdaf448 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -104,7 +104,17 @@ export const IPC = { CLI_INSTALL: 'cli:install', CLI_UNINSTALL: 'cli:uninstall', RAYCAST_GET_STATUS: 'raycast:get-status', - RAYCAST_INSTALL: 'raycast:install' + RAYCAST_INSTALL: 'raycast:install', + SHARE_GET_ACCOUNT: 'share:get-account', + SHARE_BEGIN_CONNECT: 'share:begin-connect', + SHARE_SUBMIT_CODE: 'share:submit-code', + SHARE_DISCONNECT: 'share:disconnect', + SHARE_GET_SERVER_URL: 'share:get-server-url', + SHARE_SET_SERVER_URL: 'share:set-server-url', + SHARE_PUBLISH: 'share:publish', + SHARE_UNPUBLISH: 'share:unpublish', + SHARE_LIST: 'share:list', + SHARE_ON_AUTH_RESULT: 'share:on-auth-result' } as const export interface TikzRenderResponse { @@ -528,3 +538,54 @@ export interface VaultChangeEvent { folder: NoteFolder scope?: VaultChangeScope } + +/** Default server notes are shared to. Overridable in Settings → Sharing. */ +export const DEFAULT_SHARE_SERVER_URL = 'https://zennotes.org' + +export interface ShareAccount { + connected: boolean + name: string | null + email: string | null + serverUrl: string +} + +export interface ShareRecord { + id: number + slug: string + url: string + title?: string | null + notePath?: string | null + viewCount?: number + updatedAt?: string | null +} + +/** A pending browser connect handoff (state nonce + the URL we opened). */ +export interface ShareConnectPending { + state: string + url: string +} + +export interface ShareAuthResult { + ok: boolean + account?: ShareAccount + error?: string +} + +export interface SharePublishAsset { + /** The literal markdown URL string used in the note (the viewer's lookup key). */ + ref: string + /** Resolved vault-relative path main reads the bytes from. */ + vaultRelPath: string +} + +export interface SharePublishRequest { + notePath: string + title: string + /** Frontmatter-stripped markdown body. */ + markdown: string + /** Raw ```tikz fence bodies, deduped; main pre-renders them to SVG. */ + tikzSources: string[] + assets: SharePublishAsset[] + /** Present when re-publishing an existing share (PUT instead of POST). */ + existingShareId?: number | null +}