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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

## [Unreleased]

### Fixed

- **macOS update compatibility**: preserve the existing `com.cate.app` bundle ID while adding HTTP/HTTPS browser registration. The incompatible 2.0.3-beta.1 release has been withdrawn; existing users must not be required to reinstall Cate.

## [2.0.3-beta.1] - 2026-09-21 (withdrawn)

This beta enables macOS to recognize Cate as a web browser and opens links from other apps in Cate. It prepares the app for Apple's browser passkey entitlement review; website passkey support still requires Apple's approval and the native integration.

### Added

- **macOS browser registration**: advertise HTTP and HTTPS support so Cate can be selected as the default browser.
- **External web links**: open incoming links in visible browser panels, including links received while Cate is starting or restoring a session.

### Changed

- **Withdrawn identity change**: this beta changed the macOS bundle ID to `com.0ai.cate`, which was incompatible with existing automatic updates. It is no longer offered. The replacement retains `com.cate.app`.

## [2.0.2] - 2026-09-16

This release lets connected agents work with editor files, adds Mermaid diagrams to Markdown previews, and improves browser feedback, file previews, and worktree cleanup.
Expand Down
6 changes: 6 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ publish:
repo: cate
releaseType: release
mac:
# Inherit com.cate.app: existing installations must retain their signing
# identity so Squirrel updates and macOS identity checks remain compatible.
protocols:
- name: Web URLs
schemes: [http, https]
role: Viewer
extraResources:
- from: dist-native/passkeys.node
to: passkeys.node
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cate",
"version": "2.0.2",
"version": "2.0.3-beta.1",
"productName": "Cate",
"description": "An infinite zoomable canvas IDE",
"license": "MIT",
Expand Down
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { registerDockWindowHandlers } from './ipc/dockWindows'
import { registerWindowPanelHandlers } from './ipc/windowPanels'
import { registerDragHandlers } from './ipc/dragHandlers'
import { setMainWindowReady, flushPendingOpenPaths, registerOpenFileHandler } from './lifecycle/openPath'
import { registerOpenUrlHandler } from './lifecycle/openUrl'
import { fireStartupTelemetry, registerTelemetryNoticeHandler } from './lifecycle/telemetry'
import { registerLifecycleHandlers } from './lifecycle/shutdown'
import { registerPullRequestHandlers } from './ipc/pullRequests'
Expand Down Expand Up @@ -216,6 +217,7 @@ if (process.env.CATE_E2E === '1') {
// Register the macOS open-file handler at top level: the event can fire before
// app-ready, so we must be listening early to queue paths into pendingOpenPaths.
registerOpenFileHandler()
registerOpenUrlHandler(() => createWindow({ type: 'main' }))

// Build application menu
buildApplicationMenu()
Expand Down
110 changes: 110 additions & 0 deletions src/main/lifecycle/openUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { APP_OPEN_URL, APP_OPEN_URL_READY } from '../../shared/ipc-channels'

const state = vi.hoisted(() => ({
app: null as unknown as EventEmitter & { isReady: () => boolean },
ipc: null as unknown as EventEmitter,
active: null as any,
send: vi.fn(),
focus: vi.fn(),
}))
vi.mock('electron', () => ({ get app() { return state.app }, get ipcMain() { return state.ipc } }))
vi.mock('../windows/reveal', () => ({ IS_E2E: false }))
vi.mock('../windowRegistry', () => ({
getActiveMainWindow: () => state.active,
getWindowType: (id: number) => id === 99 ? 'dock' : 'main',
windowFromEvent: (event: any) => event.win,
sendToWindow: state.send,
focusWindow: state.focus,
}))
import { registerOpenUrlHandler } from './openUrl'

function windowFixture(id = 1) {
const contents = Object.assign(new EventEmitter(), { mainFrame: {} })
const win = { id, webContents: contents, isDestroyed: () => false }
const event = { win, sender: contents, senderFrame: contents.mainFrame }
return { win, event }
}
function open(url: string) {
const event = { preventDefault: vi.fn() }
state.app.emit('open-url', event, url)
expect(event.preventDefault).toHaveBeenCalledOnce()
}

beforeEach(() => {
state.app = Object.assign(new EventEmitter(), { isReady: () => false })
state.ipc = new EventEmitter()
state.active = null
vi.clearAllMocks()
})

describe('macOS web URL delivery', () => {
it('queues cold-launch URLs in order until the restored renderer subscribes', () => {
const create = vi.fn()
registerOpenUrlHandler(create)
open('https://example.com/login?next=%2Fdocs#section')
open('http://localhost:8080/test')
state.app.isReady = () => true
open('https://example.com/during-bootstrap')
expect(create).not.toHaveBeenCalled()
const { win, event } = windowFixture()
state.active = win
expect(state.send).not.toHaveBeenCalled()
state.ipc.emit(APP_OPEN_URL_READY, event, true)
expect(state.send.mock.calls).toEqual([
[1, APP_OPEN_URL, 'https://example.com/login?next=%2Fdocs#section'],
[1, APP_OPEN_URL, 'http://localhost:8080/test'],
[1, APP_OPEN_URL, 'https://example.com/during-bootstrap'],
])
state.ipc.emit(APP_OPEN_URL_READY, event, true)
expect(state.send).toHaveBeenCalledTimes(3)
})

it('delivers warm opens and queues again during renderer reload', () => {
registerOpenUrlHandler(vi.fn())
const { win, event } = windowFixture()
state.active = win
state.ipc.emit(APP_OPEN_URL_READY, event, true)
win.webContents.emit('did-start-loading')
win.webContents.emit('did-start-navigation', { isMainFrame: false, isSameDocument: false })
win.webContents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true })
open('https://example.com/one')
expect(state.send).toHaveBeenCalledTimes(1)
win.webContents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
open('https://example.com/two')
expect(state.send).toHaveBeenCalledTimes(1)
state.ipc.emit(APP_OPEN_URL_READY, event, true)
expect(state.send).toHaveBeenLastCalledWith(1, APP_OPEN_URL, 'https://example.com/two')
expect(state.focus).toHaveBeenCalledWith(win)
})

it('opens a main window when only detached windows remain', () => {
state.app.isReady = () => true
const { win, event } = windowFixture()
const create = vi.fn(() => { state.active = win; return win })
registerOpenUrlHandler(create as never)
state.ipc.emit(APP_OPEN_URL_READY, windowFixture(2).event, true)
open('https://example.com/')
expect(create).toHaveBeenCalledOnce()
state.ipc.emit(APP_OPEN_URL_READY, event, true)
expect(state.send).toHaveBeenCalledOnce()
})

it('rejects non-web URLs and readiness from guests, subframes, and dock windows', () => {
registerOpenUrlHandler(vi.fn())
for (const url of ['file:///etc/passwd', 'javascript:alert(1)', 'cate://run', 'not a URL']) open(url)
const { win, event } = windowFixture()
state.active = win
open('https://example.com/')
state.ipc.emit(APP_OPEN_URL_READY, { ...event, win: undefined }, true)
state.ipc.emit(APP_OPEN_URL_READY, { ...event, senderFrame: {} }, true)
state.ipc.emit(APP_OPEN_URL_READY, windowFixture(99).event, true)
expect(state.send).not.toHaveBeenCalled()
state.ipc.emit(APP_OPEN_URL_READY, event, true)
expect(state.send).toHaveBeenCalledTimes(1)
state.ipc.emit(APP_OPEN_URL_READY, event, false)
open('https://example.com/later')
expect(state.send).toHaveBeenCalledTimes(1)
})
})
48 changes: 48 additions & 0 deletions src/main/lifecycle/openUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { app, ipcMain, type BrowserWindow, type WebContents } from 'electron'
import { APP_OPEN_URL, APP_OPEN_URL_READY } from '../../shared/ipc-channels'
import { isWebUrl } from '../../shared/webUrl'
import { focusWindow, getActiveMainWindow, getWindowType, sendToWindow, windowFromEvent } from '../windowRegistry'
import { IS_E2E } from '../windows/reveal'

