Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/remote/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* module instead and stays fetch-only.
*/

import { trustSystemCertificates } from '../system-trust'

export interface RemoteRequestOptions {
baseUrl: string
authToken?: string | null
Expand Down Expand Up @@ -91,6 +93,7 @@ export async function remoteJsonRequest<T>(
headers.set('Content-Type', 'application/json')
}

trustSystemCertificates()
let response: Response
try {
response = await fetch(`${options.baseUrl}${path}`, {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/remote/server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
normalizeBaseUrl,
requestErrorMessage
} from './connection'
import { trustSystemCertificates } from '../system-trust'

export interface RemoteServerClientOptions {
baseUrl: string
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/main/system-trust.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>
} {
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)
})
})
51 changes: 51 additions & 0 deletions apps/desktop/src/main/system-trust.ts
Original file line number Diff line number Diff line change
@@ -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)
}
83 changes: 81 additions & 2 deletions packages/app-core/src/lib/cm-markdown-list-indent.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()
})
})
46 changes: 45 additions & 1 deletion packages/app-core/src/lib/cm-markdown-list-indent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -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)
})
}
)
Loading