opencode-island 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +185 -2
- package/dist/gui-experimental.cjs +45 -0
- package/dist/island/index.html +24 -0
- package/package.json +68 -4
- package/src/env.d.ts +1 -0
- package/src/gui.tsx +84 -0
- package/src/index.ts +11 -0
- package/src/island/app/components/island.tsx +491 -0
- package/src/island/app/components/ui/button.tsx +63 -0
- package/src/island/app/components/ui/toggle.tsx +44 -0
- package/src/island/app/global.d.ts +22 -0
- package/src/island/app/index.html +15 -0
- package/src/island/app/island.css +101 -0
- package/src/island/app/lib/utils.ts +6 -0
- package/src/island/app/main.tsx +10 -0
- package/src/island/app/tsconfig.json +20 -0
- package/src/island/preload.cjs +20 -0
- package/src/model.ts +416 -0
- package/src/state.ts +59 -0
- package/src/window.ts +262 -0
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
|
+
}
|