@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.
@@ -0,0 +1,356 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const crypto = require('node:crypto');
7
+ const { spawn } = require('node:child_process');
8
+
9
+ let singleton = null;
10
+
11
+ class NativeHost {
12
+ constructor(appName) {
13
+ this.appName = String(appName || 'AtomJS App');
14
+ this.child = null;
15
+ this.startPromise = null;
16
+ this.readyResolve = null;
17
+ this.readyReject = null;
18
+ this.pending = new Map();
19
+ this.windows = new Map();
20
+ this.stdoutBuffer = '';
21
+ this.nextRequestId = 1;
22
+ this.stopping = false;
23
+ }
24
+
25
+ async createWindow(window, config) {
26
+ await this.ensureStarted();
27
+ this.windows.set(Number(window.id), window);
28
+ this.send({
29
+ command: 'create',
30
+ windowId: Number(window.id),
31
+ config
32
+ });
33
+ }
34
+
35
+ async request(message) {
36
+ await this.ensureStarted();
37
+
38
+ const requestId = `${process.pid}-${this.nextRequestId++}`;
39
+ return new Promise((resolve, reject) => {
40
+ const timeout = setTimeout(() => {
41
+ this.pending.delete(requestId);
42
+ reject(new Error(`AtomJS native host request timed out: ${message.command}`));
43
+ }, 30000);
44
+
45
+ this.pending.set(requestId, { resolve, reject, timeout });
46
+
47
+ try {
48
+ this.send({ ...message, requestId });
49
+ } catch (error) {
50
+ clearTimeout(timeout);
51
+ this.pending.delete(requestId);
52
+ reject(error);
53
+ }
54
+ });
55
+ }
56
+
57
+ send(message) {
58
+ if (!this.child || this.child.killed || !this.child.stdin || this.child.stdin.destroyed) {
59
+ throw new Error('AtomJS native host is not running.');
60
+ }
61
+
62
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
63
+ }
64
+
65
+ async ensureStarted() {
66
+ if (process.platform !== 'darwin') {
67
+ throw new Error('The shared native host is currently implemented for macOS only.');
68
+ }
69
+
70
+ if (this.child && !this.child.killed) return;
71
+ if (!this.startPromise) {
72
+ this.startPromise = this._start().catch((error) => {
73
+ this.startPromise = null;
74
+ throw error;
75
+ });
76
+ }
77
+
78
+ await this.startPromise;
79
+ }
80
+
81
+ async _start() {
82
+ const executable = await resolveNativeHostExecutable();
83
+
84
+ await new Promise((resolve, reject) => {
85
+ const child = spawn(executable, ['--app-name', this.appName], {
86
+ cwd: process.env.ATOM_PROJECT_ROOT || process.cwd(),
87
+ env: process.env,
88
+ stdio: ['pipe', 'pipe', 'inherit']
89
+ });
90
+
91
+ this.child = child;
92
+ this.stopping = false;
93
+ this.stdoutBuffer = '';
94
+ this.readyResolve = resolve;
95
+ this.readyReject = reject;
96
+
97
+ const startupTimeout = setTimeout(() => {
98
+ if (this.readyReject) {
99
+ const rejectReady = this.readyReject;
100
+ this._clearReadyHandlers();
101
+ rejectReady(new Error('AtomJS native macOS host did not become ready within 20 seconds.'));
102
+ }
103
+ try { child.kill(); } catch {}
104
+ }, 20000);
105
+
106
+ child.stdout.setEncoding('utf8');
107
+ child.stdout.on('data', (chunk) => this._handleOutput(chunk));
108
+ child.stdout.on('end', () => {
109
+ if (this.stdoutBuffer) {
110
+ this._handleLine(this.stdoutBuffer.replace(/\r$/, ''));
111
+ this.stdoutBuffer = '';
112
+ }
113
+ });
114
+
115
+ child.once('error', (error) => {
116
+ clearTimeout(startupTimeout);
117
+ if (this.readyReject) {
118
+ const rejectReady = this.readyReject;
119
+ this._clearReadyHandlers();
120
+ rejectReady(error);
121
+ }
122
+ });
123
+
124
+ child.once('exit', (code, signal) => {
125
+ clearTimeout(startupTimeout);
126
+ const wasStopping = this.stopping;
127
+ this.child = null;
128
+ this.startPromise = null;
129
+
130
+ if (this.readyReject) {
131
+ const rejectReady = this.readyReject;
132
+ this._clearReadyHandlers();
133
+ rejectReady(new Error(`AtomJS native macOS host exited before startup (code ${code}, signal ${signal || 'none'}).`));
134
+ }
135
+
136
+ const error = new Error(`AtomJS native macOS host exited (code ${code}, signal ${signal || 'none'}).`);
137
+ for (const pending of this.pending.values()) {
138
+ clearTimeout(pending.timeout);
139
+ pending.reject(error);
140
+ }
141
+ this.pending.clear();
142
+
143
+ if (!wasStopping) {
144
+ for (const window of this.windows.values()) {
145
+ try {
146
+ window._handleHostEvent({
147
+ type: 'did-fail-load',
148
+ error: error.message,
149
+ url: window._currentUrl || ''
150
+ });
151
+ } catch {}
152
+ }
153
+ }
154
+ this.windows.clear();
155
+ });
156
+
157
+ this._startupTimeout = startupTimeout;
158
+ });
159
+ }
160
+
161
+ _handleOutput(chunk) {
162
+ this.stdoutBuffer += chunk;
163
+
164
+ let newline;
165
+ while ((newline = this.stdoutBuffer.indexOf('\n')) !== -1) {
166
+ const line = this.stdoutBuffer.slice(0, newline).replace(/\r$/, '');
167
+ this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
168
+ this._handleLine(line);
169
+ }
170
+ }
171
+
172
+ _handleLine(line) {
173
+ if (!line) return;
174
+
175
+ const prefix = '__ATOMJS_EVENT__';
176
+ if (!line.startsWith(prefix)) {
177
+ process.stdout.write(`${line}\n`);
178
+ return;
179
+ }
180
+
181
+ let event;
182
+ try {
183
+ event = JSON.parse(line.slice(prefix.length));
184
+ } catch (error) {
185
+ console.warn('[AtomJS] Invalid native-host event:', error.message);
186
+ return;
187
+ }
188
+
189
+ if (event.type === 'ready') {
190
+ if (this._startupTimeout) clearTimeout(this._startupTimeout);
191
+ if (this.readyResolve) {
192
+ const resolveReady = this.readyResolve;
193
+ this._clearReadyHandlers();
194
+ resolveReady();
195
+ }
196
+ return;
197
+ }
198
+
199
+ if (event.type === 'response' && event.requestId) {
200
+ const pending = this.pending.get(String(event.requestId));
201
+ if (!pending) return;
202
+
203
+ this.pending.delete(String(event.requestId));
204
+ clearTimeout(pending.timeout);
205
+
206
+ if (event.ok === false) {
207
+ pending.reject(new Error(event.error || 'AtomJS native host request failed.'));
208
+ } else {
209
+ pending.resolve(event.result);
210
+ }
211
+ return;
212
+ }
213
+
214
+ const windowId = Number(event.windowId);
215
+ if (!Number.isFinite(windowId)) return;
216
+
217
+ const window = this.windows.get(windowId);
218
+ if (!window) return;
219
+
220
+ if (event.type === 'closed') this.windows.delete(windowId);
221
+ window._handleHostEvent(event);
222
+ }
223
+
224
+ _clearReadyHandlers() {
225
+ this.readyResolve = null;
226
+ this.readyReject = null;
227
+ this._startupTimeout = null;
228
+ }
229
+
230
+ async stop() {
231
+ if (!this.child) return;
232
+
233
+ this.stopping = true;
234
+ const child = this.child;
235
+
236
+ const exited = new Promise((resolve) => {
237
+ child.once('exit', () => resolve());
238
+ });
239
+
240
+ try {
241
+ this.send({ command: 'quit' });
242
+ } catch {}
243
+
244
+ const timeout = new Promise((resolve) => {
245
+ setTimeout(() => {
246
+ if (this.child === child && !child.killed) {
247
+ try { child.kill('SIGTERM'); } catch {}
248
+ }
249
+ resolve();
250
+ }, 1500).unref();
251
+ });
252
+
253
+ await Promise.race([exited, timeout]);
254
+ }
255
+ }
256
+
257
+ function getNativeHost(appName) {
258
+ if (!singleton) singleton = new NativeHost(appName);
259
+ return singleton;
260
+ }
261
+
262
+ async function stopNativeHost() {
263
+ if (!singleton) return;
264
+ const host = singleton;
265
+ singleton = null;
266
+ await host.stop();
267
+ }
268
+
269
+ async function resolveNativeHostExecutable() {
270
+ const bundled = process.env.ATOM_MACOS_HOST_EXECUTABLE;
271
+ if (bundled) {
272
+ const resolved = path.resolve(bundled);
273
+ if (!fs.existsSync(resolved)) {
274
+ throw new Error(`AtomJS native macOS host executable was not found: ${resolved}`);
275
+ }
276
+ return resolved;
277
+ }
278
+
279
+ const source = path.join(__dirname, 'runtime', 'macos-native-host.m');
280
+ if (!fs.existsSync(source)) {
281
+ throw new Error(`AtomJS native macOS host source was not found: ${source}`);
282
+ }
283
+
284
+ const sourceData = await fs.promises.readFile(source);
285
+ const hash = crypto
286
+ .createHash('sha256')
287
+ .update(sourceData)
288
+ .update(process.arch)
289
+ .digest('hex')
290
+ .slice(0, 20);
291
+
292
+ const outputDirectory = path.join(os.tmpdir(), 'atomjs-native-host', hash);
293
+ const executable = path.join(outputDirectory, 'AtomJSWindowHost');
294
+ if (fs.existsSync(executable)) return executable;
295
+
296
+ await fs.promises.mkdir(outputDirectory, { recursive: true });
297
+ const temporary = `${executable}.tmp-${process.pid}`;
298
+
299
+ await runCompiler([
300
+ 'clang',
301
+ '-fobjc-arc',
302
+ '-fmodules',
303
+ '-mmacosx-version-min=12.0',
304
+ '-framework', 'Cocoa',
305
+ '-framework', 'WebKit',
306
+ source,
307
+ '-o', temporary
308
+ ]);
309
+
310
+ await fs.promises.chmod(temporary, 0o755);
311
+ try {
312
+ await fs.promises.rename(temporary, executable);
313
+ } catch (error) {
314
+ if (error.code !== 'EEXIST') throw error;
315
+ await fs.promises.rm(temporary, { force: true });
316
+ }
317
+
318
+ return executable;
319
+ }
320
+
321
+ async function runCompiler(args) {
322
+ const xcrun = '/usr/bin/xcrun';
323
+ if (!fs.existsSync(xcrun)) {
324
+ throw new Error('AtomJS requires the Xcode Command Line Tools. Run `xcode-select --install`.');
325
+ }
326
+
327
+ await new Promise((resolve, reject) => {
328
+ const child = spawn(xcrun, args, {
329
+ stdio: ['ignore', 'pipe', 'pipe']
330
+ });
331
+
332
+ let stdout = '';
333
+ let stderr = '';
334
+ child.stdout.setEncoding('utf8');
335
+ child.stderr.setEncoding('utf8');
336
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
337
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
338
+ child.once('error', reject);
339
+ child.once('exit', (code) => {
340
+ if (code === 0) {
341
+ resolve();
342
+ return;
343
+ }
344
+
345
+ reject(new Error([
346
+ 'AtomJS could not compile the native macOS WKWebView host.',
347
+ stderr.trim() || stdout.trim() || `xcrun exited with code ${code}`
348
+ ].join('\n')));
349
+ });
350
+ });
351
+ }
352
+
353
+ module.exports = {
354
+ getNativeHost,
355
+ stopNativeHost
356
+ };