/** Register before app readiness. Renderer readiness is separate from native
* ready-to-show: session restoration must finish before adding browser panels. */
export function registerOpenUrlHandler(createMainWindow: () => BrowserWindow): void {
const pending: string[] = []
const ready = new WeakSet<WebContents>()
const tracked = new WeakSet<WebContents>()
let started = false

const flush = (win: BrowserWindow): void => {
if (!ready.has(win.webContents) || win.isDestroyed()) return
if (pending.length && !IS_E2E) focusWindow(win)
for (const url of pending.splice(0)) sendToWindow(win.id, APP_OPEN_URL, url)
}

ipcMain.on(APP_OPEN_URL_READY, (event, listening: unknown) => {
const win = windowFromEvent(event)
if (!win || getWindowType(win.id) !== 'main' || event.senderFrame !== event.sender.mainFrame) return
if (listening !== true) {
ready.delete(event.sender)
return
}
if (!tracked.has(event.sender)) {
tracked.add(event.sender)
event.sender.on('did-start-navigation', (details) => {
if (details.isMainFrame && !details.isSameDocument) ready.delete(event.sender)
})
}
ready.add(event.sender)
started = true
flush(win)
})

app.on('open-url', (event, url) => {
event.preventDefault()
if (!isWebUrl(url)) return
pending.push(url)
// app.ready can precede asynchronous bootstrap and IPC registration. Let
// bootstrap create the first window; only recreate one after startup.
const win = getActiveMainWindow() ?? (started && app.isReady() ? createMainWindow() : undefined)
if (win) flush(win)
})
}
12 changes: 12 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ import {
WORKSPACE_EXTERNAL_EDIT_DISMISS,
BOOT_SNAPSHOT_WRITE,
APP_OPEN_PATH,
APP_OPEN_URL,
APP_OPEN_URL_READY,
MENU_OPEN_SETTINGS,
MENU_TRIGGER_ACTION,
BROWSER_SHORTCUT,
Expand Down Expand Up @@ -766,6 +768,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
return createIpcListener(APP_OPEN_PATH, callback)
},

