diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4abffcd35..c40333365 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -346,6 +346,7 @@ import { resolveMarkdownOpenTarget, } from "./file-open"; import { isStandaloneLink, resolveStandaloneLink } from "./standalone-links"; +import { trustSystemCertificates } from "./system-trust"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const nodeRequire = createRequire(import.meta.url); @@ -2053,6 +2054,9 @@ async function createWindow( ); if (restoredState?.isMaximized) win.maximize(); win.show(); + // Cloud, updates and link previews trust the system's certificates too, + // read once the window is up rather than on the boot path (#1009). + setTimeout(trustSystemCertificates, 2000); }); win.webContents.on("did-start-loading", () => { readyWindowIds.delete(win.id); diff --git a/apps/desktop/src/main/remote/connection.ts b/apps/desktop/src/main/remote/connection.ts index fdf9fc21c..dc8888af4 100644 --- a/apps/desktop/src/main/remote/connection.ts +++ b/apps/desktop/src/main/remote/connection.ts @@ -10,6 +10,8 @@ * module instead and stays fetch-only. */ +import { trustSystemCertificates } from '../system-trust' + export interface RemoteRequestOptions { baseUrl: string authToken?: string | null @@ -91,6 +93,7 @@ export async function remoteJsonRequest( headers.set('Content-Type', 'application/json') } + trustSystemCertificates() let response: Response try { response = await fetch(`${options.baseUrl}${path}`, { diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts index 0dc33f0ed..b6b9d3dd9 100644 --- a/apps/desktop/src/main/remote/server-client.ts +++ b/apps/desktop/src/main/remote/server-client.ts @@ -27,6 +27,7 @@ import { normalizeBaseUrl, requestErrorMessage } from './connection' +import { trustSystemCertificates } from '../system-trust' export interface RemoteServerClientOptions { baseUrl: string @@ -76,6 +77,8 @@ export class RemoteServerClient { private readonly onStalePath: ((path: string) => void) | null constructor(options: RemoteServerClientOptions) { + // Every request and the change feed go through Node's TLS (system-trust.ts). + trustSystemCertificates() this.baseUrl = normalizeBaseUrl(options.baseUrl) this.authToken = options.authToken?.trim() || null this.onStalePath = options.onStalePath ?? null diff --git a/apps/desktop/src/main/system-trust.test.ts b/apps/desktop/src/main/system-trust.test.ts new file mode 100644 index 000000000..f65cce769 --- /dev/null +++ b/apps/desktop/src/main/system-trust.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { addSystemCertificates, type CertificateStore } from './system-trust' + +function store(defaults: string[], system: string[] | Error): CertificateStore & { + set: ReturnType +} { + const set = vi.fn() + return { + getCACertificates: (type) => { + if (type === 'default') return defaults + if (system instanceof Error) throw system + return system + }, + setDefaultCACertificates: set, + set + } +} + +describe('addSystemCertificates (#1009)', () => { + it("adds the system's certificates to Node's own, once each", () => { + const certificates = store(['node-a', 'node-b'], ['node-b', 'private-ca']) + expect(addSystemCertificates(certificates)).toBe(true) + expect(certificates.set).toHaveBeenCalledWith(['node-a', 'node-b', 'private-ca']) + }) + + it('leaves the default list alone when the system store is empty', () => { + const certificates = store(['node-a'], []) + expect(addSystemCertificates(certificates)).toBe(false) + expect(certificates.set).not.toHaveBeenCalled() + }) + + it("keeps Node's list when the system store cannot be read", () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const certificates = store(['node-a'], new Error('keychain locked')) + expect(addSystemCertificates(certificates)).toBe(false) + expect(certificates.set).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) + + it('does nothing on a Node without the calls', () => { + expect(addSystemCertificates({} as CertificateStore)).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/system-trust.ts b/apps/desktop/src/main/system-trust.ts new file mode 100644 index 000000000..9510d3288 --- /dev/null +++ b/apps/desktop/src/main/system-trust.ts @@ -0,0 +1,51 @@ +import tls from 'node:tls' + +/** + * Trust the operating system's certificate store in this process, as Chromium + * already does for the window. + * + * Node's TLS, which every request from the main process, the MCP server and + * the bundled CLI goes through (fetch, the `ws` change feed), trusts only the + * list bundled into Node. A self-hosted server whose certificate comes from a + * private CA the system trusts (step-ca, a company root, a proxy that inspects + * TLS) answered "fetch failed" while a browser on the same Mac opened it fine + * (#1009). NODE_USE_SYSTEM_CA=1 cured it, but only when set before the process + * starts, which nobody launching from the Dock can do. + * + * Reading the system store costs 20 to 30 ms on macOS, so it is read once and + * never on the boot path: before the first request to a server, and a moment + * after the window shows for everything else. Trust is only ever added: a + * store that cannot be read, or a Node without these calls, keeps Node's list. + */ + +export interface CertificateStore { + getCACertificates(type: 'default' | 'system'): string[] + setDefaultCACertificates(certificates: string[]): void +} + +/** Make the default list Node's own plus the system's. False when nothing changed. */ +export function addSystemCertificates(store: CertificateStore): boolean { + if ( + typeof store.getCACertificates !== 'function' || + typeof store.setDefaultCACertificates !== 'function' + ) { + return false + } + try { + const system = store.getCACertificates('system') + if (system.length === 0) return false + store.setDefaultCACertificates([...new Set([...store.getCACertificates('default'), ...system])]) + return true + } catch (error) { + console.warn("[tls] the system certificate store could not be read; keeping Node's list", error) + return false + } +} + +let systemTrustRead = false + +export function trustSystemCertificates(): void { + if (systemTrustRead) return + systemTrustRead = true + addSystemCertificates(tls) +} diff --git a/packages/app-core/src/lib/cm-markdown-list-indent.test.ts b/packages/app-core/src/lib/cm-markdown-list-indent.test.ts index 5c44a9ca6..41e0a94e0 100644 --- a/packages/app-core/src/lib/cm-markdown-list-indent.test.ts +++ b/packages/app-core/src/lib/cm-markdown-list-indent.test.ts @@ -1,10 +1,12 @@ // @vitest-environment jsdom +import { indentMore } from '@codemirror/commands' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' -import { forceParsing } from '@codemirror/language' +import { forceParsing, HighlightStyle, indentUnit, syntaxHighlighting } from '@codemirror/language' import { Compartment, EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' -import { describe, expect, it } from 'vitest' +import { tags } from '@lezer/highlight' +import { afterEach, describe, expect, it, vi } from 'vitest' import { listGuideStyle, listIndentPadCh, @@ -128,3 +130,80 @@ describe('markdownListIndentPlugin', () => { parent.remove() }) }) + +describe('the caret after Tab nests an empty task (#1012)', () => { + // The editor's own token classes: the redraw that moves the caret's node + // depends on the list spans they produce around the marker. + const tokens = HighlightStyle.define([ + { tag: tags.list, class: 'tok-list' }, + { tag: tags.meta, class: 'tok-meta' }, + { tag: tags.atom, class: 'tok-atom' } + ]) + let view: EditorView | null = null + + function mountFocused(doc: string): EditorView { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + const parent = document.createElement('div') + document.body.append(parent) + view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor: doc.length }, + extensions: [ + markdown({ base: markdownLanguage }), + syntaxHighlighting(tokens), + indentUnit.of(' '), + markdownListIndentPlugin + ] + }) + }) + forceParsing(view, doc.length, 5000) + view.focus() + return view + } + + afterEach(() => { + view?.destroy() + view = null + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + it('sets the DOM caret again where the page reports it, so typing lands after the marker', () => { + const editor = mountFocused('- [ ] parent\n- [ ] ') + const selection = document.getSelection()! + const caretNode = selection.focusNode! + expect(caretNode.textContent).toBe(' ') + const collapse = vi.spyOn(selection, 'collapse') + + indentMore(editor) + + expect(editor.state.doc.toString()).toBe('- [ ] parent\n - [ ] ') + // The redraw reused the caret's node for an indent cell, and the page now + // reports the line's end, which CodeMirror accepts without setting it. + expect(caretNode.isConnected).toBe(true) + expect((caretNode.parentNode as Element).closest('.cm-list-prefix-cell')).not.toBeNull() + expect(selection.focusNode).not.toBe(caretNode) + expect(editor.posAtDOM(selection.focusNode!, selection.focusOffset)).toBe(editor.state.doc.length) + // So the plugin sets it, on that same reported place. + expect(collapse).toHaveBeenCalledTimes(1) + expect(collapse).toHaveBeenCalledWith(selection.focusNode, selection.focusOffset) + }) + + it('leaves the DOM caret alone when an edit keeps it in its node', () => { + // A caret on the parent line while a nested task changes below it, the + // way most edits leave the caret: no extra selection write per change. + const editor = mountFocused('- [ ] parent\n - [ ] x') + editor.dispatch({ selection: { anchor: '- [ ] parent'.length } }) + const selection = document.getSelection()! + const caretNode = selection.focusNode + const collapse = vi.spyOn(selection, 'collapse') + + editor.dispatch({ changes: { from: editor.state.doc.length, insert: 'y' }, userEvent: 'input.type' }) + + expect(editor.state.doc.toString()).toBe('- [ ] parent\n - [ ] xy') + expect(selection.focusNode).toBe(caretNode) + expect(collapse).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/lib/cm-markdown-list-indent.ts b/packages/app-core/src/lib/cm-markdown-list-indent.ts index 535234d78..bd3096d8e 100644 --- a/packages/app-core/src/lib/cm-markdown-list-indent.ts +++ b/packages/app-core/src/lib/cm-markdown-list-indent.ts @@ -210,15 +210,49 @@ function computeDecorations(view: EditorView): DecorationSet { return builder.finish() } +/** The text node holding a collapsed caret in this editor, if there is one. */ +function caretTextNode(view: EditorView): Node | null { + const selection = view.dom.ownerDocument.getSelection() + const node = selection?.isCollapsed ? selection.focusNode : null + return node?.nodeType === Node.TEXT_NODE && view.contentDOM.contains(node) ? node : null +} + +/** + * Puts Chromium's caret back where the page says it is, after a redraw moved + * the text node it was in (#1012). + * + * The prefix cells give an indent one single-space text node per column. On + * an empty task the caret sits after the `- [ ] ` marker's trailing space, + * itself a lone space, so when Tab indents that line, CodeMirror's redraw + * reuses the caret's node for one of the new indent cells. getSelection() + * then reports the line's end, which CodeMirror takes as already right and + * leaves alone, but Chromium keeps its own caret in the moved node, and the + * next keystroke lands before the marker. CodeMirror's guard for this Chrome + * behavior only notices a node that was rewritten or removed, not one moved + * intact. Collapsing the selection onto the position it already reports + * brings Chromium's caret back to it. + */ +function resyncMovedCaret(view: EditorView, before: Node): void { + if (view.composing || !view.contentDOM.contains(before)) return + const selection = view.dom.ownerDocument.getSelection() + if (!selection?.isCollapsed || !selection.focusNode || selection.focusNode === before) return + if (!view.contentDOM.contains(selection.focusNode)) return + selection.collapse(selection.focusNode, selection.focusOffset) +} + export const markdownListIndentPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet + /** The caret's text node before this update's redraw (see resyncMovedCaret). */ + caretBefore: Node | null = null constructor(view: EditorView) { this.decorations = computeDecorations(view) } update(update: ViewUpdate): void { + // Plugins update before CodeMirror redraws, so the DOM is still the old one. + this.caretBefore = update.docChanged ? caretTextNode(update.view) : null if ( update.docChanged || update.viewportChanged || @@ -232,6 +266,16 @@ export const markdownListIndentPlugin = ViewPlugin.fromClass( } }, { - decorations: (plugin) => plugin.decorations + decorations: (plugin) => plugin.decorations, + // Update listeners run after the redraw and after CodeMirror has placed + // the DOM selection. + provide: (plugin) => + EditorView.updateListener.of((update) => { + const value = update.view.plugin(plugin) + const before = value?.caretBefore + if (!value || !before) return + value.caretBefore = null + resyncMovedCaret(update.view, before) + }) } )