anvilsign in

collin/browser-terminal-extension

main / extension / sw.js
1// Background worker.
2//
3// Its one real job is the element picker's keyboard shortcut. `activeTab` — the
4// permission that lets us touch a page without asking for <all_urls> — is only
5// granted on specific user gestures: a toolbar-action click, a context menu
6// item, or a keyboard command. A click inside the side panel is not one of
7// them, so the in-panel button can fail while the shortcut always works. Both
8// paths exist; this is the reliable one.
9
10// One of the two always exists — this file only ever runs as an extension
11// background script — but neither is declared unconditionally, so say which.
12const api = /** @type {TbExtensionApi} */ (globalThis.browser ?? globalThis.chrome);
13
14// Chrome's service worker starts empty; Firefox loads picker.js alongside this
15// file via background.scripts, so there it is already defined.
16if (typeof tbPickElement === "undefined" && typeof importScripts === "function") {
17 importScripts("picker.js", "lib/shot.js", "lib/split.js");
18}
19
20// Chrome only: make the toolbar button open the side panel.
21if (api.sidePanel?.setPanelBehavior) {
22 api.sidePanel
23 .setPanelBehavior({ openPanelOnActionClick: true })
24 .catch((e) => console.error(e));
25}
26
27const SKIP = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/;
28
29// The tabs a shortcut-started pick is running in. Empty means none is.
30let pickTabs = /** @type {number[]} */ ([]);
31
32async function runPicker() {
33 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
34 if (!tab || SKIP.test(tab.url ?? "")) {
35 console.warn("termbridge: cannot pick on this page", tab?.url);
36 return;
37 }
38
39 // Both halves of a split view, when this is one. The activeTab grant the
40 // command just minted covers only the active half, so the partner half is
41 // reachable only where its origin was granted — Split.racePick treats that
42 // refusal as one runner dropping out, not as the pick failing.
43 const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP.test(t.url ?? ""));
44 // Remembered so the panel can call the pick off: this path's Escape has to
45 // work from the panel too, and the panel doesn't know this pick exists.
46 pickTabs = [];
47 for (const t of targets) if (t.id != null) pickTabs.push(t.id);
48 const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement);
49 pickTabs = [];
50 if (error) console.error("termbridge: pick failed", error);
51 if (!value) return;
52
53 // Screenshot here rather than in the sidebar: this path's page access comes
54 // from the activeTab grant the keyboard command just minted, and that grant
55 // belongs to the worker.
56 const shot = await Shot.copyPickedShot(api, tabId, value);
57 if (shot !== true) console.warn("termbridge: no screenshot", shot);
58
59 // The sidebar may not be open. Try to hand it over directly, and fall back to
60 // storage so the pick isn't lost — the sidebar drains it when it next opens.
61 try {
62 await api.runtime.sendMessage({ type: "picked", value, shot: shot === true });
63 } catch {
64 await api.storage.local.set({ pendingPick: value });
65 }
66}
67
68// --- the toggle shortcut -----------------------------------------------------
69//
70// Neither browser will tell us whether the panel is open, so the panel tells
71// us: it holds a port open for as long as it is alive and reports its own
72// focus over it. That state has to be here rather than fetched on demand,
73// because opening a side panel is only allowed during a user gesture, and a
74// round trip to the panel and back outlives the gesture the shortcut minted.
75//
76// The port carries three fixed commands and no data. It is deliberately not a
77// second route into the terminal — see the note at the top of sidebar.js.
78
79/** @type {Map<number, { port: TbPort, focused: boolean }>} */
80const panels = new Map();
81
82// Windows whose panel is being closed by the toggle shortcut, Chrome only —
83// see focusActiveTabContent below for why this waits for the disconnect.
84/** @type {Set<number>} */
85const closingForToggle = new Set();
86
87api.runtime.onConnect.addListener((port) => {
88 if (port.name !== "sidebar") return;
89 // Content scripts can connect too. Ours is an extension page: no sender.tab.
90 if (port.sender?.tab || port.sender?.id !== api.runtime.id) {
91 port.disconnect();
92 return;
93 }
94 /** @type {number | null} */
95 let windowId = null;
96 port.onMessage.addListener((msg) => {
97 if (msg?.type === "hello" && typeof msg.windowId === "number") {
98 windowId = /** @type {number} */ (msg.windowId);
99 panels.set(windowId, { port, focused: !!msg.focused });
100 } else if (msg?.type === "cancel-pick") {
101 // Escape in the panel, for a pick the shortcut started here. Harmless
102 // when nothing is picking: the list is empty and this does nothing.
103 const ids = pickTabs;
104 pickTabs = [];
105 if (ids.length) Split.cancelPicks(api, ids);
106 } else if (msg?.type === "focus" && windowId != null) {
107 const entry = panels.get(windowId);
108 if (entry?.port === port) entry.focused = !!msg.focused;
109 }
110 });
111 port.onDisconnect.addListener(() => {
112 if (windowId != null && panels.get(windowId)?.port === port) panels.delete(windowId);
113 // Chrome only: the panel document is actually gone now, so this is the
114 // first moment stealing focus back for the tab can win against whatever
115 // default target Chrome just picked for itself.
116 if (windowId != null && closingForToggle.delete(windowId)) focusActiveTabContent(windowId);
117 });
118});
119
120// Chrome only allows opening the panel while the gesture that the command
121// minted is still live, and a single `await` — even one that resolves
122// immediately — spends it. So everything from the command listener down to the
123// open call is synchronous, and the window id comes from the tab the listener
124// hands us rather than from a windows.getLastFocused() round trip.
125/** @param {number} windowId */
126function openPanel(windowId) {
127 const opened = api.sidePanel?.open
128 ? api.sidePanel.open({ windowId })
129 : api.sidebarAction?.open();
130 // Chrome still rejects if the gesture was somehow already spent; that is not
131 // worth an unhandled rejection in the worker's console.
132 Promise.resolve(opened).catch((e) => console.warn("termbridge: cannot open panel", e));
133}
134
135/** @param {number} windowId */
136function focusActiveTabContent(windowId) {
137 if (!api.scripting?.executeScript) return;
138 api.tabs
139 .query({ active: true, windowId })
140 .then(([tab]) => {
141 if (!tab || typeof tab.id !== "number" || SKIP.test(tab.url ?? "")) return;
142 return api.scripting.executeScript({ target: { tabId: tab.id }, func: () => window.focus() });
143 })
144 .catch(() => {});
145}
146
147/** @param {number | undefined} windowId */
148function toggleSidebar(windowId) {
149 // Firefox only started passing the tab to command listeners in 106; without
150 // it the gesture is lost, but sidebarAction has no such restriction.
151 if (windowId == null) {
152 api.windows.getLastFocused().then((w) => toggleSidebar(w.id));
153 return;
154 }
155 const entry = panels.get(windowId);
156
157 if (!entry) {
158 openPanel(windowId);
159 return;
160 }
161
162 if (entry.focused) {
163 // Neither browser reliably hands keyboard focus back to the page once the
164 // panel it was in disappears — it can land on the omnibox or nowhere.
165 // window.focus(), run in the page itself, is the one thing that reclaims it.
166 if (api.sidebarAction?.close) {
167 // Firefox: the sidebar document just gets hidden, not unloaded, so it is
168 // safe to steal focus back right away.
169 api.sidebarAction.close();
170 focusActiveTabContent(windowId);
171 } else {
172 // Chrome: the panel document is destroyed by window.close(), and Chrome
173 // assigns its own new focus target the moment that happens — a
174 // window.focus() issued before then just loses that race. Wait for the
175 // port to disconnect, which fires once the panel is actually gone.
176 closingForToggle.add(windowId);
177 entry.port.postMessage({ type: "close" });
178 }
179 return;
180 }
181
182 // Open but focus is elsewhere — put the caret back in the terminal. The
183 // panel is already showing, so the open call is only there to hand it focus.
184 entry.port.postMessage({ type: "focus" });
185 openPanel(windowId);
186}
187
188// Put the caret in the panel's jump bar. Same port and the same reasoning as
189// the toggle: whether the panel is open is state only the panel can report.
190//
191// Deliberately does nothing unless the panel is already open and holding the
192// keyboard: this is the panel's own jump-box key, not a way to open the panel
193// or steal focus away from whatever page the user is on.
194/** @param {number | undefined} windowId */
195function focusOmnibar(windowId) {
196 if (windowId == null) {
197 api.windows.getLastFocused().then((w) => focusOmnibar(w.id));
198 return;
199 }
200 const entry = panels.get(windowId);
201 if (!entry?.focused) return;
202 entry.port.postMessage({ type: "omnibar" });
203}
204
205// --- handing a tab to Claude in Chrome ---------------------------------------
206//
207// Not the Claude the rest of termbridge means: that one is Claude Code in a
208// tmux pane, reached through the daemon. This is the browser extension, and it
209// only drives tabs that sit inside its own tab group — a tab outside that group
210// is invisible to it. Membership is the entire gate, so handing a page over is
211// a tabs.group() call and nothing else.
212//
213// Which group is Claude's is a question only the user can answer. The group
214// carries no title to match on — Claude's comes back untitled — so
215// `set-claude-group` records the active tab's group id, and until that has
216// happened a single group in the browser is taken to be the one.
217//
218// Chrome only: Firefox has no tab group API, so api.tabGroups is undefined
219// there and both commands no-op.
220
221const CLAUDE_GROUP_KEY = "claudeGroupId";
222
223/** @returns {Promise<TbTabGroup | null>} */
224async function resolveClaudeGroup() {
225 const tabGroups = api.tabGroups;
226 if (!tabGroups) return null;
227
228 const stored = (await api.storage.local.get(CLAUDE_GROUP_KEY))[CLAUDE_GROUP_KEY];
229 if (typeof stored === "number") {
230 try {
231 return await tabGroups.get(stored);
232 } catch {
233 // Recorded group has been closed since. Fall through and rediscover.
234 }
235 }
236
237 const groups = await tabGroups.query({});
238 if (groups.length === 1) {
239 await api.storage.local.set({ [CLAUDE_GROUP_KEY]: groups[0].id });
240 return groups[0];
241 }
242 return null;
243}
244
245async function handTabToClaude() {
246 const groupTabs = api.tabs.group;
247 if (!groupTabs) return;
248
249 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
250 if (!tab || typeof tab.id !== "number") return;
251 // The same pages the picker cannot touch are pages Claude cannot drive.
252 if (SKIP.test(tab.url ?? "")) {
253 console.warn("termbridge: Claude cannot drive this page", tab.url);
254 return;
255 }
256
257 const group = await resolveClaudeGroup();
258 if (!group) {
259 console.warn(
260 "termbridge: no Claude group recorded — put a tab in Claude's group and run set-claude-group",
261 );
262 return;
263 }
264 if (tab.groupId === group.id) return;
265
266 try {
267 // group() will not reach across windows, and Claude's group usually sits in
268 // one of its own, so the tab has to travel first.
269 if (tab.windowId !== group.windowId) {
270 await api.tabs.move(tab.id, { windowId: group.windowId, index: -1 });
271 }
272 await groupTabs({ groupId: group.id, tabIds: [tab.id] });
273 await api.tabs.update(tab.id, { active: true });
274 await api.windows.update(group.windowId, { focused: true });
275 } catch (e) {
276 console.error("termbridge: could not hand the tab over", e);
277 }
278}
279
280async function setClaudeGroup() {
281 if (!api.tabGroups) return;
282
283 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
284 // Chrome reports -1 for a tab that is in no group.
285 if (!tab || typeof tab.groupId !== "number" || tab.groupId < 0) {
286 console.warn("termbridge: this tab is in no group — drag it into Claude's group first");
287 return;
288 }
289 await api.storage.local.set({ [CLAUDE_GROUP_KEY]: tab.groupId });
290 console.info("termbridge: Claude group set to", tab.groupId);
291}
292
293api.commands?.onCommand.addListener((command, tab) => {
294 if (command === "pick-element") runPicker();
295 if (command === "toggle-terminal") toggleSidebar(tab?.windowId);
296 if (command === "focus-omnibar") focusOmnibar(tab?.windowId);
297 if (command === "hand-tab-to-claude") handTabToClaude();
298 if (command === "set-claude-group") setClaudeGroup();
299});