onOpenUrl(callback: (url: string) => void): () => void {
// Subscribe before announcing readiness so cold-launch URLs cannot be lost.
const unsubscribe = createIpcListener(APP_OPEN_URL, callback)
ipcRenderer.send(APP_OPEN_URL_READY, true)
return () => {
ipcRenderer.send(APP_OPEN_URL_READY, false)
unsubscribe()
}
},

// ---------------------------------------------------------------------------
// Dialog
// ---------------------------------------------------------------------------
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { useWindowRuntime } from './lib/hooks/useWindowRuntime'
import { closePanelWithConfirm, closePanelsWithConfirm } from './lib/closePanelWithConfirm'
import { IS_MAC } from './lib/platform'
import pkg from '../../package.json'
import { openWebUrl } from './lib/openWebUrl'
import { PersistentBrowserHostContext } from './panels/browserSurfaceRegistry'

const BackgroundBrowserHost = React.lazy(() => import('./panels/BackgroundBrowserHost'))
Expand Down Expand Up @@ -130,6 +131,10 @@ function MainApp() {

const sidebarTintOpacity = useSettingsStore((s) => s.sidebarTintOpacity)
const [initializing, setInitializing] = useState(true)
useEffect(() => {
if (initializing) return
return window.electronAPI.onOpenUrl(openWebUrl)
}, [initializing])
const initializedRef = useRef(false)
// Guards against stacking reload-confirm dialogs when the detector re-fires.
const reloadPromptOpenRef = useRef(false)
Expand Down
31 changes: 31 additions & 0 deletions src/renderer/lib/openWebUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { beforeEach, expect, it, vi } from 'vitest'
vi.mock('./terminal/terminalRegistry', () => ({
terminalRegistry: { dispose: vi.fn(), disposeWorkspace: vi.fn(), getEntry: vi.fn(), has: vi.fn(() => false) },
}))
import { useAppStore } from '../stores/appStore'
import { getOrCreateWorkspaceDockStore, releaseWorkspaceDockStore } from './workspace/dockRegistry'
import { openWebUrl } from './openWebUrl'

beforeEach(() => {
vi.stubGlobal('window', { electronAPI: {} })
for (const ws of useAppStore.getState().workspaces) releaseWorkspaceDockStore(ws.id)
useAppStore.setState({ workspaces: [], selectedWorkspaceId: '' })
})

it('opens web links in a visible browser without requiring a project folder', () => {
const id = openWebUrl('https://example.com/a?b=c#d')!
const app = useAppStore.getState()
const ws = app.getWorkspace(app.selectedWorkspaceId)!
expect(ws.rootPath).toBe('')
expect(ws.panels[id].tabs?.[0].url).toBe('https://example.com/a?b=c#d')
expect(getOrCreateWorkspaceDockStore(ws.id).getState().getPanelLocation(id)?.type).toBe('dock')
const second = openWebUrl('http://localhost:8080')!
expect(useAppStore.getState().workspaces).toHaveLength(1)
expect(useAppStore.getState().getWorkspace(ws.id)?.panels[second].type).toBe('browser')
})

it('does not create a workspace or panel for unsupported schemes', () => {
expect(openWebUrl('file:///tmp/test.html')).toBeNull()
expect(openWebUrl('javascript:alert(1)')).toBeNull()
expect(useAppStore.getState().workspaces).toEqual([])
})
16 changes: 16 additions & 0 deletions src/renderer/lib/openWebUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { isWebUrl } from '../../shared/webUrl'
import { useAppStore } from '../stores/appStore'
import { createInteractivePanel } from './panels/createInteractivePanel'

/** External links get a visible dock tab, including on a fresh installation
* with no project folder. Browsing does not require granting filesystem access. */
export function openWebUrl(url: string): string | null {
if (!isWebUrl(url)) return null
const app = useAppStore.getState()
const workspaceId = app.selectedWorkspaceId || app.addWorkspace()
return createInteractivePanel('browser', {
workspaceId,
url,
placement: { target: 'dock', zone: 'center' },
})
}
11 changes: 9 additions & 2 deletions src/renderer/panels/BackgroundBrowserHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ afterEach(() => {
})

describe('BackgroundBrowserHost', () => {
it('mounts a browser surface without requiring a project folder', async () => {
act(() => useAppStore.setState({ workspaces: [{ ...workspace('one'), rootPath: '' }], selectedWorkspaceId: 'one' }))
await renderHost()
expect(container.querySelector('[data-browser-panel="browser-one"]')).not.toBeNull()
expect(container.querySelector('[data-workspace-required]')).toBeNull()
})

it('hides portaled browsers during settings without remounting their guests', async () => {
await renderHost()
const browser = container.querySelector('[data-browser-panel="browser-one"]')
Expand Down Expand Up @@ -290,13 +297,13 @@ it('moves the retained T3 surface into its canvas slot without remounting it', a
expect(original.closest('[data-browser-surface-slot="agent-one"]')).not.toBeNull()
})

it('does not mount persistent browser or T3 guests for an unconfigured workspace', async () => {
it('mounts browsers but keeps T3 guests gated until a project folder is selected', async () => {
const ws = workspace('one')
ws.rootPath = ''
ws.panels.agent = { id: 'agent', type: 'agent', title: 'T3' } as never
act(() => useAppStore.setState({ workspaces: [ws] }))
await renderHost()
expect(container.querySelector('[data-browser-panel]')).toBeNull()
expect(container.querySelector('[data-browser-panel]')).not.toBeNull()
expect(container.querySelector('[data-retained-agent]')).toBeNull()
act(() => useAppStore.setState({ workspaces: [{ ...ws, rootPath: '/project' }] }))
expect(container.querySelector('[data-browser-panel]')).not.toBeNull()
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/panels/BackgroundBrowserHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const PersistentBrowserSurface = memo(function PersistentBrowserSurface({
useLayoutEffect(() => () => container.remove(), [container])

return createPortal(
<WorkspaceRequired workspaceId={workspaceId}>{panel.type === 'agent' ? <Suspense fallback={null}><AgentPanel panelId={panel.id} workspaceId={workspaceId} /></Suspense> : <BrowserPanel
<WorkspaceRequired workspaceId={workspaceId} requiresFolder={panel.type !== 'browser'}>{panel.type === 'agent' ? <Suspense fallback={null}><AgentPanel panelId={panel.id} workspaceId={workspaceId} /></Suspense> : <BrowserPanel
panelId={panel.id}
workspaceId={workspaceId}
tabs={panel.tabs!}
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/panels/PanelHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,11 @@ it.each(['canvas', 'terminal', 'editor', 'browser', 'agent', 'review', 'surface'
expect(host.textContent).toContain('No workspace selected')
expect(mounted).not.toHaveBeenCalled()
})


it.each(['browser', 'terminal'] as const)('allows only browser panels to render in a workspace without a project folder (%s)', (type) => {
registryMocks.renderPanelComponent.mockReturnValue(<span>Panel content</span>)
act(() => useAppStore.setState({ workspaces: [{ id: 'empty', rootPath: '', panels: {} } as never] }))
act(() => root.render(<PanelHost panelId="p" panels={{ p: panel('p', type) }} workspaceId="empty" />))
expect(host.textContent).toContain(type === 'browser' ? 'Panel content' : 'No workspace selected')
})
4 changes: 2 additions & 2 deletions src/renderer/panels/PanelHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ const PanelContent = memo(function PanelContent({
if (!panel) return null
if (!allowCanvas && !getPanelDef(panel.type).canLiveOnCanvas) return null
if ((panel.type === 'browser' || panel.type === 'agent') && persistentBrowserHost) {
return <WorkspaceRequired workspaceId={workspaceId}><BrowserPanelSurfaceSlot panelId={panel.id} /></WorkspaceRequired>
return <WorkspaceRequired workspaceId={workspaceId} requiresFolder={panel.type !== 'browser'}><BrowserPanelSurfaceSlot panelId={panel.id} /></WorkspaceRequired>
}
const content = renderPanelComponent(panel, {
workspaceId,
nodeId,
zoomLevel,
renderPanelContent,
})
return content ? <WorkspaceRequired workspaceId={workspaceId}><PanelSuspense key={panel.id}>{content}</PanelSuspense></WorkspaceRequired> : null
return content ? <WorkspaceRequired workspaceId={workspaceId} requiresFolder={panel.type !== 'browser'}><PanelSuspense key={panel.id}>{content}</PanelSuspense></WorkspaceRequired> : null
})
Loading
Loading