diff --git a/.lane/memory/apps/app/src/channel.tsx/01M5JRPBHXDKJV5CM9KV4R35YH-only-this-host-s-own-local-c.md b/.lane/memory/apps/app/src/channel.tsx/01M5JRPBHXDKJV5CM9KV4R35YH-only-this-host-s-own-local-c.md index 5675a271..161ddbf2 100644 --- a/.lane/memory/apps/app/src/channel.tsx/01M5JRPBHXDKJV5CM9KV4R35YH-only-this-host-s-own-local-c.md +++ b/.lane/memory/apps/app/src/channel.tsx/01M5JRPBHXDKJV5CM9KV4R35YH-only-this-host-s-own-local-c.md @@ -4,8 +4,9 @@ anchor: function Channel created: 2026-10-22T21:36:22Z norm: '1' sig: eaede8e100f39ef4 -body_hash: d29a81f75aaca2c5 -raw_hash: 24baf292b9594958 +body_hash: d4eb25af070d2db1 +raw_hash: 41a9c1eb90325b28 +vouched: 2026-10-27T15:34:57Z lines: 305-439 --- diff --git a/.lane/memory/apps/app/src/layout/layout.tsx/01M5JQ5PEWBK5ET4YKSMZ6VHE6-tabs-are-per-browser-window.md b/.lane/memory/apps/app/src/layout/layout.tsx/01M5JQ5PEWBK5ET4YKSMZ6VHE6-tabs-are-per-browser-window.md index e7bc38e4..18ea0274 100644 --- a/.lane/memory/apps/app/src/layout/layout.tsx/01M5JQ5PEWBK5ET4YKSMZ6VHE6-tabs-are-per-browser-window.md +++ b/.lane/memory/apps/app/src/layout/layout.tsx/01M5JQ5PEWBK5ET4YKSMZ6VHE6-tabs-are-per-browser-window.md @@ -4,8 +4,9 @@ anchor: function useLayout created: 2026-10-22T21:09:48Z norm: '1' sig: 6f69f0bcaa0b8bb2 -body_hash: 25edc7f0a5cbd93a -raw_hash: 01b3bdc9e40f5226 +body_hash: 88964a1f7f5adf12 +raw_hash: c41ee0b85dab63b9 +vouched: 2026-10-27T15:34:58Z lines: 755-999 --- diff --git a/.lane/memory/apps/app/src/panels/live.tsx/01M5YZZPJW4GZEWHECPRQ0KG88-status-is-polled-on-a-timer.md b/.lane/memory/apps/app/src/panels/live.tsx/01M5YZZPJW4GZEWHECPRQ0KG88-status-is-polled-on-a-timer.md new file mode 100644 index 00000000..97f001ef --- /dev/null +++ b/.lane/memory/apps/app/src/panels/live.tsx/01M5YZZPJW4GZEWHECPRQ0KG88-status-is-polled-on-a-timer.md @@ -0,0 +1,12 @@ +--- +id: 01M5YZZPJW4GZEWHECPRQ0KG88 +anchor: function Live +created: 2026-10-27T15:34:41Z +norm: '1' +sig: acb4d23736381074 +body_hash: 4a5367bec438cf3d +raw_hash: 713b5a7ee3ac4295 +lines: 13-83 +--- + +status is polled on a timer too; animation frames pause while Ace is covered diff --git a/.lane/memory/apps/desktop/native/live.m/01M5YZZPFECK4WJBTAX87MNMYE-the-live-view-must-stay-the.md b/.lane/memory/apps/desktop/native/live.m/01M5YZZPFECK4WJBTAX87MNMYE-the-live-view-must-stay-the.md new file mode 100644 index 00000000..01e39725 --- /dev/null +++ b/.lane/memory/apps/desktop/native/live.m/01M5YZZPFECK4WJBTAX87MNMYE-the-live-view-must-stay-the.md @@ -0,0 +1,8 @@ +--- +id: 01M5YZZPFECK4WJBTAX87MNMYE +anchor: function ace_live_show +created: 2026-10-27T15:34:41Z +norm: '1' +--- + +the live view must stay the content view's top subview; Electrobun re-stacks the webview above it on resize diff --git a/.lane/memory/apps/desktop/native/live.m/01M5YZZPH2ADYVC2F2ACMCP7B2-a-closed-window-s-scstream-s.md b/.lane/memory/apps/desktop/native/live.m/01M5YZZPH2ADYVC2F2ACMCP7B2-a-closed-window-s-scstream-s.md new file mode 100644 index 00000000..53b1f8c4 --- /dev/null +++ b/.lane/memory/apps/desktop/native/live.m/01M5YZZPH2ADYVC2F2ACMCP7B2-a-closed-window-s-scstream-s.md @@ -0,0 +1,12 @@ +--- +id: 01M5YZZPH2ADYVC2F2ACMCP7B2 +anchor: '@file' +created: 2026-10-27T15:34:41Z +norm: '1' +sig: f5f542a9730901ae +body_hash: 0d3d7801d2eeec11 +raw_hash: b7c0ca684f0d3c15 +lines: 1-249 +--- + +a closed window's SCStream stops without calling didStopWithError, so existence is polled diff --git a/.lane/memory/apps/desktop/src/index.ts/01M4YTCKVWY18KY0CFPD2F5TCD-each-browserview-needs-its-o.md b/.lane/memory/apps/desktop/src/index.ts/01M4YTCKVWY18KY0CFPD2F5TCD-each-browserview-needs-its-o.md index 870ea3bc..91a10add 100644 --- a/.lane/memory/apps/desktop/src/index.ts/01M4YTCKVWY18KY0CFPD2F5TCD-each-browserview-needs-its-o.md +++ b/.lane/memory/apps/desktop/src/index.ts/01M4YTCKVWY18KY0CFPD2F5TCD-each-browserview-needs-its-o.md @@ -4,9 +4,9 @@ anchor: function controls created: 2026-10-15T03:41:11Z norm: '1' sig: 93cc5b6836667bee -body_hash: 858640fcc9e00b29 -raw_hash: bc3a3bbeda94ba1f -vouched: 2026-10-15T14:10:43Z +body_hash: 4ebf778076e86026 +raw_hash: 192bdb24241a0f60 +vouched: 2026-10-27T15:34:58Z lines: 39-86 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md index b571ddc6..bb0619f4 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T04:08:49Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 177d0cc7c38c0a16 -raw_hash: 587e4316030f6250 -vouched: 2026-10-23T12:27:32Z +body_hash: aa81d073e484e685 +raw_hash: 9056b5baf04473f5 +vouched: 2026-10-27T15:34:58Z lines: 266-519 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md index 2affc11d..a13cba95 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T06:00:20Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 177d0cc7c38c0a16 -raw_hash: 587e4316030f6250 -vouched: 2026-10-23T12:27:32Z +body_hash: aa81d073e484e685 +raw_hash: 9056b5baf04473f5 +vouched: 2026-10-27T15:34:58Z lines: 281-561 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md index c857f807..8d31922e 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-19T21:09:57Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 177d0cc7c38c0a16 -raw_hash: 587e4316030f6250 -vouched: 2026-10-23T12:27:33Z +body_hash: aa81d073e484e685 +raw_hash: 9056b5baf04473f5 +vouched: 2026-10-27T15:34:58Z lines: 384-700 supersedes: 01M5APK9YCQH467YNMKAC8BT5B --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md index 680c1126..01fc20a7 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-20T17:04:56Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: fe20eb5454fb7a15 -raw_hash: f105febd5bb519a1 -vouched: 2026-10-23T15:02:23Z +body_hash: 1c4d9341aafad755 +raw_hash: e1a141ae7ad90111 +vouched: 2026-10-27T15:34:58Z lines: 1-1153 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md index 9f901bc4..879f00f9 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-22T14:00:47Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: fe20eb5454fb7a15 -raw_hash: f105febd5bb519a1 -vouched: 2026-10-23T15:02:24Z +body_hash: 1c4d9341aafad755 +raw_hash: e1a141ae7ad90111 +vouched: 2026-10-27T15:34:58Z lines: 1-1309 --- diff --git a/.lane/memory/packages/channel/src/tabs.ts/01M4YT9YGWM2WRSJYYQ1NF8XV5-only-local-channel-workers-i.md b/.lane/memory/packages/channel/src/tabs.ts/01M4YT9YGWM2WRSJYYQ1NF8XV5-only-local-channel-workers-i.md index 7eb790a5..1c019eac 100644 --- a/.lane/memory/packages/channel/src/tabs.ts/01M4YT9YGWM2WRSJYYQ1NF8XV5-only-local-channel-workers-i.md +++ b/.lane/memory/packages/channel/src/tabs.ts/01M4YT9YGWM2WRSJYYQ1NF8XV5-only-local-channel-workers-i.md @@ -4,8 +4,9 @@ anchor: function tabs created: 2026-10-15T03:39:44Z norm: '1' sig: dfcd4ce2368aa381 -body_hash: 2b698f7e26a845da -raw_hash: 8685549985734ece +body_hash: b83e13913bd23af1 +raw_hash: 0c3f1f5bff043e6f +vouched: 2026-10-27T15:34:58Z lines: 15-49 --- diff --git a/.lane/memory/packages/channel/src/tabs.ts/01M4YT9Z8ANRAQWK9AG48RJYZS-tab-tools-are-ace-layout-nev.md b/.lane/memory/packages/channel/src/tabs.ts/01M4YT9Z8ANRAQWK9AG48RJYZS-tab-tools-are-ace-layout-nev.md index 7aa322b3..49ad1022 100644 --- a/.lane/memory/packages/channel/src/tabs.ts/01M4YT9Z8ANRAQWK9AG48RJYZS-tab-tools-are-ace-layout-nev.md +++ b/.lane/memory/packages/channel/src/tabs.ts/01M4YT9Z8ANRAQWK9AG48RJYZS-tab-tools-are-ace-layout-nev.md @@ -4,8 +4,9 @@ anchor: function tabs created: 2026-10-15T03:39:45Z norm: '1' sig: dfcd4ce2368aa381 -body_hash: 2b698f7e26a845da -raw_hash: 8685549985734ece +body_hash: b83e13913bd23af1 +raw_hash: 0c3f1f5bff043e6f +vouched: 2026-10-27T15:34:58Z lines: 15-49 --- diff --git a/apps/app/src/channel.tsx b/apps/app/src/channel.tsx index f68dd54a..18874206 100644 --- a/apps/app/src/channel.tsx +++ b/apps/app/src/channel.tsx @@ -31,6 +31,7 @@ import { useAsked, useGit } from "./git"; import { host, onOpen as onHostOpen } from "./host"; import { CHAT, Layout } from "./layout/layout"; import { Diff, GROUPS } from "./panels/diff"; +import { Live } from "./panels/live"; import { Terminal } from "./panels/terminal"; import { placement } from "./placement"; import type { Item } from "./transcript"; @@ -409,6 +410,17 @@ export function Channel( if (data.type === "diff") { return ; } + if (data.type === "live") { + return ( + + ); + } if (data.type !== "terminal") return null; // Tabs saved before Terminal was withheld here still open, so they say why they're empty. if (elsewhere) { diff --git a/apps/app/src/desktop.ts b/apps/app/src/desktop.ts index 212a770c..a7afa653 100644 --- a/apps/app/src/desktop.ts +++ b/apps/app/src/desktop.ts @@ -25,6 +25,21 @@ export const desktop = view && token project: () => rpc.request.project({ token }), lights: (expanded: boolean) => rpc.request.lights({ token, expanded }), zoom: () => rpc.request.zoom({ token }), + live: ( + id: string, + window: number | null, + rect?: DOMRectReadOnly, + relink?: { app?: string }, + ) => + rpc.request.live({ + token, + id, + window, + ...(rect + ? { rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } } + : {}), + ...(relink ? { relink } : {}), + }), window: () => rpc.request.window({ token }), helper: (action: HelperAction) => rpc.request.helper({ token, action }), native: (action: NativeAction) => rpc.request.native({ token, action }), diff --git a/apps/app/src/layout/layout.tsx b/apps/app/src/layout/layout.tsx index b5b85895..99dc4b54 100644 --- a/apps/app/src/layout/layout.tsx +++ b/apps/app/src/layout/layout.tsx @@ -38,6 +38,7 @@ import { IconChevronDown, IconFileDiff, IconMessage, + IconMonitor, IconPencil, IconPlus, IconRows, @@ -122,12 +123,14 @@ const ICONS: Record = { chat: IconMessage, diff: IconFileDiff, terminal: IconTerminal, + live: IconMonitor, }; const TITLES: Record = { blank: "Blank", chat: "Chat", diff: "Diff", terminal: "Terminal", + live: "Live", }; function minimum(tab?: Split.Tab) { @@ -814,6 +817,11 @@ function useLayout( const command = useEffectEvent((request: TabRename) => { if (request.expires <= Date.now()) throw new Error("Tab rename expired"); if (request.channel !== id) throw new Error("This window is showing another channel"); + if (request.live !== undefined) { + flushSync(() => watch(request.live!, request.name)); + persist(); + return snapshot(); + } if (!api.get().tabs.includes(request.tab)) throw new Error("Tab is no longer open"); if (request.tab === CHAT) throw new Error("Rename the channel to change its Chat tab"); // The host must acknowledge the name that the client has actually rendered and saved. @@ -949,6 +957,37 @@ function useLayout( requestAnimationFrame(() => focus(uid)); } + // One tab per watched window, split to the right of the chat the first time. + function watch(target: number, name: string) { + // A relaunched app has a new window ID; its tab of the same name follows it. + const open = api.get().tabs.find((uid) => { + const value = meta[uid]; + return value?.type === "live" && (value.window === target || !!name && value.name === name); + }); + if (open) { + setMeta((meta) => ({ + ...meta, + [open]: { type: "live", window: target, ...(name ? { name } : undefined) }, + })); + return activate(open); + } + const uid = `tab-${api.get().seq + 1}`; + const pane = Split.host(api.get(), CHAT)?.uid; + const side = !flat && Split.can(api.get(), { ...fit, open: { to: { pane, side: "right" } } }); + const to: Split.Target | undefined = side + ? { pane, side: "right" } + : flat + ? undefined + : { pane }; + if (!api.open({ tab: uid, to, ...(side ? { fit } : {}) })) { + throw new Error("This Ace window has no room for another tab"); + } + setMeta((meta) => ({ + ...meta, + [uid]: { type: "live", window: target, ...(name ? { name } : undefined) }, + })); + } + function swap(uid: string, choice: Choice) { if (choice === "chat") { close(uid); diff --git a/apps/app/src/layout/storage.ts b/apps/app/src/layout/storage.ts index da439f3a..d6171995 100644 --- a/apps/app/src/layout/storage.ts +++ b/apps/app/src/layout/storage.ts @@ -7,7 +7,7 @@ export const CHAT = "chat"; const STORAGE = "ace:channel-layout"; const VERSION = 1; -const KINDS = new Set(["blank", "chat", "diff", "terminal"]); +const KINDS = new Set(["blank", "chat", "diff", "terminal", "live"]); /** Diff and Terminal tabs belong to the chat whose lane they show. */ export type Content = @@ -16,6 +16,9 @@ export type Content = | { type: "chat" } | { type: "diff"; chat: number } | { type: "terminal"; chat: number; terminal?: string } + // A native window on this Mac, drawn live over the pane; `window` is its CGWindowID and + // `app` the bundle ID that a relink looks for. + | { type: "live"; window: number; app?: string } ) & { name?: string }; @@ -33,6 +36,7 @@ function object(value: unknown): value is Record { export function content(kind: Kind, chat: number): Content { if (kind === "diff" || kind === "terminal") return { type: kind, chat }; + if (kind === "live") return { type: "blank" }; return { type: kind }; } @@ -49,6 +53,17 @@ function clean(state: Split.State, meta?: unknown): Meta { next[uid] = fallback(uid); continue; } + if (value.type === "live") { + next[uid] = Number.isInteger(value.window) + ? { + type: "live", + window: value.window as number, + ...(typeof value.app === "string" ? { app: value.app } : undefined), + ...(typeof value.name === "string" ? { name: value.name } : undefined), + } + : fallback(uid); + continue; + } const chat = Number.isInteger(value.chat) && (value.chat as number) > 0 ? value.chat as number : 1; diff --git a/apps/app/src/panels/live.tsx b/apps/app/src/panels/live.tsx new file mode 100644 index 00000000..c15c3d57 --- /dev/null +++ b/apps/app/src/panels/live.tsx @@ -0,0 +1,83 @@ +import { useEffect, useEffectEvent, useRef, useState } from "react"; + +import type { LiveStatus } from "@ace/desktop/protocol"; +import { Button } from "@ace/ui"; + +import { desktop } from "../desktop"; +import type { Content } from "../layout/storage"; + +/** How often a still pane asks whether its window is still there. */ +const CHECK = 500; + +/** Another app's window, drawn natively over this pane at its own frame rate. */ +export function Live( + { id, window, app, active, update }: { + id: string; + window: number; + app?: string; + active: boolean; + update: (patch: Partial) => void; + }, +) { + const ref = useRef(null); + const relink = useRef<{ app?: string }>(undefined); + const [lost, setLost] = useState(false); + + const report = useEffectEvent((status: LiveStatus | null) => { + if (!status) return; + setLost(status.state === "lost"); + // A relink moves the tab to the app's new window, and the app is kept for the next one. + if (status.state !== "live") return; + if (status.window !== window || status.app !== app) { + update({ window: status.window, app: status.app }); + } + }); + + useEffect(() => { + const live = desktop?.live; + const element = ref.current; + if (!live || !element || !active) return; + const send = () => { + const request = relink.current; + relink.current = undefined; + live(id, window, element.getBoundingClientRect(), request).then(report).catch(() => {}); + }; + // Pane drags and window resizes move the box without a React render, so follow it per frame. + let last = ""; + let frame = requestAnimationFrame(function follow() { + frame = requestAnimationFrame(follow); + const rect = element.getBoundingClientRect(); + const key = `${rect.x},${rect.y},${rect.width},${rect.height}`; + if (key === last) return; + last = key; + send(); + }); + // Frames pause while Ace is covered, and a closed window must still show its relink prompt. + const timer = setInterval(send, CHECK); + return () => { + cancelAnimationFrame(frame); + clearInterval(timer); + live(id, null).catch(() => {}); + }; + }, [id, window, active]); + + return ( + + {!desktop + ? "Live windows show only in Ace on the Mac running the app." + : lost + ? ( + + This window closed. If its app is open again, relink to its current window. + (relink.current = { app })}> + Relink + + + ) + : ""} + + ); +} diff --git a/apps/desktop/native/live.m b/apps/desktop/native/live.m new file mode 100644 index 00000000..45c98048 --- /dev/null +++ b/apps/desktop/native/live.m @@ -0,0 +1,249 @@ +#import +#import +#import +#import + +// A live view draws another app's window over a pane natively: ScreenCaptureKit delivers frames +// as IOSurfaces at the display's rate, and the layer shows them without copying them through the +// page. Covered windows still capture; minimized ones and those on other Spaces don't. + +@interface AceLiveView : NSView +@property(nonatomic) CGWindowID target; +/** The window being captured, which differs from `target` after a relink. */ +@property(nonatomic) CGWindowID current; +/** The app that owns `current`, so a relink can find its new window. */ +@property(nonatomic, copy) NSString *bundle; +@property(nonatomic, strong) SCStream *stream; +@property(nonatomic, strong) NSTimer *watch; +@property(nonatomic) BOOL stopped; +@property(nonatomic) BOOL lost; +/** Bumped by each start or relink, so a slow lookup can't attach a superseded window. */ +@property(nonatomic) NSUInteger generation; +@end + +static BOOL exists(CGWindowID window) { + const void *values[] = {(const void *)(uintptr_t)window}; + CFArrayRef ids = CFArrayCreate(NULL, values, 1, NULL); + CFArrayRef found = CGWindowListCreateDescriptionFromArray(ids); + BOOL result = found && CFArrayGetCount(found) > 0; + if (found) CFRelease(found); + CFRelease(ids); + return result; +} + +@implementation AceLiveView + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + self.wantsLayer = YES; + self.layer.contentsGravity = kCAGravityResizeAspect; + self.layer.backgroundColor = NSColor.clearColor.CGColor; + self.layer.cornerRadius = 8; + self.layer.masksToBounds = YES; + return self; +} + +// The pane underneath stays the page's: clicks and scrolls pass through to it. +- (NSView *)hitTest:(NSPoint)point { + return nil; +} + +- (void)start:(CGWindowID)target { + // A lost view waits for a relink rather than retrying every frame. + BOOL same = self.target == target || self.current == target; + if (same && (!self.stopped || self.lost)) { + self.target = target; + return; + } + [self stop]; + self.target = target; + self.bundle = nil; + [self attach:nil]; +} + +// Relinks to `app`'s largest normal window, for after it relaunched with a new window ID. +- (void)relink:(NSString *)app { + NSString *bundle = self.bundle ?: app; + [self stop]; + [self attach:bundle]; +} + +// Attaches the target window, or with `app`, that app's largest normal window. +- (void)attach:(NSString *)app { + self.stopped = NO; + self.lost = NO; + self.layer.contents = nil; + NSUInteger generation = ++self.generation; + CGWindowID target = self.target; + [SCShareableContent getShareableContentExcludingDesktopWindows:YES + onScreenWindowsOnly:NO + completionHandler:^(SCShareableContent *content, NSError *error) { + SCWindow *window = nil; + for (SCWindow *item in content.windows) { + if (app) { + if (![item.owningApplication.bundleIdentifier isEqualToString:app]) continue; + // Helper windows are tiny or off the normal layer. + if (item.windowLayer != 0 || item.frame.size.width < 100) continue; + if (!window || item.frame.size.width * item.frame.size.height + > window.frame.size.width * window.frame.size.height) window = item; + } else if (item.windowID == target) { + window = item; + } + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.stopped || self.generation != generation) return; + if (error || !window) return [self lose]; + [self capture:window]; + }); + }]; +} + +- (void)capture:(SCWindow *)window { + self.bundle = window.owningApplication.bundleIdentifier; + self.current = window.windowID; + SCContentFilter *filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:window]; + SCStreamConfiguration *config = [SCStreamConfiguration new]; + CGFloat scale = filter.pointPixelScale; + config.width = (size_t)(filter.contentRect.size.width * scale); + config.height = (size_t)(filter.contentRect.size.height * scale); + config.minimumFrameInterval = CMTimeMake(1, 60); + config.queueDepth = 4; + config.showsCursor = NO; + config.pixelFormat = 'BGRA'; + config.colorSpaceName = kCGColorSpaceSRGB; + SCStream *stream = [[SCStream alloc] initWithFilter:filter configuration:config delegate:self]; + NSError *added = nil; + [stream addStreamOutput:self + type:SCStreamOutputTypeScreen + sampleHandlerQueue:dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0) + error:&added]; + if (added) return [self lose]; + self.stream = stream; + // A closed window's stream goes quiet without reporting an error, so poll for it. + self.watch = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer *timer) { + if (self.stream != stream || exists(self.current)) return; + [self lost:stream]; + }]; + [stream startCaptureWithCompletionHandler:^(NSError *failed) { + if (!failed) return; + NSLog(@"Ace live view: %@", failed); + [self lost:stream]; + }]; +} + +- (void)lost:(SCStream *)stream { + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.stream == stream) [self lose]; + }); +} + +// Shows the page's relink prompt underneath; the app may have quit or relaunched. +- (void)lose { + [self stop]; + self.lost = YES; + self.layer.contents = nil; +} + +- (void)stop { + self.stopped = YES; + [self.watch invalidate]; + self.watch = nil; + SCStream *stream = self.stream; + self.stream = nil; + [stream stopCaptureWithCompletionHandler:nil]; +} + +- (void)stream:(SCStream *)stream + didOutputSampleBuffer:(CMSampleBufferRef)buffer + ofType:(SCStreamOutputType)type { + if (type != SCStreamOutputTypeScreen || !CMSampleBufferIsValid(buffer)) return; + // Frames with no new content carry no pixel buffer. + CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(buffer, NO); + if (attachments && CFArrayGetCount(attachments)) { + NSDictionary *info = (__bridge NSDictionary *)CFArrayGetValueAtIndex(attachments, 0); + NSNumber *status = info[SCStreamFrameInfoStatus]; + if (status && status.integerValue != SCFrameStatusComplete) return; + } + CVPixelBufferRef pixels = CMSampleBufferGetImageBuffer(buffer); + if (!pixels) return; + IOSurfaceRef surface = CVPixelBufferGetIOSurface(pixels); + if (!surface) return; + id contents = (__bridge id)surface; + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.stream != stream) return; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + self.layer.contents = contents; + [CATransaction commit]; + }); +} + +// The window closed or its app quit. +- (void)stream:(SCStream *)stream didStopWithError:(NSError *)error { + NSLog(@"Ace live view stopped: %@", error); + [self lost:stream]; +} + +@end + +static NSMutableDictionary *views(void) { + static NSMutableDictionary *value; + static dispatch_once_t once; + dispatch_once(&once, ^{ value = [NSMutableDictionary new]; }); + return value; +} + +/** + * Shows `target` at the page's CSS rect (top-left origin, in points) within the window, and + * returns "wait", "lost", or "live ". The text lasts until the next call. + */ +const char *ace_live_show(void *pointer, const char *key, uint32_t target, double x, double y, double w, double h) { + NSString *id = [NSString stringWithUTF8String:key]; + static char status[512]; + dispatch_sync(dispatch_get_main_queue(), ^{ + NSWindow *window = (__bridge NSWindow *)pointer; + NSView *content = window.contentView; + NSRect frame = NSMakeRect(x, content.bounds.size.height - y - h, w, h); + AceLiveView *view = views()[id]; + if (!view || view.window != window) { + [view stop]; + [view removeFromSuperview]; + view = [[AceLiveView alloc] initWithFrame:frame]; + views()[id] = view; + [content addSubview:view positioned:NSWindowAbove relativeTo:nil]; + } + // Resizing the window re-stacks the webview above other subviews. + if (content.subviews.lastObject != view) { + [content addSubview:view positioned:NSWindowAbove relativeTo:nil]; + } + view.frame = frame; + [view start:target]; + view.hidden = view.lost; + NSString *text = view.lost ? @"lost" + : view.stream ? [NSString stringWithFormat:@"live %u %@", view.current, view.bundle] + : @"wait"; + strlcpy(status, text.UTF8String, sizeof status); + }); + return status; +} + +/** Relinks a lost view to its app's current window; `app` is the bundle if this process never saw it. */ +void ace_live_relink(const char *key, const char *app) { + NSString *id = [NSString stringWithUTF8String:key]; + NSString *bundle = app[0] ? [NSString stringWithUTF8String:app] : nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + AceLiveView *view = views()[id]; + if (view.bundle || bundle) [view relink:bundle]; + }); +} + +void ace_live_hide(const char *key) { + NSString *id = [NSString stringWithUTF8String:key]; + dispatch_sync(dispatch_get_main_queue(), ^{ + AceLiveView *view = views()[id]; + if (!view) return; + [view stop]; + [view removeFromSuperview]; + [views() removeObjectForKey:id]; + }); +} diff --git a/apps/desktop/scripts/helper.ts b/apps/desktop/scripts/helper.ts index 5b156ad8..d3a8769e 100644 --- a/apps/desktop/scripts/helper.ts +++ b/apps/desktop/scripts/helper.ts @@ -140,7 +140,14 @@ run([ "Cocoa", "-framework", "QuartzCore", + "-framework", + "ScreenCaptureKit", + "-framework", + "CoreMedia", + "-framework", + "CoreVideo", join(native, "window.m"), + join(native, "live.m"), "-o", join(bin, "ace-window.dylib"), ]); diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts index f8f21e6e..4a58661d 100644 --- a/apps/desktop/src/index.ts +++ b/apps/desktop/src/index.ts @@ -11,7 +11,7 @@ import { helper as control } from "./helper"; import { native as inspection } from "./native"; import type { DesktopRPC } from "./protocol"; import { updates as updater } from "./updates"; -import { windowStyle } from "./window"; +import { live, windowStyle } from "./window"; const resources = join(dirname(process.execPath), "..", "Resources"); const { channel, identifier, version } = await Bun.file(join(resources, "version.json")).json() as { @@ -53,6 +53,15 @@ function controls(target: () => BrowserWindow) { windowStyle.lights(target().ptr, expanded); return null; }, + live: ({ token, id, window, rect, relink }) => { + authorize(token); + if (window === null || !rect) { + live.hide(id); + return null; + } + if (relink) live.relink(id, relink.app); + return live.show(target().ptr, id, window, rect); + }, zoom: ({ token }) => { authorize(token); windowStyle.zoom(target().ptr); diff --git a/apps/desktop/src/protocol.ts b/apps/desktop/src/protocol.ts index a141731f..726e0ff5 100644 --- a/apps/desktop/src/protocol.ts +++ b/apps/desktop/src/protocol.ts @@ -8,6 +8,11 @@ export type HelperState = { export type HelperAction = "status" | "start" | "stop" | "restart" | "settings" | "log"; +/** A live view's capture: a lost window waits for a relink. */ +export type LiveStatus = + | { state: "wait" | "lost" } + | { state: "live"; window: number; app: string }; + export type NativeState = { state: "stopped" | "starting" | "ready" | "stopping" | "error"; accessibility: boolean; @@ -77,6 +82,17 @@ export type DesktopRPC = { project: { params: { token: string }; response: string | null }; lights: { params: { token: string; expanded: boolean }; response: null }; zoom: { params: { token: string }; response: null }; + live: { + params: { + token: string; + id: string; + window: number | null; + rect?: { x: number; y: number; width: number; height: number }; + /** Relinks a lost window to this app's (a bundle ID) current window. */ + relink?: { app?: string }; + }; + response: LiveStatus | null; + }; window: { params: { token: string }; response: null }; helper: { params: { token: string; action: HelperAction }; response: HelperState }; native: { params: { token: string; action: NativeAction }; response: NativeState }; diff --git a/apps/desktop/src/window.ts b/apps/desktop/src/window.ts index 83a9d734..6e007524 100644 --- a/apps/desktop/src/window.ts +++ b/apps/desktop/src/window.ts @@ -2,14 +2,50 @@ import { dirname, join } from "node:path"; import { dlopen } from "bun:ffi"; +import type { LiveStatus } from "./protocol"; + const { symbols } = dlopen(join(dirname(process.execPath), "ace-window.dylib"), { ace_window_setup: { args: ["ptr"], returns: "void" }, ace_window_lights: { args: ["ptr", "bool"], returns: "void" }, ace_window_zoom: { args: ["ptr"], returns: "void" }, + ace_live_show: { + args: ["ptr", "cstring", "u32", "f64", "f64", "f64", "f64"], + returns: "cstring", + }, + ace_live_relink: { args: ["cstring", "cstring"], returns: "void" }, + ace_live_hide: { args: ["cstring"], returns: "void" }, }); +const text = (value: string) => Buffer.from(value + "\0"); + export const windowStyle = { setup: symbols.ace_window_setup, lights: symbols.ace_window_lights, zoom: symbols.ace_window_zoom, }; + +export const live = { + show: ( + window: Parameters[0], + id: string, + target: number, + rect: Rect, + ): LiveStatus => { + const [state, current, app] = symbols.ace_live_show( + window, + text(id), + target, + rect.x, + rect.y, + rect.width, + rect.height, + ).toString().split(" "); + return state === "live" + ? { state, window: Number(current), app: app! } + : { state: state as "wait" | "lost" }; + }, + relink: (id: string, app?: string) => symbols.ace_live_relink(text(id), text(app || "")), + hide: (id: string) => symbols.ace_live_hide(text(id)), +}; + +export type Rect = { x: number; y: number; width: number; height: number }; diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index d7dcfbd0..b1d2a2d2 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -477,7 +477,13 @@ async function lifecycle( } const owned = new Set(["archive", "archive-inactive", "delete", "fork"]); -const windowOps = new Set(["window", "windows", "tab-rename", "tab-result"]); +const windowOps = new Set([ + "window", + "windows", + "tab-rename", + "tab-live", + "tab-result", +]); const moveOps = new Set(["move-targets", "move"]); const keychainOps = new Set([ "key-store", @@ -580,6 +586,8 @@ async function handle( return windows.list(); case "tab-rename": return windows.rename(request); + case "tab-live": + return windows.live(request); case "tab-result": return windows.result(socket, request.call, request.result); case "update-prepare": { diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index 7b297842..238709ea 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -226,7 +226,7 @@ export type People = Record; export type Tab = { id: string; name: string; - type: "blank" | "chat" | "diff" | "terminal"; + type: "blank" | "chat" | "diff" | "terminal" | "live"; /** Selected in its pane; split layouts can show several active tabs. */ active: boolean; }; @@ -244,6 +244,8 @@ export type TabRename = { tab: string; name: string; expires: number; + /** Opens a new tab showing this native window (a CGWindowID) live; `tab` is then empty. */ + live?: number; }; export type TabResult = { ok: true; value: WindowState } | { ok: false; error: string }; @@ -317,6 +319,7 @@ export type HostRequest = /** Connected channel windows on the owner's local gateway. */ | { op: "windows" } | { op: "tab-rename"; window: string; channel: string; tab: string; name: string } + | { op: "tab-live"; window: string; channel: string; target: number; name: string } /** Only the window that received a rename can acknowledge it. */ | { op: "tab-result"; call: string; result: TabResult } | { op: "channels" } diff --git a/apps/host/src/tabs.ts b/apps/host/src/tabs.ts index f6a4ff2e..42c9106c 100644 --- a/apps/host/src/tabs.ts +++ b/apps/host/src/tabs.ts @@ -16,5 +16,7 @@ export function tabs(channel: string): Tabs { .map(window), rename: async ({ window: id, tab, name }) => window(await local({ op: "tab-rename", window: id, channel, tab, name })), + live: async ({ window: id, target, name }) => + window(await local({ op: "tab-live", window: id, channel, target, name })), }; } diff --git a/apps/host/src/windows.ts b/apps/host/src/windows.ts index 7022c2d7..ffb47c9f 100644 --- a/apps/host/src/windows.ts +++ b/apps/host/src/windows.ts @@ -65,7 +65,26 @@ export function rename(request: Extract): Pro if (tab.type === "chat") { throw new Error("The Chat tab follows the channel name and cannot be renamed"); } - const name = request.name.trim(); + return send(window, request.channel, request.tab, request.name); +} + +export function live(request: Extract): Promise { + const window = windows.get(request.window); + if (!window) throw new Error("No connected Ace window with that ID; list tabs again"); + if (window.value.channel.id !== request.channel) { + throw new Error("That Ace window is showing a different channel; list tabs again"); + } + return send(window, request.channel, "", request.name, request.target); +} + +function send( + window: Window, + channel: string, + tab: string, + text: string, + target?: number, +): Promise { + const name = text.trim(); if (name.length > 80) throw new Error("A tab name must be at most 80 characters"); const call = crypto.randomUUID(); return new Promise((resolve, reject) => { @@ -75,8 +94,8 @@ export function rename(request: Extract): Pro }, TIMEOUT); pending.set(call, { window, - channel: request.channel, - tab: request.tab, + channel, + tab, resolve, reject, timer, @@ -86,10 +105,11 @@ export function rename(request: Extract): Pro { rename: { call, - channel: request.channel, - tab: request.tab, + channel, + tab, name, expires: Date.now() + TIMEOUT, + ...(target === undefined ? {} : { live: target }), }, } satisfies HostFrame, )); @@ -113,7 +133,7 @@ export function result(socket: Socket, call: string, result: TabResult): void { if (!result.ok) return request.reject(new Error(result.error)); if ( result.value.channel.id !== request.channel - || !result.value.tabs.some((tab) => tab.id === request.tab) + || (request.tab && !result.value.tabs.some((tab) => tab.id === request.tab)) ) { return request.reject( new Error("The Ace window changed channels or closed the tab during rename"), diff --git a/packages/channel/src/tabs.ts b/packages/channel/src/tabs.ts index be3eaaaf..54de0676 100644 --- a/packages/channel/src/tabs.ts +++ b/packages/channel/src/tabs.ts @@ -9,6 +9,10 @@ export type Window = { id: string; tabs: Tab[] }; export type Tabs = { list(context: Context): Promise; rename(request: { window: string; tab: string; name: string }, context: Context): Promise; + live( + request: { window: string; target: number; name: string }, + context: Context, + ): Promise; }; /** Ace's own layout, so it is never gated by native desktop tools. */ @@ -44,6 +48,20 @@ export function tabs(layout: Tabs): Extension { content: [{ type: "text", text: JSON.stringify(await layout.rename(args, context)) }], }), }), + defineTool({ + name: "tab_live", + description: + "Show a native app window from this Mac live in a split beside the chat, in an Ace app window from tabs, so people watch the app at full frame rate while you work in it. `target` is the window's original_window_id from desktop_windows. The window keeps updating while covered by Ace, so keep it behind Ace; minimized windows and those on another Space stop updating. Opening the same target again selects its existing tab.", + parameters: Type.Object({ + window: Type.String({ minLength: 1, maxLength: 64 }), + target: Type.Integer({ minimum: 1 }), + name: Type.String({ maxLength: 80 }), + }, { additionalProperties: false }), + replay: "safe", + execute: async (args, _api, context) => ({ + content: [{ type: "text", text: JSON.stringify(await layout.live(args, context)) }], + }), + }), ], }); }
This window closed. If its app is open again, relink to its current window.