positron.js 1.0.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 +7 -0
- package/README.md +154 -0
- package/bin/positron.js +40 -0
- package/builder.js +229 -0
- package/core/mac/main.swift +1154 -0
- package/core/win/PositronRuntime.csproj +14 -0
- package/core/win/main.cs +1124 -0
- package/extensions.js +42 -0
- package/findpackage.js +34 -0
- package/index.js +912 -0
- package/ipc.js +81 -0
- package/logs.js +19 -0
- package/menu.js +100 -0
- package/package.json +30 -0
- package/packager.js +260 -0
- package/pbannerfull.png +0 -0
- package/positronicon.png +0 -0
- package/screen.js +35 -0
- package/store.js +104 -0
package/index.js
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
const WebSocket = require("ws");
|
|
2
|
+
const Events = require("events");
|
|
3
|
+
const cp = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { performNativeBuild } = require("./builder");
|
|
7
|
+
const logger = require("./logs");
|
|
8
|
+
const IpcRouter = require("./ipc");
|
|
9
|
+
const { Menu } = require("./menu");
|
|
10
|
+
const http = require("http");
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
const { info, error, warn, success } = require("./logs");
|
|
13
|
+
|
|
14
|
+
let currMenu = []
|
|
15
|
+
let contextMenu = [];
|
|
16
|
+
|
|
17
|
+
const randomPort = () => {
|
|
18
|
+
const min = 1024;
|
|
19
|
+
const max = 65535;
|
|
20
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PORT = process.env.POSITRON_IPC_PORT || randomPort();
|
|
24
|
+
const HOST = "127.0.0.1";
|
|
25
|
+
|
|
26
|
+
if (!process.env.POSITRON_AUTH_TOKEN) {
|
|
27
|
+
process.env.POSITRON_AUTH_TOKEN = crypto.randomUUID();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if(!process.env.POSITRON_IPC_PORT) {
|
|
31
|
+
process.env.POSITRON_IPC_PORT = PORT;
|
|
32
|
+
}
|
|
33
|
+
const appRoot = process.cwd();
|
|
34
|
+
const binaryName = process.platform === "win32" ? "positron-runtime.exe" : "positron-runtime";
|
|
35
|
+
const binaryPath = path.join(appRoot, "bin", binaryName);
|
|
36
|
+
|
|
37
|
+
const appEvents = new Events.EventEmitter();
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
const isPackaged = process.env.POSITRON_PACKAGED === "true";
|
|
41
|
+
|
|
42
|
+
if(isPackaged) {
|
|
43
|
+
if (typeof process.pkg !== 'undefined') {
|
|
44
|
+
if (process.platform === 'darwin') {
|
|
45
|
+
__dirname = path.join(path.dirname(process.execPath), '.');
|
|
46
|
+
} else {
|
|
47
|
+
__dirname = path.dirname(process.execPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EXPECTED_TOKEN = process.env.POSITRON_AUTH_TOKEN;
|
|
53
|
+
|
|
54
|
+
const parseRes = (obj) => {
|
|
55
|
+
if (Object.keys(obj) > 1) return obj;
|
|
56
|
+
|
|
57
|
+
return Object.values(obj)[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!isPackaged) {
|
|
61
|
+
// DEV MODE
|
|
62
|
+
if (!fs.existsSync(binaryPath)) {
|
|
63
|
+
warn("Native binary missing. Triggering automatic background build...");
|
|
64
|
+
const buildSuccess = performNativeBuild();
|
|
65
|
+
if (!buildSuccess) {
|
|
66
|
+
error("[Positron] Fatal: Could not auto-compile native binary.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
info("Starting Positron render process...");
|
|
72
|
+
const renderProcess = cp.spawn(binaryPath, {
|
|
73
|
+
env: {
|
|
74
|
+
...process.env,
|
|
75
|
+
POSITRON_AUTH_TOKEN: EXPECTED_TOKEN
|
|
76
|
+
},
|
|
77
|
+
stdio: process.env.POSITRON_SILENT_NATIVE ? "ignore" : "inherit"
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
renderProcess.on("error", (err) => {
|
|
81
|
+
error("Failed to start render process:", err);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
process.on("exit", () => {
|
|
86
|
+
if (!isPackaged && renderProcess && !renderProcess.killed) {
|
|
87
|
+
renderProcess.kill();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
process.on("SIGINT", () => {
|
|
92
|
+
process.exit();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
process.on("uncaughtException", (err) => {
|
|
96
|
+
error("Uncaught exception:", err, '\n', err.stack.split('\n').slice(1).join('\n'));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
renderProcess.on("close", (code) => {
|
|
101
|
+
info(`[Positron] Render process exited with code ${code}`);
|
|
102
|
+
process.exit(code);
|
|
103
|
+
});
|
|
104
|
+
} else {
|
|
105
|
+
// PRODUCTION MODE
|
|
106
|
+
info("[Positron] Packaged mode detected. Skipping native binary spawn.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const httpServer = http.createServer((req, res) => {
|
|
110
|
+
if (req.method === 'GET' && req.url === '/running') {
|
|
111
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
112
|
+
res.end('true');
|
|
113
|
+
} else {
|
|
114
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
115
|
+
res.end('Not Found');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const _ipcWS = new WebSocket.Server({ server: httpServer });
|
|
120
|
+
let activeSocket = null;
|
|
121
|
+
const pendingWindows = new Set();
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
const commandQueue = [];
|
|
125
|
+
|
|
126
|
+
let activeWindows = new Set();
|
|
127
|
+
|
|
128
|
+
_ipcWS.on("connection", (ws, req) => {
|
|
129
|
+
const clientToken = req.headers["x-positron-auth-token"];
|
|
130
|
+
|
|
131
|
+
if (clientToken !== EXPECTED_TOKEN) {
|
|
132
|
+
warn("[Security] Unauthorized local connection attempt rejected. Token:", clientToken, "Expected:", EXPECTED_TOKEN);
|
|
133
|
+
ws.close(4001, "Unauthorized token match failure.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
activeSocket = ws;
|
|
138
|
+
success("Client connected to IPC");
|
|
139
|
+
|
|
140
|
+
while (commandQueue.length > 0) {
|
|
141
|
+
const payload = commandQueue.shift();
|
|
142
|
+
activeSocket.send(payload);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
pendingWindows.forEach(win => {
|
|
146
|
+
win.emit("ready");
|
|
147
|
+
});
|
|
148
|
+
pendingWindows.clear();
|
|
149
|
+
|
|
150
|
+
ws.on("message", raw => {
|
|
151
|
+
try {
|
|
152
|
+
const msg = JSON.parse(raw);
|
|
153
|
+
|
|
154
|
+
if(process.env.POSITRON_LOG_IPC) console.log("Received IPC message:", msg);
|
|
155
|
+
|
|
156
|
+
if (msg.event === "ipcMessage" || msg.event.includes("-reply-") || msg.event.includes("-result-")) {
|
|
157
|
+
|
|
158
|
+
const simulatedMsg = msg.event === "ipcMessage" ? msg : {
|
|
159
|
+
event: "ipcMessage",
|
|
160
|
+
windowId: msg.windowId,
|
|
161
|
+
data: {
|
|
162
|
+
channel: msg.event,
|
|
163
|
+
payload: msg.data
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
ipc.dispatch(ws, simulatedMsg);
|
|
168
|
+
} else if (msg.event === "window-close-requested") {
|
|
169
|
+
const win = [...activeWindows].find(w => w.id === msg.windowId);
|
|
170
|
+
if (win) {
|
|
171
|
+
let defaultPrevented = false;
|
|
172
|
+
|
|
173
|
+
const eventObject = {
|
|
174
|
+
preventDefault: () => { defaultPrevented = true; }
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
win.emit("close", eventObject);
|
|
178
|
+
|
|
179
|
+
if (!defaultPrevented) {
|
|
180
|
+
win.destroy();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else if(msg.event == "menu-action" || msg.event == "context-menu-action") {
|
|
184
|
+
|
|
185
|
+
const findMenuAction = (items, label, channel) => {
|
|
186
|
+
if (!items || items.length === 0) return null;
|
|
187
|
+
|
|
188
|
+
for (const item of items) {
|
|
189
|
+
if (item.label === label || (channel && item.channel === channel)) {
|
|
190
|
+
return item;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (item.items && item.items.length > 0) {
|
|
194
|
+
const found = findMenuAction(item.items, label, channel);
|
|
195
|
+
if (found) return found;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const menuAction = findMenuAction((msg.event === "menu-action" ? currMenu : contextMenu), msg.data.label, msg.data.channel);
|
|
203
|
+
|
|
204
|
+
if (menuAction) {
|
|
205
|
+
menuAction.click();
|
|
206
|
+
} else {
|
|
207
|
+
warn("Received menu action for unknown item:", msg.data);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
appEvents.emit(msg.event, msg.data);
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
error("Failed to process incoming IPC network frame:", err);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
ws.on("close", () => {
|
|
219
|
+
activeSocket = null;
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const ipc = new IpcRouter();
|
|
224
|
+
|
|
225
|
+
let _windowCounter = 0;
|
|
226
|
+
|
|
227
|
+
class Window extends Events.EventEmitter {
|
|
228
|
+
|
|
229
|
+
/** Creates a new window instance. */
|
|
230
|
+
constructor(options = {
|
|
231
|
+
|
|
232
|
+
darwinOptions: {
|
|
233
|
+
closable: true,
|
|
234
|
+
resizable: true,
|
|
235
|
+
minimizable: true,
|
|
236
|
+
titlebarTransparent: false,
|
|
237
|
+
titlebarVisible: true
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
}) {
|
|
241
|
+
super();
|
|
242
|
+
this.id = ++_windowCounter;
|
|
243
|
+
this.options = options;
|
|
244
|
+
activeWindows.add(this);
|
|
245
|
+
|
|
246
|
+
if (activeSocket && activeSocket.readyState === WebSocket.OPEN) {
|
|
247
|
+
process.nextTick(() => this.emit("ready"));
|
|
248
|
+
} else {
|
|
249
|
+
pendingWindows.add(this);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const width = options.width ? String(options.width) : "800";
|
|
253
|
+
const height = options.height ? String(options.height) : "600";
|
|
254
|
+
|
|
255
|
+
if(!this.options.skipCreate) {
|
|
256
|
+
this.create(width, height, options.darwinOptions);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Send a fire-and-forget command to the native layer. Commands are simple strings that correspond to actions the native layer can perform.
|
|
263
|
+
* Args can be provided as an array or a single value, and will be normalized to an array of strings before being sent.
|
|
264
|
+
* If the socket connection is not currently open, the command will be queued and sent once the connection is established.
|
|
265
|
+
* @param {string} command
|
|
266
|
+
* @param {string[]} args
|
|
267
|
+
*/
|
|
268
|
+
sendCommand(command, args = []) {
|
|
269
|
+
const normalizedArgs = Array.isArray(args) ? args.map(String) : [String(args)];
|
|
270
|
+
|
|
271
|
+
const payload = JSON.stringify({
|
|
272
|
+
windowId: this.id,
|
|
273
|
+
command,
|
|
274
|
+
args: normalizedArgs
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (activeSocket && activeSocket.readyState === WebSocket.OPEN) {
|
|
278
|
+
activeSocket.send(payload);
|
|
279
|
+
this.emit("command-sent", { command, args: normalizedArgs });
|
|
280
|
+
} else {
|
|
281
|
+
if(command != "createWindow") info(`Socket not ready. Outbound command queued: ${command}`);
|
|
282
|
+
commandQueue.push(payload);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Sets the title of the window.
|
|
288
|
+
* @param {string} title The new title for the window.
|
|
289
|
+
*/
|
|
290
|
+
setTitle(title) {
|
|
291
|
+
this.sendCommand("setTitle", [title]);
|
|
292
|
+
this.emit("title-updated", title);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Loads a remote URL in the window. Emits "url-loaded" and "navigated" events with the URL as data.
|
|
297
|
+
* @param {string} url The URL to load.
|
|
298
|
+
*/
|
|
299
|
+
loadURL(url) {
|
|
300
|
+
this.sendCommand("loadURL", [url]);
|
|
301
|
+
this.emit("url-loaded", url);
|
|
302
|
+
this.emit("navigated", url);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Triggers the print dialog for the window. Emits a "print" event. Note that the actual print functionality and dialog is handled by the native layer, so behavior may vary across platforms.
|
|
307
|
+
*/
|
|
308
|
+
print() {
|
|
309
|
+
this.sendCommand("print");
|
|
310
|
+
this.emit("print");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Sets the user agent string for the window. Emits a "user-agent-updated" event with the new user agent as data.
|
|
315
|
+
* @param {string} userAgent The new user agent string.
|
|
316
|
+
*/
|
|
317
|
+
setUserAgent(userAgent) {
|
|
318
|
+
this.sendCommand("setUserAgent", [userAgent]);
|
|
319
|
+
this.emit("user-agent-updated", userAgent);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Loads a local file in the window. Emits "file-loaded" and "navigated" events with the file path as data.
|
|
324
|
+
* @param {string} path The path to the file to load.
|
|
325
|
+
*/
|
|
326
|
+
loadFile(path) {
|
|
327
|
+
this.sendCommand("loadFile", [path]);
|
|
328
|
+
this.emit("file-loaded", path);
|
|
329
|
+
this.emit("navigated", path);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Sends an IPC message to the renderer process.
|
|
334
|
+
* @param {string} channel The IPC channel to send the message on.
|
|
335
|
+
* @param {string[]} args The arguments to send with the message.
|
|
336
|
+
*/
|
|
337
|
+
sendIpc(channel, args = []) {
|
|
338
|
+
if (activeSocket && activeSocket.readyState === WebSocket.OPEN) {
|
|
339
|
+
const payload = JSON.stringify({
|
|
340
|
+
windowId: this.id,
|
|
341
|
+
command: "emitToRenderer",
|
|
342
|
+
args: [channel, JSON.stringify(args)]
|
|
343
|
+
});
|
|
344
|
+
activeSocket.send(payload);
|
|
345
|
+
this.emit("ipc-sent", { channel, args });
|
|
346
|
+
} else {
|
|
347
|
+
warn(`Cannot send IPC message, socket not ready. Channel: ${channel}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#created = false;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Creates the window in the native layer.
|
|
355
|
+
* @param {string} width
|
|
356
|
+
* @param {string} height
|
|
357
|
+
* @param {Object} darwinOptions
|
|
358
|
+
* @returns
|
|
359
|
+
*/
|
|
360
|
+
create(width, height, darwinOptions = {
|
|
361
|
+
closable: true,
|
|
362
|
+
resizable: true,
|
|
363
|
+
minimizable: true,
|
|
364
|
+
titlebarTransparent: false,
|
|
365
|
+
titlebarVisible: true
|
|
366
|
+
}) {
|
|
367
|
+
if (this.#created) {
|
|
368
|
+
warn(`Window ${this.id} is already created.`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
darwinOptions = {
|
|
373
|
+
closable: true,
|
|
374
|
+
resizable: true,
|
|
375
|
+
minimizable: true,
|
|
376
|
+
titlebarTransparent: false,
|
|
377
|
+
titlebarVisible: true,
|
|
378
|
+
...darwinOptions
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
this.#created = true;
|
|
382
|
+
if(!width) width = this.options.width || 800;
|
|
383
|
+
if(!height) height = this.options.height || 600;
|
|
384
|
+
this.sendCommand("createWindow", [width, height, ...Object.values(darwinOptions).map(val => String(val))]);
|
|
385
|
+
this.emit("created");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Closes the window by triggering the native close sequence. This allows the "close" event to be emitted and gives the app a chance to prevent the close if needed. If you want to force close without emitting "close", use the destroy() method instead.
|
|
390
|
+
*/
|
|
391
|
+
close() {
|
|
392
|
+
this.sendCommand("triggerCloseSequence");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Immediately destroys the window without emitting "close" or allowing prevention. This should be used with caution, as it can lead to unsaved state or other issues if the app is not prepared for it.
|
|
397
|
+
*/
|
|
398
|
+
destroy() {
|
|
399
|
+
this.#created = false;
|
|
400
|
+
activeWindows.delete(this);
|
|
401
|
+
this.sendCommand("forceCloseWindow");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Sets the application menu for this window.
|
|
407
|
+
* @param {Menu} menuTemplate
|
|
408
|
+
*/
|
|
409
|
+
setMenu(menuTemplate) {
|
|
410
|
+
if(menuTemplate instanceof Menu) {
|
|
411
|
+
menuTemplate = menuTemplate.template;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
currMenu = menuTemplate;
|
|
415
|
+
|
|
416
|
+
const stripClick = (items) => {
|
|
417
|
+
if (!items) return null;
|
|
418
|
+
return items.map(i => {
|
|
419
|
+
const newItem = { ...i, click: undefined };
|
|
420
|
+
if (newItem.items) {
|
|
421
|
+
newItem.items = stripClick(newItem.items);
|
|
422
|
+
}
|
|
423
|
+
return newItem;
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
this.sendCommand("setMenu", [JSON.stringify(stripClick(menuTemplate))]);
|
|
428
|
+
this.emit("menu-updated", menuTemplate);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Clears the application menu for this window.
|
|
433
|
+
*/
|
|
434
|
+
resetMenu() {
|
|
435
|
+
this.sendCommand("resetMenu");
|
|
436
|
+
currMenu = [];
|
|
437
|
+
this.emit("menu-updated", null);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Displays an alert dialog with the given message. Emits an "alert" event with the message as data.
|
|
442
|
+
* @param {string} message The message to display in the alert dialog.
|
|
443
|
+
*/
|
|
444
|
+
alert(message) {
|
|
445
|
+
this.sendCommand("alert", [message]);
|
|
446
|
+
this.emit("alert", message);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Adds a user script to the window.
|
|
451
|
+
* @param {string} script The script to add.
|
|
452
|
+
*/
|
|
453
|
+
addUserScript(script) {
|
|
454
|
+
this.sendCommand("addUserScript", [script]);
|
|
455
|
+
this.emit("user-script-added", { content: script, filePath: null });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Adds a user script from a file.
|
|
460
|
+
* @param {string} filePath The path to the script file.
|
|
461
|
+
*/
|
|
462
|
+
addUserScriptFromFile(filePath) {
|
|
463
|
+
fs.readFile(filePath, "utf-8", (err, data) => {
|
|
464
|
+
if (err) {
|
|
465
|
+
error(`Failed to read user script from ${filePath}:`, err);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
this.addUserScript(data);
|
|
469
|
+
this.emit("user-script-added", { filePath, content: data });
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Resizes the window to the specified dimensions.
|
|
475
|
+
* @param {number} width The new width of the window.
|
|
476
|
+
* @param {number} height The new height of the window.
|
|
477
|
+
*/
|
|
478
|
+
resize(width, height) {
|
|
479
|
+
this.sendCommand("resizeWindow", [width, height]);
|
|
480
|
+
this.emit("resized", { width, height });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Opens the developer tools for the window. Emits a "devtools-opened" event when done. Does not work on macOS. For macOS, right-click the window and select "Inspect Element" to open dev tools for that window.
|
|
485
|
+
*/
|
|
486
|
+
openDevTools() {
|
|
487
|
+
if(process.platform === "darwin") {
|
|
488
|
+
warn("The openDevTools command is not supported on macOS due to OS limitations. Please right-click the window and select 'Inspect Element' to access developer tools.");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
this.sendCommand("openDevTools");
|
|
492
|
+
this.emit("devtools-opened");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Toggles fullscreen mode for the window. Emits a "fullscreen-toggled" event when done.
|
|
497
|
+
*/
|
|
498
|
+
toggleFullscreen() {
|
|
499
|
+
this.sendCommand("toggleFullscreen");
|
|
500
|
+
this.emit("fullscreen-toggled");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Enters fullscreen mode for the window. Emits a "fullscreen-entered" event when done.
|
|
505
|
+
*/
|
|
506
|
+
goFullscreen() {
|
|
507
|
+
this.sendCommand("fullscreen");
|
|
508
|
+
this.emit("fullscreen-entered");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Exits fullscreen mode for the window. Emits a "fullscreen-exited" event when done.
|
|
513
|
+
*/
|
|
514
|
+
exitFullscreen() {
|
|
515
|
+
this.sendCommand("exitFullscreen");
|
|
516
|
+
this.emit("fullscreen-exited");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Navigates forward in the window's history. Emits a "navigated-forward" event when done.
|
|
521
|
+
*/
|
|
522
|
+
goForward() {
|
|
523
|
+
this.sendCommand("forward");
|
|
524
|
+
this.emit("navigated-forward");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Navigates back in the window's history. Emits a "navigated-back" event when done.
|
|
529
|
+
*/
|
|
530
|
+
goBack() {
|
|
531
|
+
this.sendCommand("back");
|
|
532
|
+
this.emit("navigated-back");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Hides the window. Emits a "hidden" event when done.
|
|
537
|
+
*/
|
|
538
|
+
hide() {
|
|
539
|
+
this.sendCommand("hideWindow");
|
|
540
|
+
this.emit("hidden");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Shows the window. Emits a "shown" event when done.
|
|
545
|
+
*/
|
|
546
|
+
show() {
|
|
547
|
+
this.sendCommand("showWindow");
|
|
548
|
+
this.emit("shown");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Focuses the window. Emits a "focused" event when done.
|
|
553
|
+
*/
|
|
554
|
+
focus() {
|
|
555
|
+
this.sendCommand("focus");
|
|
556
|
+
this.emit("focused");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Reloads the window. Emits a "reloaded" event when done.
|
|
561
|
+
*/
|
|
562
|
+
reload() {
|
|
563
|
+
this.sendCommand("reload");
|
|
564
|
+
this.emit("reloaded");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Captures a screenshot of the current window. Returns a Promise that resolves to a Buffer containing the image data in PNG format, or null if the capture failed. Emits a "screenshot-captured" event with the image buffer as data when done.
|
|
569
|
+
* @returns {Promise<Buffer|null>} The captured screenshot as a Buffer, or null if the capture failed.
|
|
570
|
+
*/
|
|
571
|
+
async capturePage() {
|
|
572
|
+
const response = await this.request("capturePage", `capture-page-result-${this.id}`);
|
|
573
|
+
return response.image ? Buffer.from(response.image, "base64") : null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Checks if the window can navigate back in its history. Returns a Promise that resolves to true if it can go back, or false if it cannot. Emits a "can-go-back-checked" event with the result as data when done.
|
|
578
|
+
* @returns {Promise<boolean>} True if the window can navigate back, false otherwise.
|
|
579
|
+
*/
|
|
580
|
+
async canGoBack() {
|
|
581
|
+
const response = await this.request("canGoBack", `canGoBack-reply-${this.id}`);
|
|
582
|
+
return response === "true";
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Sends a request/response command to the native layer. The command will be sent, and the method will wait for a response on the specified reply channel. Once a response is received, the promise will resolve with the reply data.
|
|
587
|
+
* @param {string} command The command to send.
|
|
588
|
+
* @param {string} replyChannel The channel to listen for the reply on.
|
|
589
|
+
* @returns {Promise<*>} A promise that resolves to the reply data.
|
|
590
|
+
*/
|
|
591
|
+
async request(command, replyChannel, ...args) {
|
|
592
|
+
return new Promise((resolve, reject) => {
|
|
593
|
+
let settled = false;
|
|
594
|
+
|
|
595
|
+
const unsubscribe = ipc.handle(replyChannel, (data) => {
|
|
596
|
+
if (!settled) {
|
|
597
|
+
settled = true;
|
|
598
|
+
clearTimeout(timeout);
|
|
599
|
+
unsubscribe();
|
|
600
|
+
resolve(data);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const timeout = setTimeout(() => {
|
|
605
|
+
if (!settled) {
|
|
606
|
+
settled = true;
|
|
607
|
+
unsubscribe();
|
|
608
|
+
reject(new Error(`Request timed out waiting for reply on channel "${replyChannel}"`));
|
|
609
|
+
}
|
|
610
|
+
}, 5000);
|
|
611
|
+
|
|
612
|
+
this.sendCommand(command, args);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Checks if the window can navigate forward in its history. Returns a Promise that resolves to true if it can go forward, or false if it cannot. Emits a "can-go-forward-checked" event with the result as data when done.
|
|
618
|
+
* @returns {Promise<boolean>} True if the window can navigate forward, false otherwise.
|
|
619
|
+
*/
|
|
620
|
+
async canGoForward() {
|
|
621
|
+
const response = await this.request("canGoForward", `canGoForward-reply-${this.id}`);
|
|
622
|
+
return response === "true";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Shows a notification. Emits a "notification-shown" event when done.
|
|
627
|
+
* @param {string} title The title of the notification.
|
|
628
|
+
* @param {string} body The body of the notification.
|
|
629
|
+
* @param {Object} options The options for the notification.
|
|
630
|
+
*/
|
|
631
|
+
showNotification(title, body, options = {}) {
|
|
632
|
+
if(!isPackaged) {
|
|
633
|
+
warn("Notifications do not work in development mode due to limitations of the OS notification APIs. This command will be a no-op until the app is packaged.");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
this.sendCommand("showNotification", [title, body, JSON.stringify(options)]);
|
|
637
|
+
this.emit("notification-shown", { title, body, options });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Sets whether the window is closeable. Emits a "closeable-updated" event with the new value when done.
|
|
642
|
+
* @param {boolean} isClosable Whether the window is closeable.
|
|
643
|
+
*/
|
|
644
|
+
setCloseable(isClosable) {
|
|
645
|
+
this.sendCommand("setCloseable", [String(isClosable)]);
|
|
646
|
+
this.emit("closeable-updated", isClosable);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Sets whether the window is resizable. Emits a "resizable-updated" event with the new value when done.
|
|
651
|
+
* @param {boolean} isResizable Whether the window is resizable.
|
|
652
|
+
*/
|
|
653
|
+
setResizable(isResizable) {
|
|
654
|
+
this.sendCommand("setResizable", [String(isResizable)]);
|
|
655
|
+
this.emit("resizable-updated", isResizable);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Sets whether the window is minimizable. Emits a "minimizable-updated" event with the new value when done.
|
|
660
|
+
* @param {boolean} isMinimizable Whether the window is minimizable.
|
|
661
|
+
*/
|
|
662
|
+
setMinimizable(isMinimizable) {
|
|
663
|
+
this.sendCommand("setMinimizable", [String(isMinimizable)]);
|
|
664
|
+
this.emit("minimizable-updated", isMinimizable);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Sets the bounds of the window. Emits a "bounds-updated" event with the new bounds when done.
|
|
669
|
+
* @param {number} x The x-coordinate of the window's position.
|
|
670
|
+
* @param {number} y The y-coordinate of the window's position.
|
|
671
|
+
* @param {number} width The width of the window.
|
|
672
|
+
* @param {number} height The height of the window.
|
|
673
|
+
*/
|
|
674
|
+
setBounds(x, y, width, height) {
|
|
675
|
+
this.sendCommand("setBounds", [x, y, width, height]);
|
|
676
|
+
this.emit("bounds-updated", { x, y, width, height });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Displays a prompt dialog with the given message and default value. Returns a Promise that resolves to the user's input as a string, or null if the user cancelled the prompt. Emits a "prompt" event with the message and default value as data when done.
|
|
681
|
+
* @param {string} message The message to display in the prompt dialog.
|
|
682
|
+
* @param {string} defaultValue The default value to display in the prompt input field.
|
|
683
|
+
* @returns {Promise<string|null>} The user's input as a string, or null if the user cancelled the prompt.
|
|
684
|
+
*/
|
|
685
|
+
async prompt(message, defaultValue = "") {
|
|
686
|
+
const res = await this.request("prompt", `prompt-reply-${this.id}`, message, defaultValue);
|
|
687
|
+
this.emit("prompt", { message, defaultValue });
|
|
688
|
+
return res?.input;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Sets the context menu for the window. The menuTemplate should be an array of menu item objects, where each object can have a label, an optional click handler, and an optional submenu (which is itself an array of menu item objects). Emits a "context-menu-updated" event with the new menu template when done.
|
|
693
|
+
* @param {Menu} menuTemplate
|
|
694
|
+
*/
|
|
695
|
+
setContextMenu(menuTemplate) {
|
|
696
|
+
|
|
697
|
+
if(menuTemplate instanceof Menu) {
|
|
698
|
+
menuTemplate = menuTemplate.template;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const stripClick = (items) => {
|
|
702
|
+
if (!items) return null;
|
|
703
|
+
return items.map(i => {
|
|
704
|
+
const newItem = { ...i, click: undefined };
|
|
705
|
+
if (newItem.items) {
|
|
706
|
+
newItem.items = stripClick(newItem.items);
|
|
707
|
+
}
|
|
708
|
+
return newItem;
|
|
709
|
+
});
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
contextMenu = menuTemplate;
|
|
713
|
+
|
|
714
|
+
this.sendCommand("setContextMenu", [JSON.stringify(stripClick(menuTemplate))]);
|
|
715
|
+
this.emit("context-menu-updated", menuTemplate);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async isFocused() {
|
|
719
|
+
const res = await this.request("isFocused", `isFocused-reply-${this.id}`);
|
|
720
|
+
return res?.isFocused === "true";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Gets the current bounds of the window. Returns a Promise that resolves to an object containing the x and y coordinates of the window's position, as well as its width and height. Emits a "bounds-retrieved" event with the bounds data when done.
|
|
725
|
+
* @returns {Promise<{x: number, y: number, width: number, height: number}>} An object containing the window's bounds.
|
|
726
|
+
*/
|
|
727
|
+
async getBounds() {
|
|
728
|
+
return await this.request("getBounds", `getBounds-reply-${this.id}`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Gets the current URL loaded in the window. Returns a Promise that resolves to the URL as a string. Emits a "url-retrieved" event with the URL data when done.
|
|
733
|
+
* @returns {Promise<string>} The current URL loaded in the window.
|
|
734
|
+
*/
|
|
735
|
+
async getURL() {
|
|
736
|
+
return await this.request("getURL", `getURL-reply-${this.id}`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Gets the current title of the window. Returns a Promise that resolves to the title as a string. Emits a "title-retrieved" event with the title data when done.
|
|
741
|
+
* @returns {Promise<string>} The current title of the window.
|
|
742
|
+
*/
|
|
743
|
+
async getTitle() {
|
|
744
|
+
return await this.request("getTitle", `getTitle-reply-${this.id}`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Sets whether the window's titlebar is visible. Emits a "titlebar-visibility-updated" event with the new value when done.
|
|
749
|
+
* @param {boolean} isVisible Whether the titlebar is visible.
|
|
750
|
+
*/
|
|
751
|
+
setTitlebarVisible(isVisible) {
|
|
752
|
+
this.sendCommand("setTitlebarVisible", [String(isVisible)]);
|
|
753
|
+
this.emit("titlebar-visibility-updated", isVisible);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Sets whether the window's titlebar is transparent. Emits a "titlebar-transparency-updated" event with the new value when done.
|
|
758
|
+
* @param {boolean} isTransparent Whether the titlebar is transparent.
|
|
759
|
+
*/
|
|
760
|
+
setTitlebarTransparent(isTransparent) {
|
|
761
|
+
this.sendCommand("setTitlebarTransparent", [String(isTransparent)]);
|
|
762
|
+
this.emit("titlebar-transparency-updated", isTransparent);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Evaluates JavaScript code in the context of the window. Returns a Promise that resolves to the result of the evaluation. Emits a "js-evaluated" event with the result data when done.
|
|
767
|
+
* @param {string} script The JavaScript code to evaluate.
|
|
768
|
+
* @returns {Promise<*>} A Promise that resolves to the result of the evaluation.
|
|
769
|
+
*/
|
|
770
|
+
async evaluateJavaScript(script) {
|
|
771
|
+
const res = await this.request("evaluateJS", `evaluateJS-reply-${this.id}`, script);
|
|
772
|
+
return res;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const app = {
|
|
778
|
+
|
|
779
|
+
name:"PositronApp",
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Quits the application by sending a terminate command to the native layer and then exiting the process. Emits a "before-quit" event before sending the command, and a "quit" event after initiating the quit sequence.
|
|
783
|
+
* @param {number} exitCode The exit code for the process.
|
|
784
|
+
*/
|
|
785
|
+
quit(exitCode = 0) {
|
|
786
|
+
this.events.emit("before-quit");
|
|
787
|
+
const payload = JSON.stringify({
|
|
788
|
+
windowId: 1,
|
|
789
|
+
command: "terminate",
|
|
790
|
+
args: []
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
if (activeSocket && activeSocket.readyState === WebSocket.OPEN) {
|
|
794
|
+
activeSocket.send(payload);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
setTimeout(() => {
|
|
798
|
+
process.exit(exitCode);
|
|
799
|
+
}, 20);
|
|
800
|
+
|
|
801
|
+
appEvents.emit("quit");
|
|
802
|
+
},
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Adds an event listener for application-level events. Supported events include "before-quit" and "quit".
|
|
806
|
+
* @param {string} event The name of the event to listen for.
|
|
807
|
+
* @param {Function} listener The callback function to invoke when the event is emitted.
|
|
808
|
+
*/
|
|
809
|
+
on(event, listener) {
|
|
810
|
+
this.events.on(event, listener);
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Removes an event listener for application-level events.
|
|
815
|
+
* @param {string} event The name of the event to remove the listener from.
|
|
816
|
+
* @param {Function} listener The callback function to remove.
|
|
817
|
+
*/
|
|
818
|
+
off(event, listener) {
|
|
819
|
+
this.events.off(event, listener);
|
|
820
|
+
},
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Adds a one-time event listener for application-level events. The listener will be invoked at most once for the specified event, and then automatically removed.
|
|
824
|
+
* @param {string} event The name of the event to listen for.
|
|
825
|
+
* @param {Function} listener The callback function to invoke when the event is emitted.
|
|
826
|
+
*/
|
|
827
|
+
once(event, listener) {
|
|
828
|
+
this.events.once(event, listener);
|
|
829
|
+
},
|
|
830
|
+
|
|
831
|
+
async getFocusedWindow() {
|
|
832
|
+
const results = await Promise.all([...activeWindows].map(win => win.isFocused().then(isFocused => ({ win, isFocused }))));
|
|
833
|
+
return results.find(({ isFocused }) => isFocused)?.win || null;
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
setName(name) {
|
|
837
|
+
process.env.POSITRON_APP_NAME = name;
|
|
838
|
+
this.events.emit("name-updated", name);
|
|
839
|
+
this.name = name;
|
|
840
|
+
const path = this.userData.getPath();
|
|
841
|
+
if (!fs.existsSync(path)) {
|
|
842
|
+
fs.mkdirSync(path, { recursive: true });
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
|
|
846
|
+
userData: {
|
|
847
|
+
getPath() {
|
|
848
|
+
let userPath = null;
|
|
849
|
+
|
|
850
|
+
if (process.platform === "win32") {
|
|
851
|
+
userPath = process.env.APPDATA
|
|
852
|
+
? path.join(process.env.APPDATA, process.env.POSITRON_APP_NAME)
|
|
853
|
+
: path.join(
|
|
854
|
+
process.env.USERPROFILE,
|
|
855
|
+
"AppData",
|
|
856
|
+
"Roaming",
|
|
857
|
+
process.env.POSITRON_APP_NAME
|
|
858
|
+
);
|
|
859
|
+
} else if (process.platform === "darwin") {
|
|
860
|
+
userPath = path.join(
|
|
861
|
+
process.env.HOME,
|
|
862
|
+
"Library",
|
|
863
|
+
"Application Support",
|
|
864
|
+
process.env.POSITRON_APP_NAME
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if(!fs.existsSync(userPath)) {
|
|
869
|
+
fs.mkdirSync(userPath, { recursive: true });
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return userPath;
|
|
873
|
+
},
|
|
874
|
+
|
|
875
|
+
create() {
|
|
876
|
+
const userPath = this.getPath();
|
|
877
|
+
|
|
878
|
+
if (!fs.existsSync(userPath)) {
|
|
879
|
+
fs.mkdirSync(userPath, { recursive: true });
|
|
880
|
+
success("User data directory created successfully.");
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
|
|
884
|
+
delete() {
|
|
885
|
+
const userPath = this.getPath();
|
|
886
|
+
|
|
887
|
+
if (fs.existsSync(userPath)) {
|
|
888
|
+
fs.rmSync(userPath, { recursive: true, force: true });
|
|
889
|
+
success("User data deleted successfully.");
|
|
890
|
+
} else {
|
|
891
|
+
warn("User data path does not exist:", userPath);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Full access to the underlying event emitter for application-level events, allowing for advanced event handling patterns if needed.
|
|
898
|
+
*/
|
|
899
|
+
events: appEvents
|
|
900
|
+
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
module.exports = { Window, ipc, isPackaged, app, PORT };
|
|
904
|
+
|
|
905
|
+
const findNearestPackageJson = require("./findpackage");
|
|
906
|
+
|
|
907
|
+
const pkgjson = findNearestPackageJson()?.packageJson;
|
|
908
|
+
app.setName(pkgjson?.productName || pkgjson?.name || app.name);
|
|
909
|
+
|
|
910
|
+
httpServer.listen(PORT, HOST, () => {
|
|
911
|
+
info("IPC server running on " + HOST + ":" + PORT);
|
|
912
|
+
});
|