@atom-js-org/runtime 0.2.0-alpha.0 → 0.3.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.3.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,32 +70,49 @@ 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
 
@@ -97,7 +123,7 @@ class BrowserWindow extends EventEmitter {
97
123
  env: process.env,
98
124
  stdio: ['ignore', 'pipe', 'inherit']
99
125
  });
100
-
126
+ this._hostAttached = true;
101
127
  attachHostOutput(this, this._child);
102
128
 
103
129
  this._child.once('error', (error) => {
@@ -111,16 +137,22 @@ class BrowserWindow extends EventEmitter {
111
137
  const failedBeforeReady = !this._rendererReady && code !== 0 && signal == null;
112
138
  if (failedBeforeReady) {
113
139
  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);
140
+ this.webContents.emit(
141
+ 'did-fail-load',
142
+ {},
143
+ code || 1,
144
+ 'AtomJS window host exited before the renderer became ready',
145
+ this._currentUrl,
146
+ true
147
+ );
115
148
  }
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');
149
+ this._finalizeClosed({ code, signal });
120
150
  }
121
151
  });
152
+ }
122
153
 
123
- this._pendingLoad = new Promise((resolve, reject) => {
154
+ _createPendingLoad() {
155
+ return new Promise((resolve, reject) => {
124
156
  const timeout = setTimeout(() => {
125
157
  cleanup();
126
158
  reject(new Error('Renderer did not become ready within 20 seconds'));
@@ -141,11 +173,14 @@ class BrowserWindow extends EventEmitter {
141
173
  this.once('ready-to-show', onReady);
142
174
  this.once('closed', onClosed);
143
175
  });
176
+ }
144
177
 
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;
178
+ _sendHostCommand(command) {
179
+ if (this._nativeHost) {
180
+ this._nativeHost.send({ ...command, windowId: this.id });
181
+ return true;
182
+ }
183
+ return false;
149
184
  }
150
185
 
151
186
  _markRendererReady(details) {
@@ -154,6 +189,29 @@ class BrowserWindow extends EventEmitter {
154
189
 
155
190
  _handleHostEvent(event) {
156
191
  if (!event || typeof event !== 'object') return;
192
+
193
+ if (event.type === 'closed') {
194
+ this._finalizeClosed({ code: 0, signal: null });
195
+ return;
196
+ }
197
+ if (event.type === 'focus') {
198
+ this.emit('focus');
199
+ return;
200
+ }
201
+ if (event.type === 'blur') {
202
+ this.emit('blur');
203
+ return;
204
+ }
205
+ if (event.type === 'minimize') {
206
+ this._minimized = true;
207
+ this.emit('minimize');
208
+ return;
209
+ }
210
+ if (event.type === 'restore') {
211
+ this._minimized = false;
212
+ this.emit('restore');
213
+ return;
214
+ }
157
215
  if (event.type === 'did-start-loading') {
158
216
  if (event.url) this._currentUrl = String(event.url);
159
217
  this.webContents.emit('did-start-loading');
@@ -179,10 +237,12 @@ class BrowserWindow extends EventEmitter {
179
237
  if (href) this._currentUrl = href;
180
238
 
181
239
  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) {
240
+ if (
241
+ this._lastFinishedLoad &&
242
+ this._lastFinishedLoad.url === this._currentUrl &&
243
+ this._lastFinishedLoad.source !== source &&
244
+ now - this._lastFinishedLoad.time < 300
245
+ ) {
186
246
  return;
187
247
  }
188
248
  this._lastFinishedLoad = { url: this._currentUrl, time: now, source };
@@ -199,14 +259,26 @@ class BrowserWindow extends EventEmitter {
199
259
  }
200
260
  }
201
261
 
262
+ _finalizeClosed(details) {
263
+ if (this._destroyed) return;
264
+ this._destroyed = true;
265
+ this._hostAttached = false;
266
+ state.windows.delete(this.id);
267
+ this.emit('closed', details);
268
+ if (state.windows.size === 0 && !state.isQuitting) app.emit('window-all-closed');
269
+ }
270
+
202
271
  show() {
203
272
  this._visible = true;
273
+ this._sendHostCommand({ command: 'show' });
204
274
  this.emit('show');
205
275
  }
206
276
 
207
277
  hide() {
208
278
  this._visible = false;
209
- console.warn('[AtomJS] Native hide/show is not yet supported by the current pure-JS window host.');
279
+ if (!this._sendHostCommand({ command: 'hide' })) {
280
+ console.warn('[AtomJS] Native hide is not supported by the current platform host.');
281
+ }
210
282
  this.emit('hide');
211
283
  }
212
284
 
@@ -215,7 +287,9 @@ class BrowserWindow extends EventEmitter {
215
287
  }
216
288
 
217
289
  focus() {
218
- console.warn('[AtomJS] Native focus is not yet supported by the current window host.');
290
+ if (!this._sendHostCommand({ command: 'focus' })) {
291
+ console.warn('[AtomJS] Native focus is not supported by the current platform host.');
292
+ }
219
293
  }
220
294
 
221
295
  blur() {}
@@ -225,6 +299,8 @@ class BrowserWindow extends EventEmitter {
225
299
  const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
226
300
  this.emit('close', event);
227
301
  if (event.defaultPrevented) return;
302
+
303
+ if (this._sendHostCommand({ command: 'close' })) return;
228
304
  state.bridgeServer && state.bridgeServer.send(this.id, { type: 'system', command: 'close' });
229
305
  setTimeout(() => {
230
306
  if (!this._destroyed && this._child) this._child.kill();
@@ -233,13 +309,14 @@ class BrowserWindow extends EventEmitter {
233
309
 
234
310
  destroy() {
235
311
  if (this._destroyed) return;
236
- this._destroyed = true;
237
- state.windows.delete(this.id);
312
+ if (this._nativeHost) {
313
+ try { this._sendHostCommand({ command: 'destroy' }); } catch {}
314
+ }
238
315
  if (this._child) {
239
316
  try { this._child.kill(); } catch {}
240
317
  this._child = null;
241
318
  }
242
- this.emit('closed');
319
+ this._finalizeClosed({ code: 0, signal: null });
243
320
  }
244
321
 
245
322
  isDestroyed() {
@@ -248,7 +325,7 @@ class BrowserWindow extends EventEmitter {
248
325
 
249
326
  setTitle(title) {
250
327
  this.options.title = String(title);
251
- if (state.bridgeServer) {
328
+ if (!this._sendHostCommand({ command: 'set-title', title: this.options.title }) && state.bridgeServer) {
252
329
  state.bridgeServer.send(this.id, { type: 'system', command: 'set-title', title: this.options.title });
253
330
  }
254
331
  }
@@ -257,7 +334,6 @@ class BrowserWindow extends EventEmitter {
257
334
  return this.options.title || app.getName();
258
335
  }
259
336
 
260
-
261
337
  setMenu(menu) {
262
338
  this._menu = menu || null;
263
339
  return this;
@@ -286,51 +362,69 @@ class BrowserWindow extends EventEmitter {
286
362
  setAutoHideMenuBar() {}
287
363
 
288
364
  isFullScreen() {
289
- return false;
365
+ return this._fullScreen;
290
366
  }
291
367
 
292
- setFullScreen() {
293
- console.warn('[AtomJS] Native fullscreen switching is not implemented yet.');
368
+ setFullScreen(value = true) {
369
+ this._fullScreen = Boolean(value);
370
+ if (!this._sendHostCommand({ command: 'fullscreen', value: this._fullScreen })) {
371
+ console.warn('[AtomJS] Native fullscreen switching is not supported by the current platform host.');
372
+ }
294
373
  }
295
374
 
296
375
  maximize() {
297
- console.warn('[AtomJS] Native maximize is not implemented yet.');
376
+ this._maximized = true;
377
+ if (!this._sendHostCommand({ command: 'maximize' })) {
378
+ console.warn('[AtomJS] Native maximize is not supported by the current platform host.');
379
+ }
298
380
  }
299
381
 
300
- unmaximize() {}
382
+ unmaximize() {
383
+ this._maximized = false;
384
+ this._sendHostCommand({ command: 'unmaximize' });
385
+ }
301
386
 
302
387
  isMaximized() {
303
- return false;
388
+ return this._maximized;
304
389
  }
305
390
 
306
391
  minimize() {
307
- console.warn('[AtomJS] Native minimize is not implemented yet.');
392
+ this._minimized = true;
393
+ if (!this._sendHostCommand({ command: 'minimize' })) {
394
+ console.warn('[AtomJS] Native minimize is not supported by the current platform host.');
395
+ }
308
396
  }
309
397
 
310
- restore() {}
398
+ restore() {
399
+ this._minimized = false;
400
+ this._sendHostCommand({ command: 'restore' });
401
+ }
311
402
 
312
403
  isMinimized() {
313
- return false;
404
+ return this._minimized;
314
405
  }
315
406
 
316
407
  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.');
408
+ this.setBounds({ width, height });
320
409
  }
321
410
 
322
411
  getSize() {
323
- return [this.options.width, this.options.height];
412
+ return [this._bounds.width, this._bounds.height];
324
413
  }
325
414
 
326
415
  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.');
416
+ const next = { ...this._bounds };
417
+ for (const key of ['x', 'y', 'width', 'height']) {
418
+ if (Number.isFinite(Number(bounds?.[key]))) next[key] = Number(bounds[key]);
419
+ }
420
+ this._bounds = next;
421
+ if (!this._sendHostCommand({ command: 'set-bounds', bounds: next })) {
422
+ console.warn('[AtomJS] Changing native bounds is not supported by the current platform host.');
423
+ }
330
424
  }
331
425
 
332
426
  getBounds() {
333
- return { x: 0, y: 0, width: this.options.width, height: this.options.height };
427
+ return { ...this._bounds };
334
428
  }
335
429
 
336
430
  reload() {
@@ -372,7 +466,7 @@ function attachHostOutput(win, child) {
372
466
  function handleHostLine(win, line) {
373
467
  const prefix = '__ATOMJS_EVENT__';
374
468
  if (!line.startsWith(prefix)) {
375
- if (line) process.stdout.write(line + '\n');
469
+ if (line) process.stdout.write(`${line}\n`);
376
470
  return;
377
471
  }
378
472
  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 };