opencode-island 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/window.ts ADDED
@@ -0,0 +1,262 @@
1
+ import { BrowserWindow, ipcMain, screen } from "electron"
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import islandHtml from "../dist/island/index.html?raw"
6
+ import islandPreload from "./island/preload.cjs?raw"
7
+ import type { IslandMessage, PermissionDecision } from "./state"
8
+
9
+ /**
10
+ * The only file that touches raw Electron.
11
+ *
12
+ * The GUI API is experimental and has no way to create a window of its own
13
+ * (`ctx.windows` lists the app's windows, `ctx.embeds` places pages inside
14
+ * them), so the island is a BrowserWindow we own. Keeping that here means the
15
+ * off-contract surface is one small file if the API changes.
16
+ *
17
+ * The page and its preload are written to a temp directory at startup because
18
+ * the app reads a plugin's GUI bundle from the server and evaluates it as
19
+ * code: there is no path on disk to load sibling files from.
20
+ */
21
+
22
+ const CHANNEL = "opencode-island"
23
+ const MIN_WIDTH = 24
24
+ const MAX_WIDTH = 640
25
+
26
+ export interface Reply extends Record<string, unknown> {
27
+ sessionID: string
28
+ requestID: string
29
+ decision: PermissionDecision
30
+ }
31
+
32
+ export interface IslandWindowOptions {
33
+ /** The page installed its listeners; send it the first state. */
34
+ onReady: () => void
35
+ onToggle: () => void
36
+ /** The island lost focus, so it should contract. */
37
+ onBlur: () => void
38
+ onReply: (reply: Reply) => void
39
+ log: (
40
+ level: "debug" | "info" | "warn" | "error",
41
+ message: string,
42
+ data?: Record<string, unknown>,
43
+ ) => void
44
+ }
45
+
46
+ export function createIslandWindow(options: IslandWindowOptions) {
47
+ const dir = mkdtempSync(join(tmpdir(), "opencode-island-"))
48
+ const page = join(dir, "island.html")
49
+ const preload = join(dir, "preload.cjs")
50
+ writeFileSync(page, islandHtml)
51
+ writeFileSync(preload, islandPreload)
52
+
53
+ const window = new BrowserWindow({
54
+ width: 460,
55
+ height: 240,
56
+ show: false,
57
+ frame: false,
58
+ transparent: true,
59
+ backgroundColor: "#00000000",
60
+ hasShadow: false,
61
+ // The island shapes its own outer corners; the window mask would round the
62
+ // screen edge too and leave a visible sliver of desktop there.
63
+ roundedCorners: false,
64
+ resizable: false,
65
+ movable: false,
66
+ minimizable: false,
67
+ maximizable: false,
68
+ fullscreenable: false,
69
+ skipTaskbar: true,
70
+ acceptFirstMouse: true,
71
+ hiddenInMissionControl: true,
72
+ webPreferences: {
73
+ preload,
74
+ contextIsolation: true,
75
+ nodeIntegration: false,
76
+ sandbox: true,
77
+ backgroundThrottling: false,
78
+ spellcheck: false,
79
+ },
80
+ })
81
+
82
+ // Float above full-screen apps and every Space, without ever taking focus
83
+ // when it appears.
84
+ window.setAlwaysOnTop(true, "screen-saver")
85
+ window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
86
+
87
+ let size = { width: 460, height: 240 }
88
+ // Where the tab's centre sits on the right edge; null means "middle".
89
+ let center: number | null = null
90
+ // While the bar is being resized its top stays put, so the bottom follows
91
+ // the mouse instead of drifting away from it.
92
+ let anchorTop: number | null = null
93
+
94
+ const place = (animate = false) => {
95
+ if (window.isDestroyed()) return
96
+ const area = screen.getPrimaryDisplay().workArea
97
+ const middle = area.y + (area.height - size.height) / 2
98
+ const y =
99
+ anchorTop != null
100
+ ? Math.min(area.y + area.height - size.height, Math.max(area.y, anchorTop))
101
+ : center == null
102
+ ? middle
103
+ : Math.min(area.y + area.height - size.height, Math.max(area.y, center - size.height / 2))
104
+ window.setBounds(
105
+ {
106
+ x: Math.round(area.x + area.width - size.width),
107
+ y: Math.round(y),
108
+ width: size.width,
109
+ height: size.height,
110
+ },
111
+ animate,
112
+ )
113
+ }
114
+
115
+ const ours = (sender: Electron.WebContents) => !window.isDestroyed() && sender === window.webContents
116
+
117
+ const onReady = (event: Electron.IpcMainEvent) => {
118
+ if (ours(event.sender)) options.onReady()
119
+ }
120
+
121
+ const onSize = (event: Electron.IpcMainEvent, next: { width?: number; height?: number }) => {
122
+ if (!ours(event.sender)) return
123
+ const area = screen.getPrimaryDisplay().workArea
124
+ const width = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(next?.width ?? 0)))
125
+ // The page may ask for a share of the screen, so the ceiling is the screen.
126
+ const height = Math.min(area.height, Math.max(24, Math.round(next?.height ?? 0)))
127
+ if (width === size.width && height === size.height) return
128
+ size = { width, height }
129
+ // The first report happens while hidden; every later one is a drawer move.
130
+ const first = !window.isVisible()
131
+ place(!first)
132
+ if (first) {
133
+ window.showInactive()
134
+ options.log("info", "island shown", { width, height })
135
+ }
136
+ }
137
+
138
+ const onToggle = (event: Electron.IpcMainEvent) => {
139
+ if (ours(event.sender)) options.onToggle()
140
+ }
141
+
142
+ const onReply = (event: Electron.IpcMainEvent, reply: Reply) => {
143
+ if (ours(event.sender)) options.onReply(reply)
144
+ }
145
+
146
+ // Dragging the closed tab slides it along the edge: the window follows the
147
+ // cursor's vertical position and stays flush right.
148
+ let drag: ReturnType<typeof setInterval> | null = null
149
+
150
+ const stopDrag = () => {
151
+ if (drag) clearInterval(drag)
152
+ drag = null
153
+ }
154
+
155
+ const onDragStart = (event: Electron.IpcMainEvent) => {
156
+ if (!ours(event.sender) || drag) return
157
+ const start = screen.getCursorScreenPoint().y
158
+ const offset = start - (window.getBounds().y + size.height / 2)
159
+ let last = start
160
+ drag = setInterval(() => {
161
+ if (window.isDestroyed()) return stopDrag()
162
+ const y = screen.getCursorScreenPoint().y
163
+ if (y === last) return
164
+ last = y
165
+ center = y - offset
166
+ place()
167
+ }, 16)
168
+ }
169
+
170
+ const onDragEnd = (event: Electron.IpcMainEvent) => {
171
+ if (ours(event.sender)) stopDrag()
172
+ }
173
+
174
+ // Click-through: the page decides, since only it knows where the grip is.
175
+ const onIgnore = (event: Electron.IpcMainEvent, ignore: boolean) => {
176
+ if (ours(event.sender)) window.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
177
+ }
178
+
179
+ // The bar is resized from its bottom edge: hold the top still and let the
180
+ // bottom follow the mouse.
181
+ const onResizeStart = (event: Electron.IpcMainEvent) => {
182
+ if (!ours(event.sender)) return
183
+ anchorTop = window.getBounds().y
184
+ }
185
+
186
+ const onResize = (event: Electron.IpcMainEvent, screenY: number) => {
187
+ if (!ours(event.sender) || anchorTop == null) return
188
+ const area = screen.getPrimaryDisplay().workArea
189
+ const height = Math.min(area.height, Math.max(24, Math.round(screenY - anchorTop)))
190
+ if (height === size.height) return
191
+ size = { ...size, height }
192
+ place(false)
193
+ }
194
+
195
+ const onResizeEnd = (event: Electron.IpcMainEvent) => {
196
+ if (!ours(event.sender)) return
197
+ if (anchorTop != null) center = anchorTop + size.height / 2
198
+ anchorTop = null
199
+ }
200
+
201
+ // The page sizes itself against the screen, so it needs to know how big it is.
202
+ const onScreen = () => {
203
+ const area = screen.getPrimaryDisplay().workArea
204
+ return { width: area.width, height: area.height }
205
+ }
206
+
207
+ ipcMain.on(`${CHANNEL}:ready`, onReady)
208
+ ipcMain.on(`${CHANNEL}:size`, onSize)
209
+ ipcMain.on(`${CHANNEL}:toggle`, onToggle)
210
+ ipcMain.on(`${CHANNEL}:reply`, onReply)
211
+ ipcMain.on(`${CHANNEL}:drag-start`, onDragStart)
212
+ ipcMain.on(`${CHANNEL}:drag-end`, onDragEnd)
213
+ ipcMain.on(`${CHANNEL}:ignore`, onIgnore)
214
+ ipcMain.on(`${CHANNEL}:resize-start`, onResizeStart)
215
+ ipcMain.on(`${CHANNEL}:resize`, onResize)
216
+ ipcMain.on(`${CHANNEL}:resize-end`, onResizeEnd)
217
+ ipcMain.handle(`${CHANNEL}:screen`, onScreen)
218
+
219
+ window.on("blur", () => options.onBlur())
220
+ window.on("closed", () => {
221
+ stopDrag()
222
+ options.log("debug", "island window closed")
223
+ })
224
+ window.webContents.on("did-fail-load", (_event, code, description) =>
225
+ options.log("error", "island page failed to load", { code, description }),
226
+ )
227
+
228
+ // The island belongs on whichever screen is primary now.
229
+ const onDisplaysChanged = () => place()
230
+ screen.on("display-metrics-changed", onDisplaysChanged)
231
+ screen.on("display-added", onDisplaysChanged)
232
+ screen.on("display-removed", onDisplaysChanged)
233
+
234
+ place()
235
+ void window.loadFile(page)
236
+
237
+ return {
238
+ send(message: IslandMessage) {
239
+ if (window.isDestroyed()) return
240
+ window.webContents.send(`${CHANNEL}:state`, message)
241
+ },
242
+ dispose() {
243
+ ipcMain.removeListener(`${CHANNEL}:ready`, onReady)
244
+ ipcMain.removeListener(`${CHANNEL}:size`, onSize)
245
+ ipcMain.removeListener(`${CHANNEL}:toggle`, onToggle)
246
+ ipcMain.removeListener(`${CHANNEL}:reply`, onReply)
247
+ ipcMain.removeListener(`${CHANNEL}:drag-start`, onDragStart)
248
+ ipcMain.removeListener(`${CHANNEL}:drag-end`, onDragEnd)
249
+ ipcMain.removeListener(`${CHANNEL}:ignore`, onIgnore)
250
+ ipcMain.removeListener(`${CHANNEL}:resize-start`, onResizeStart)
251
+ ipcMain.removeListener(`${CHANNEL}:resize`, onResize)
252
+ ipcMain.removeListener(`${CHANNEL}:resize-end`, onResizeEnd)
253
+ ipcMain.removeHandler(`${CHANNEL}:screen`)
254
+ screen.removeListener("display-metrics-changed", onDisplaysChanged)
255
+ screen.removeListener("display-added", onDisplaysChanged)
256
+ screen.removeListener("display-removed", onDisplaysChanged)
257
+ stopDrag()
258
+ if (!window.isDestroyed()) window.destroy()
259
+ rmSync(dir, { recursive: true, force: true })
260
+ },
261
+ }
262
+ }