@atom-js-org/runtime 0.2.0-alpha.0 → 0.4.0-alpha.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atom-js-org/runtime",
3
- "version": "0.2.0-alpha.0",
3
+ "version": "0.4.0-alpha.0",
4
4
  "description": "Electron-like desktop runtime powered by the operating system WebView.",
5
5
  "main": "src/index.cjs",
6
6
  "types": "index.d.ts",
package/src/app.cjs CHANGED
@@ -6,6 +6,7 @@ const path = require('node:path');
6
6
  const fs = require('node:fs');
7
7
  const state = require('./state.cjs');
8
8
  const { BridgeServer } = require('./bridge-server.cjs');
9
+ const { stopNativeHost } = require('./native-host.cjs');
9
10
 
10
11
  class App extends EventEmitter {
11
12
  constructor() {
@@ -41,6 +42,7 @@ class App extends EventEmitter {
41
42
  }
42
43
 
43
44
  for (const win of [...state.windows.values()]) win.destroy();
45
+ await stopNativeHost();
44
46
  if (state.bridgeServer) await state.bridgeServer.stop();
45
47
  this.emit('will-quit');
46
48
  this.emit('quit', {}, 0);
@@ -9,6 +9,7 @@ const state = require('./state.cjs');
9
9
  const app = require('./app.cjs');
10
10
  const { WebContents } = require('./web-contents.cjs');
11
11
  const { generateBridgeScript } = require('./bridge-script.cjs');
12
+ const { getNativeHost } = require('./native-host.cjs');
12
13
 
13
14
  class BrowserWindow extends EventEmitter {
14
15
  constructor(options = {}) {
@@ -17,6 +18,8 @@ class BrowserWindow extends EventEmitter {
17
18
  this.options = normalizeOptions(options);
18
19
  this.webContents = new WebContents(this);
19
20
  this._child = null;
21
+ this._nativeHost = null;
22
+ this._hostAttached = false;
20
23
  this._destroyed = false;
21
24
  this._rendererReady = false;
22
25
  this._visible = this.options.show;
@@ -26,14 +29,20 @@ class BrowserWindow extends EventEmitter {
26
29
  this._menu = null;
27
30
  this._menuBarVisible = true;
28
31
  this._lastFinishedLoad = null;
32
+ this._fullScreen = false;
33
+ this._maximized = false;
34
+ this._minimized = false;
35
+ this._bounds = {
36
+ x: 0,
37
+ y: 0,
38
+ width: this.options.width,
39
+ height: this.options.height
40
+ };
29
41
  state.windows.set(this.id, this);
30
42
  }
31
43
 
32
44
  loadFile(filePath) {
33
45
  const task = this._loadFile(filePath);
34
- // Electron applications commonly call loadFile() without awaiting it. Keep
35
- // failures observable to callers while preventing a second, unhandled
36
- // rejection from terminating modern Node.js processes.
37
46
  task.catch(() => {});
38
47
  return task;
39
48
  }
@@ -61,43 +70,63 @@ class BrowserWindow extends EventEmitter {
61
70
 
62
71
  async _load(url) {
63
72
  if (this._destroyed) throw new Error('BrowserWindow has been destroyed');
64
- this._currentUrl = url;
73
+ this._currentUrl = String(url);
65
74
 
66
- if (this._child) {
67
- state.bridgeServer.send(this.id, { type: 'system', command: 'navigate', url });
75
+ if (this._hostAttached) {
76
+ this._sendHostCommand({ command: 'navigate', url: this._currentUrl });
68
77
  return;
69
78
  }
70
79
 
71
80
  const preloadPath = this.options.webPreferences.preload;
72
81
  let preloadCode = '';
73
- if (preloadPath) {
74
- preloadCode = await fs.promises.readFile(path.resolve(preloadPath), 'utf8');
75
- }
82
+ if (preloadPath) preloadCode = await fs.promises.readFile(path.resolve(preloadPath), 'utf8');
76
83
 
77
84
  const config = {
78
85
  title: this.options.title || app.getName(),
79
86
  width: this.options.width,
80
87
  height: this.options.height,
81
88
  resizable: this.options.resizable,
89
+ center: this.options.center,
90
+ frame: this.options.frame,
91
+ show: this.options.show,
92
+ backgroundColor: this.options.backgroundColor,
82
93
  debug: Boolean(this.options.webPreferences.devTools || process.env.ATOM_DEV === '1'),
83
- url,
94
+ url: this._currentUrl,
84
95
  bridgeScript: generateBridgeScript({
85
96
  websocketUrl: state.bridgeServer.websocketUrl(this.id),
86
97
  preloadCode
87
98
  })
88
99
  };
89
100
 
101
+ this._pendingLoad = this._createPendingLoad();
102
+
103
+ if (process.platform === 'darwin') {
104
+ this._nativeHost = getNativeHost(app.getName());
105
+ this._hostAttached = true;
106
+ await this._nativeHost.createWindow(this, config);
107
+ } else {
108
+ await this._startLegacyHost(config);
109
+ }
110
+
111
+ this._pendingLoad.catch(() => {});
112
+ return this._pendingLoad;
113
+ }
114
+
115
+ async _startLegacyHost(config) {
90
116
  const configPath = path.join(os.tmpdir(), `atomjs-window-${process.pid}-${this.id}-${Date.now()}.json`);
91
117
  await fs.promises.writeFile(configPath, JSON.stringify(config), { mode: 0o600 });
92
118
 
93
- const hostPath = path.join(__dirname, 'runtime', 'window-host.mjs');
119
+ const hostPath = process.env.ATOM_WINDOW_HOST_ENTRY || path.join(__dirname, 'runtime', 'window-host.mjs');
94
120
  const nodeExecutable = process.env.ATOM_NODE_EXECUTABLE || process.execPath;
95
- this._child = spawn(nodeExecutable, [hostPath, configPath], {
121
+ const hostArgs = process.env.ATOM_EMBEDDED_RUNTIME === '1'
122
+ ? ['--atomjs-window-host', configPath]
123
+ : [hostPath, configPath];
124
+ this._child = spawn(nodeExecutable, hostArgs, {
96
125
  cwd: state.projectRoot,
97
126
  env: process.env,
98
127
  stdio: ['ignore', 'pipe', 'inherit']
99
128
  });
100
-
129
+ this._hostAttached = true;
101
130
  attachHostOutput(this, this._child);
102
131
 
103
132
  this._child.once('error', (error) => {
@@ -111,16 +140,22 @@ class BrowserWindow extends EventEmitter {
111
140
  const failedBeforeReady = !this._rendererReady && code !== 0 && signal == null;
112
141
  if (failedBeforeReady) {
113
142
  process.exitCode = code || 1;
114
- this.webContents.emit('did-fail-load', {}, code || 1, 'AtomJS window host exited before the renderer became ready', this._currentUrl, true);
143
+ this.webContents.emit(
144
+ 'did-fail-load',
145
+ {},
146
+ code || 1,
147
+ 'AtomJS window host exited before the renderer became ready',
148
+ this._currentUrl,
149
+ true
150
+ );
115
151
  }
116
- this._destroyed = true;
117
- state.windows.delete(this.id);
118
- this.emit('closed', { code, signal });
119
- if (state.windows.size === 0 && !state.isQuitting) app.emit('window-all-closed');
152
+ this._finalizeClosed({ code, signal });
120
153
  }
121
154
  });
155
+ }
122
156
 
123
- this._pendingLoad = new Promise((resolve, reject) => {
157
+ _createPendingLoad() {
158
+ return new Promise((resolve, reject) => {
124
159
  const timeout = setTimeout(() => {
125
160
  cleanup();
126
161
  reject(new Error('Renderer did not become ready within 20 seconds'));
@@ -141,11 +176,14 @@ class BrowserWindow extends EventEmitter {
141
176
  this.once('ready-to-show', onReady);
142
177
  this.once('closed', onClosed);
143
178
  });
179
+ }
144
180
 
145
- // Keep Electron-style fire-and-forget loadFile() usage from producing an
146
- // unhandled rejection while still returning the rejecting promise to callers.
147
- this._pendingLoad.catch(() => {});
148
- return this._pendingLoad;
181
+ _sendHostCommand(command) {
182
+ if (this._nativeHost) {
183
+ this._nativeHost.send({ ...command, windowId: this.id });
184
+ return true;
185
+ }
186
+ return false;
149
187
  }
150
188
 
151
189
  _markRendererReady(details) {
@@ -154,6 +192,29 @@ class BrowserWindow extends EventEmitter {
154
192
 
155
193
  _handleHostEvent(event) {
156
194
  if (!event || typeof event !== 'object') return;
195
+
196
+ if (event.type === 'closed') {
197
+ this._finalizeClosed({ code: 0, signal: null });
198
+ return;
199
+ }
200
+ if (event.type === 'focus') {
201
+ this.emit('focus');
202
+ return;
203
+ }
204
+ if (event.type === 'blur') {
205
+ this.emit('blur');
206
+ return;
207
+ }
208
+ if (event.type === 'minimize') {
209
+ this._minimized = true;
210
+ this.emit('minimize');
211
+ return;
212
+ }
213
+ if (event.type === 'restore') {
214
+ this._minimized = false;
215
+ this.emit('restore');
216
+ return;
217
+ }
157
218
  if (event.type === 'did-start-loading') {
158
219
  if (event.url) this._currentUrl = String(event.url);
159
220
  this.webContents.emit('did-start-loading');
@@ -179,10 +240,12 @@ class BrowserWindow extends EventEmitter {
179
240
  if (href) this._currentUrl = href;
180
241
 
181
242
  const now = Date.now();
182
- if (this._lastFinishedLoad &&
183
- this._lastFinishedLoad.url === this._currentUrl &&
184
- this._lastFinishedLoad.source !== source &&
185
- now - this._lastFinishedLoad.time < 300) {
243
+ if (
244
+ this._lastFinishedLoad &&
245
+ this._lastFinishedLoad.url === this._currentUrl &&
246
+ this._lastFinishedLoad.source !== source &&
247
+ now - this._lastFinishedLoad.time < 300
248
+ ) {
186
249
  return;
187
250
  }
188
251
  this._lastFinishedLoad = { url: this._currentUrl, time: now, source };
@@ -199,14 +262,26 @@ class BrowserWindow extends EventEmitter {
199
262
  }
200
263
  }
201
264
 
265
+ _finalizeClosed(details) {
266
+ if (this._destroyed) return;
267
+ this._destroyed = true;
268
+ this._hostAttached = false;
269
+ state.windows.delete(this.id);
270
+ this.emit('closed', details);
271
+ if (state.windows.size === 0 && !state.isQuitting) app.emit('window-all-closed');
272
+ }
273
+
202
274
  show() {
203
275
  this._visible = true;
276
+ this._sendHostCommand({ command: 'show' });
204
277
  this.emit('show');
205
278
  }
206
279
 
207
280
  hide() {
208
281
  this._visible = false;
209
- console.warn('[AtomJS] Native hide/show is not yet supported by the current pure-JS window host.');
282
+ if (!this._sendHostCommand({ command: 'hide' })) {
283
+ console.warn('[AtomJS] Native hide is not supported by the current platform host.');
284
+ }
210
285
  this.emit('hide');
211
286
  }
212
287
 
@@ -215,7 +290,9 @@ class BrowserWindow extends EventEmitter {
215
290
  }
216
291
 
217
292
  focus() {
218
- console.warn('[AtomJS] Native focus is not yet supported by the current window host.');
293
+ if (!this._sendHostCommand({ command: 'focus' })) {
294
+ console.warn('[AtomJS] Native focus is not supported by the current platform host.');
295
+ }
219
296
  }
220
297
 
221
298
  blur() {}
@@ -225,6 +302,8 @@ class BrowserWindow extends EventEmitter {
225
302
  const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
226
303
  this.emit('close', event);
227
304
  if (event.defaultPrevented) return;
305
+
306
+ if (this._sendHostCommand({ command: 'close' })) return;
228
307
  state.bridgeServer && state.bridgeServer.send(this.id, { type: 'system', command: 'close' });
229
308
  setTimeout(() => {
230
309
  if (!this._destroyed && this._child) this._child.kill();
@@ -233,13 +312,14 @@ class BrowserWindow extends EventEmitter {
233
312
 
234
313
  destroy() {
235
314
  if (this._destroyed) return;
236
- this._destroyed = true;
237
- state.windows.delete(this.id);
315
+ if (this._nativeHost) {
316
+ try { this._sendHostCommand({ command: 'destroy' }); } catch {}
317
+ }
238
318
  if (this._child) {
239
319
  try { this._child.kill(); } catch {}
240
320
  this._child = null;
241
321
  }
242
- this.emit('closed');
322
+ this._finalizeClosed({ code: 0, signal: null });
243
323
  }
244
324
 
245
325
  isDestroyed() {
@@ -248,7 +328,7 @@ class BrowserWindow extends EventEmitter {
248
328
 
249
329
  setTitle(title) {
250
330
  this.options.title = String(title);
251
- if (state.bridgeServer) {
331
+ if (!this._sendHostCommand({ command: 'set-title', title: this.options.title }) && state.bridgeServer) {
252
332
  state.bridgeServer.send(this.id, { type: 'system', command: 'set-title', title: this.options.title });
253
333
  }
254
334
  }
@@ -257,7 +337,6 @@ class BrowserWindow extends EventEmitter {
257
337
  return this.options.title || app.getName();
258
338
  }
259
339
 
260
-
261
340
  setMenu(menu) {
262
341
  this._menu = menu || null;
263
342
  return this;
@@ -286,51 +365,69 @@ class BrowserWindow extends EventEmitter {
286
365
  setAutoHideMenuBar() {}
287
366
 
288
367
  isFullScreen() {
289
- return false;
368
+ return this._fullScreen;
290
369
  }
291
370
 
292
- setFullScreen() {
293
- console.warn('[AtomJS] Native fullscreen switching is not implemented yet.');
371
+ setFullScreen(value = true) {
372
+ this._fullScreen = Boolean(value);
373
+ if (!this._sendHostCommand({ command: 'fullscreen', value: this._fullScreen })) {
374
+ console.warn('[AtomJS] Native fullscreen switching is not supported by the current platform host.');
375
+ }
294
376
  }
295
377
 
296
378
  maximize() {
297
- console.warn('[AtomJS] Native maximize is not implemented yet.');
379
+ this._maximized = true;
380
+ if (!this._sendHostCommand({ command: 'maximize' })) {
381
+ console.warn('[AtomJS] Native maximize is not supported by the current platform host.');
382
+ }
298
383
  }
299
384
 
300
- unmaximize() {}
385
+ unmaximize() {
386
+ this._maximized = false;
387
+ this._sendHostCommand({ command: 'unmaximize' });
388
+ }
301
389
 
302
390
  isMaximized() {
303
- return false;
391
+ return this._maximized;
304
392
  }
305
393
 
306
394
  minimize() {
307
- console.warn('[AtomJS] Native minimize is not implemented yet.');
395
+ this._minimized = true;
396
+ if (!this._sendHostCommand({ command: 'minimize' })) {
397
+ console.warn('[AtomJS] Native minimize is not supported by the current platform host.');
398
+ }
308
399
  }
309
400
 
310
- restore() {}
401
+ restore() {
402
+ this._minimized = false;
403
+ this._sendHostCommand({ command: 'restore' });
404
+ }
311
405
 
312
406
  isMinimized() {
313
- return false;
407
+ return this._minimized;
314
408
  }
315
409
 
316
410
  setSize(width, height) {
317
- this.options.width = Number(width);
318
- this.options.height = Number(height);
319
- console.warn('[AtomJS] Changing the native window size after creation is not yet supported.');
411
+ this.setBounds({ width, height });
320
412
  }
321
413
 
322
414
  getSize() {
323
- return [this.options.width, this.options.height];
415
+ return [this._bounds.width, this._bounds.height];
324
416
  }
325
417
 
326
418
  setBounds(bounds) {
327
- if (bounds.width) this.options.width = Number(bounds.width);
328
- if (bounds.height) this.options.height = Number(bounds.height);
329
- console.warn('[AtomJS] Changing native bounds after creation is not yet supported.');
419
+ const next = { ...this._bounds };
420
+ for (const key of ['x', 'y', 'width', 'height']) {
421
+ if (Number.isFinite(Number(bounds?.[key]))) next[key] = Number(bounds[key]);
422
+ }
423
+ this._bounds = next;
424
+ if (!this._sendHostCommand({ command: 'set-bounds', bounds: next })) {
425
+ console.warn('[AtomJS] Changing native bounds is not supported by the current platform host.');
426
+ }
330
427
  }
331
428
 
332
429
  getBounds() {
333
- return { x: 0, y: 0, width: this.options.width, height: this.options.height };
430
+ return { ...this._bounds };
334
431
  }
335
432
 
336
433
  reload() {
@@ -372,7 +469,7 @@ function attachHostOutput(win, child) {
372
469
  function handleHostLine(win, line) {
373
470
  const prefix = '__ATOMJS_EVENT__';
374
471
  if (!line.startsWith(prefix)) {
375
- if (line) process.stdout.write(line + '\n');
472
+ if (line) process.stdout.write(`${line}\n`);
376
473
  return;
377
474
  }
378
475
  try {
package/src/dialog.cjs CHANGED
@@ -2,10 +2,18 @@
2
2
 
3
3
  const { execFile } = require('node:child_process');
4
4
  const { promisify } = require('node:util');
5
+ const app = require('./app.cjs');
6
+ const { getNativeHost } = require('./native-host.cjs');
7
+
5
8
  const execFileAsync = promisify(execFile);
6
9
 
7
- async function showOpenDialog(_browserWindowOrOptions, maybeOptions) {
8
- const options = normalizeOptions(_browserWindowOrOptions, maybeOptions);
10
+ async function showOpenDialog(browserWindowOrOptions, maybeOptions) {
11
+ const options = normalizeOptions(browserWindowOrOptions, maybeOptions);
12
+
13
+ if (process.platform === 'darwin') {
14
+ return macOSRequest('dialog-open', options);
15
+ }
16
+
9
17
  try {
10
18
  const filePaths = await openDialogForPlatform(options);
11
19
  return { canceled: filePaths.length === 0, filePaths };
@@ -15,8 +23,17 @@ async function showOpenDialog(_browserWindowOrOptions, maybeOptions) {
15
23
  }
16
24
  }
17
25
 
18
- async function showSaveDialog(_browserWindowOrOptions, maybeOptions) {
19
- const options = normalizeOptions(_browserWindowOrOptions, maybeOptions);
26
+ async function showSaveDialog(browserWindowOrOptions, maybeOptions) {
27
+ const options = normalizeOptions(browserWindowOrOptions, maybeOptions);
28
+
29
+ if (process.platform === 'darwin') {
30
+ const result = await macOSRequest('dialog-save', options);
31
+ return {
32
+ canceled: Boolean(result.canceled),
33
+ filePath: result.filePath || undefined
34
+ };
35
+ }
36
+
20
37
  try {
21
38
  const filePath = await saveDialogForPlatform(options);
22
39
  return { canceled: !filePath, filePath: filePath || undefined };
@@ -26,19 +43,21 @@ async function showSaveDialog(_browserWindowOrOptions, maybeOptions) {
26
43
  }
27
44
  }
28
45
 
29
- async function showMessageBox(_browserWindowOrOptions, maybeOptions) {
30
- const options = normalizeOptions(_browserWindowOrOptions, maybeOptions);
46
+ async function showMessageBox(browserWindowOrOptions, maybeOptions) {
47
+ const options = normalizeOptions(browserWindowOrOptions, maybeOptions);
31
48
  const title = options.title || 'AtomJS';
32
49
  const message = options.message || '';
33
50
 
51
+ if (process.platform === 'darwin') {
52
+ return macOSRequest('dialog-message', { ...options, title, message });
53
+ }
54
+
34
55
  if (process.platform === 'win32') {
35
56
  const script = [
36
57
  'Add-Type -AssemblyName PresentationFramework',
37
58
  `[System.Windows.MessageBox]::Show(${psQuote(message)}, ${psQuote(title)}) | Out-Null`
38
59
  ].join('; ');
39
60
  await execFileAsync('powershell.exe', ['-NoProfile', '-STA', '-Command', script]);
40
- } else if (process.platform === 'darwin') {
41
- await execFileAsync('osascript', ['-e', `display dialog ${appleQuote(message)} with title ${appleQuote(title)} buttons {"OK"} default button "OK"`]);
42
61
  } else {
43
62
  await execFileAsync('zenity', ['--info', `--title=${title}`, `--text=${message}`]);
44
63
  }
@@ -46,6 +65,11 @@ async function showMessageBox(_browserWindowOrOptions, maybeOptions) {
46
65
  return { response: 0, checkboxChecked: false };
47
66
  }
48
67
 
68
+ async function macOSRequest(command, options) {
69
+ const host = getNativeHost(app.getName());
70
+ return host.request({ command, options });
71
+ }
72
+
49
73
  async function openDialogForPlatform(options) {
50
74
  const multiple = Array.isArray(options.properties) && options.properties.includes('multiSelections');
51
75
 
@@ -63,15 +87,6 @@ async function openDialogForPlatform(options) {
63
87
  return splitLines(stdout);
64
88
  }
65
89
 
66
- if (process.platform === 'darwin') {
67
- const prompt = options.title || 'Choose a file';
68
- const script = multiple
69
- ? `set chosenFiles to choose file with prompt ${appleQuote(prompt)} with multiple selections allowed\nset output to ""\nrepeat with f in chosenFiles\nset output to output & POSIX path of f & linefeed\nend repeat\nreturn output`
70
- : `POSIX path of (choose file with prompt ${appleQuote(prompt)})`;
71
- const { stdout } = await execFileAsync('osascript', ['-e', script], { encoding: 'utf8' });
72
- return splitLines(stdout);
73
- }
74
-
75
90
  const args = ['--file-selection'];
76
91
  if (options.title) args.push(`--title=${options.title}`);
77
92
  if (options.defaultPath) args.push(`--filename=${options.defaultPath}`);
@@ -94,13 +109,6 @@ async function saveDialogForPlatform(options) {
94
109
  return stdout.trim();
95
110
  }
96
111
 
97
- if (process.platform === 'darwin') {
98
- const defaultName = options.defaultPath ? String(options.defaultPath).split(/[\\/]/).pop() : 'Untitled';
99
- const script = `POSIX path of (choose file name with prompt ${appleQuote(options.title || 'Save file')} default name ${appleQuote(defaultName)})`;
100
- const { stdout } = await execFileAsync('osascript', ['-e', script], { encoding: 'utf8' });
101
- return stdout.trim();
102
- }
103
-
104
112
  const args = ['--file-selection', '--save', '--confirm-overwrite'];
105
113
  if (options.title) args.push(`--title=${options.title}`);
106
114
  if (options.defaultPath) args.push(`--filename=${options.defaultPath}`);
@@ -134,8 +142,4 @@ function psQuote(value) {
134
142
  return `'${String(value).replace(/'/g, "''")}'`;
135
143
  }
136
144
 
137
- function appleQuote(value) {
138
- return JSON.stringify(String(value));
139
- }
140
-
141
145
  module.exports = { showOpenDialog, showSaveDialog, showMessageBox };