diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b5a3e4..e4e4ff21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/electron-builder.yml b/electron-builder.yml index aa796efa..654a4524 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 22966b75..4dde2737 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cate", - "version": "2.0.2", + "version": "2.0.3-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cate", - "version": "2.0.2", + "version": "2.0.3-beta.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 75da60b1..2c6654c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/index.ts b/src/main/index.ts index 604be878..895949e9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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' @@ -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() diff --git a/src/main/lifecycle/openUrl.test.ts b/src/main/lifecycle/openUrl.test.ts new file mode 100644 index 00000000..554705d3 --- /dev/null +++ b/src/main/lifecycle/openUrl.test.ts @@ -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) + }) +}) diff --git a/src/main/lifecycle/openUrl.ts b/src/main/lifecycle/openUrl.ts new file mode 100644 index 00000000..70376f32 --- /dev/null +++ b/src/main/lifecycle/openUrl.ts @@ -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() + const tracked = new WeakSet() + 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) + }) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 853b544c..402edc88 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, @@ -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 // --------------------------------------------------------------------------- diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 80211f72..d7a0b915 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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')) @@ -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) diff --git a/src/renderer/lib/openWebUrl.test.ts b/src/renderer/lib/openWebUrl.test.ts new file mode 100644 index 00000000..dcd0dc04 --- /dev/null +++ b/src/renderer/lib/openWebUrl.test.ts @@ -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([]) +}) diff --git a/src/renderer/lib/openWebUrl.ts b/src/renderer/lib/openWebUrl.ts new file mode 100644 index 00000000..951c44b3 --- /dev/null +++ b/src/renderer/lib/openWebUrl.ts @@ -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' }, + }) +} diff --git a/src/renderer/panels/BackgroundBrowserHost.test.tsx b/src/renderer/panels/BackgroundBrowserHost.test.tsx index ef891533..c13594f3 100644 --- a/src/renderer/panels/BackgroundBrowserHost.test.tsx +++ b/src/renderer/panels/BackgroundBrowserHost.test.tsx @@ -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"]') @@ -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() diff --git a/src/renderer/panels/BackgroundBrowserHost.tsx b/src/renderer/panels/BackgroundBrowserHost.tsx index 48c7a829..e66c381d 100644 --- a/src/renderer/panels/BackgroundBrowserHost.tsx +++ b/src/renderer/panels/BackgroundBrowserHost.tsx @@ -42,7 +42,7 @@ const PersistentBrowserSurface = memo(function PersistentBrowserSurface({ useLayoutEffect(() => () => container.remove(), [container]) return createPortal( - {panel.type === 'agent' ? : {panel.type === 'agent' ? : { + registryMocks.renderPanelComponent.mockReturnValue(Panel content) + act(() => useAppStore.setState({ workspaces: [{ id: 'empty', rootPath: '', panels: {} } as never] })) + act(() => root.render()) + expect(host.textContent).toContain(type === 'browser' ? 'Panel content' : 'No workspace selected') +}) diff --git a/src/renderer/panels/PanelHost.tsx b/src/renderer/panels/PanelHost.tsx index 5fd2b084..836f5523 100644 --- a/src/renderer/panels/PanelHost.tsx +++ b/src/renderer/panels/PanelHost.tsx @@ -55,7 +55,7 @@ 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 + return } const content = renderPanelComponent(panel, { workspaceId, @@ -63,5 +63,5 @@ const PanelContent = memo(function PanelContent({ zoomLevel, renderPanelContent, }) - return content ? {content} : null + return content ? {content} : null }) diff --git a/src/renderer/panels/WorkspaceRequired.tsx b/src/renderer/panels/WorkspaceRequired.tsx index 55ec2f4a..717b2ed5 100644 --- a/src/renderer/panels/WorkspaceRequired.tsx +++ b/src/renderer/panels/WorkspaceRequired.tsx @@ -6,8 +6,12 @@ import { Button } from '../ui/Button' import { LoadingState } from '../ui/Spinner' /** Shared mount boundary for all panel types, including persistent guests. */ -export function WorkspaceRequired({ workspaceId, children }: { workspaceId: string; children: ReactNode }) { - const availability = useAppStore((s) => workspaceAvailability(s.workspaces.find((w) => w.id === workspaceId))) +export function WorkspaceRequired({ workspaceId, children, requiresFolder = true }: { workspaceId: string; children: ReactNode; requiresFolder?: boolean }) { + const availability = useAppStore((s) => { + const workspace = s.workspaces.find((w) => w.id === workspaceId) + // Web browsing needs a workspace to own its panels, but no project folder. + return workspace && !requiresFolder ? 'ready' : workspaceAvailability(workspace) + }) if (availability === 'ready') return <>{children} return
{availability === 'opening' ? :
diff --git a/src/runtime/version.ts b/src/runtime/version.ts index ec9df13d..ad490c9d 100644 --- a/src/runtime/version.ts +++ b/src/runtime/version.ts @@ -6,4 +6,4 @@ // matching runtime tarballs (see runtimeArtifacts.ts). // ============================================================================= -export const RUNTIME_VERSION = '2.0.2' +export const RUNTIME_VERSION = '2.0.3-beta.1' diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 5f27853e..18b4b648 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -508,6 +508,7 @@ export interface ElectronAPI { * dropped a folder on the dock icon or opened one via "Open With Cate". * Returns an unsubscribe function. */ onOpenPath(callback: (filePath: string) => void): () => void + onOpenUrl(callback: (url: string) => void): () => void // --------------------------------------------------------------------------- // Dialog diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 3b70f35e..743c6bd0 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -153,6 +153,8 @@ export const BOOT_SNAPSHOT_WRITE = 'boot:snapshotWrite' // renderer -> main /** Main -> renderer: user dropped a folder on the dock icon (or opened one * via OS "Open With..."). Renderer opens it as a new workspace. */ export const APP_OPEN_PATH = 'app:openPath' +export const APP_OPEN_URL = 'app:openUrl' +export const APP_OPEN_URL_READY = 'app:openUrlReady' // Auto-updater — in-app "update ready" modal // Main -> renderer: update lifecycle status. Payload: UpdateStatus diff --git a/src/shared/webUrl.ts b/src/shared/webUrl.ts new file mode 100644 index 00000000..db010686 --- /dev/null +++ b/src/shared/webUrl.ts @@ -0,0 +1,10 @@ +/** OS browser opens accept web URLs only, never file or executable schemes. */ +export function isWebUrl(value: unknown): value is string { + if (typeof value !== 'string') return false + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +}