Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion apps/desktop/src/main/deep-links.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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()
})
})
33 changes: 33 additions & 0 deletions apps/desktop/src/main/deep-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
RemoteWorkspaceProfile,
RemoteWorkspaceProfileInput,
ServerCapabilities,
SharePublishRequest,
VaultSettings,
VaultChangeEvent,
VaultInfo,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -141,6 +153,7 @@ import {
} from '../mcp/instructions-store'
import { recordMainPerf } from './perf'
import {
parseAuthDeepLink,
parseOpenNoteDeepLink,
ZENNOTES_DEEP_LINK_SCHEME
} from './deep-links'
Expand Down Expand Up @@ -314,7 +327,44 @@ async function flushPendingFloatingNoteRequests(): Promise<void> {
}
}

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)
Expand Down Expand Up @@ -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))
Expand Down
167 changes: 167 additions & 0 deletions apps/desktop/src/main/share/share-account.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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')
})
})
Loading
Loading