@atom-js-org/runtime 0.4.2-alpha.0 → 0.4.3-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.4.2-alpha.0",
3
+ "version": "0.4.3-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",
@@ -10,7 +10,8 @@ let singleton = null;
10
10
 
11
11
  class NativeHost {
12
12
  constructor(appName) {
13
- this.appName = String(appName || 'AtomJS App');
13
+ this.metadata = resolveMacHostMetadata(appName);
14
+ this.appName = this.metadata.appName;
14
15
  this.child = null;
15
16
  this.startPromise = null;
16
17
  this.readyResolve = null;
@@ -23,16 +24,23 @@ class NativeHost {
23
24
  }
24
25
 
25
26
  async createWindow(window, config) {
27
+ const windowId = Number(window.id);
26
28
  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
- });
29
+ this.windows.set(windowId, window);
30
+
31
+ try {
32
+ await this.request({
33
+ command: 'create',
34
+ windowId,
35
+ config
36
+ }, 15000);
37
+ } catch (error) {
38
+ this.windows.delete(windowId);
39
+ throw error;
40
+ }
33
41
  }
34
42
 
35
- async request(message) {
43
+ async request(message, timeoutMs = 30000) {
36
44
  await this.ensureStarted();
37
45
 
38
46
  const requestId = `${process.pid}-${this.nextRequestId++}`;
@@ -40,7 +48,7 @@ class NativeHost {
40
48
  const timeout = setTimeout(() => {
41
49
  this.pending.delete(requestId);
42
50
  reject(new Error(`AtomJS native host request timed out: ${message.command}`));
43
- }, 30000);
51
+ }, timeoutMs);
44
52
 
45
53
  this.pending.set(requestId, { resolve, reject, timeout });
46
54
 
@@ -79,10 +87,15 @@ class NativeHost {
79
87
  }
80
88
 
81
89
  async _start() {
82
- const executable = await resolveNativeHostExecutable();
90
+ const executable = await resolveNativeHostExecutable(this.metadata);
91
+ const args = [
92
+ '--app-name', this.metadata.appName,
93
+ '--app-id', this.metadata.appId
94
+ ];
95
+ if (this.metadata.iconPath) args.push('--app-icon', this.metadata.iconPath);
83
96
 
84
97
  await new Promise((resolve, reject) => {
85
- const child = spawn(executable, ['--app-name', this.appName], {
98
+ const child = spawn(executable, args, {
86
99
  cwd: process.env.ATOM_PROJECT_ROOT || process.cwd(),
87
100
  env: process.env,
88
101
  stdio: ['pipe', 'pipe', 'inherit']
@@ -196,6 +209,17 @@ class NativeHost {
196
209
  return;
197
210
  }
198
211
 
212
+ if (event.type === 'host-error') {
213
+ const error = new Error(event.error || 'AtomJS native macOS host reported an error.');
214
+ for (const pending of this.pending.values()) {
215
+ clearTimeout(pending.timeout);
216
+ pending.reject(error);
217
+ }
218
+ this.pending.clear();
219
+ console.error(`[AtomJS] ${error.message}`);
220
+ return;
221
+ }
222
+
199
223
  if (event.type === 'response' && event.requestId) {
200
224
  const pending = this.pending.get(String(event.requestId));
201
225
  if (!pending) return;
@@ -266,7 +290,63 @@ async function stopNativeHost() {
266
290
  await host.stop();
267
291
  }
268
292
 
269
- async function resolveNativeHostExecutable() {
293
+ function resolveMacHostMetadata(appName) {
294
+ const projectRoot = path.resolve(process.env.ATOM_PROJECT_ROOT || process.cwd());
295
+ const packageJson = readJsonIfExists(path.join(projectRoot, 'package.json')) || {};
296
+ const atomConfig = readJsonIfExists(path.join(projectRoot, 'atom.config.json')) || {};
297
+
298
+ const resolvedName = String(
299
+ process.env.ATOM_APP_NAME ||
300
+ appName ||
301
+ atomConfig.productName ||
302
+ packageJson.productName ||
303
+ packageJson.name ||
304
+ 'AtomJS App'
305
+ );
306
+ const appId = sanitizeBundleIdentifier(
307
+ process.env.ATOM_APP_ID ||
308
+ atomConfig.appId ||
309
+ `com.atomjs.${packageJson.name || resolvedName}`
310
+ );
311
+
312
+ const configuredIcon = process.env.ATOM_APP_ICON || atomConfig.icon || null;
313
+ const iconPath = configuredIcon
314
+ ? path.resolve(projectRoot, configuredIcon)
315
+ : null;
316
+
317
+ return {
318
+ appName: resolvedName,
319
+ appId,
320
+ iconPath: iconPath && fs.existsSync(iconPath) ? iconPath : null
321
+ };
322
+ }
323
+
324
+ function readJsonIfExists(filePath) {
325
+ try {
326
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+
332
+ function sanitizeBundleIdentifier(value) {
333
+ const normalized = String(value || 'com.atomjs.app')
334
+ .toLowerCase()
335
+ .replace(/[^a-z0-9.-]+/g, '-')
336
+ .replace(/^[^a-z]+/, '')
337
+ .replace(/\.{2,}/g, '.')
338
+ .replace(/^[.-]+|[.-]+$/g, '');
339
+ return normalized.includes('.') ? normalized : `com.atomjs.${normalized || 'app'}`;
340
+ }
341
+
342
+ function sanitizeMacExecutableName(value) {
343
+ return String(value || 'AtomJS App')
344
+ .replace(/[/:\x00-\x1f]/g, '-')
345
+ .replace(/\s+/g, ' ')
346
+ .trim() || 'AtomJS App';
347
+ }
348
+
349
+ async function resolveNativeHostExecutable(metadata) {
270
350
  const bundled = process.env.ATOM_MACOS_HOST_EXECUTABLE;
271
351
  if (bundled) {
272
352
  const resolved = path.resolve(bundled);
@@ -282,42 +362,84 @@ async function resolveNativeHostExecutable() {
282
362
  }
283
363
 
284
364
  const sourceData = await fs.promises.readFile(source);
285
- const hash = crypto
365
+ const hashBuilder = crypto
286
366
  .createHash('sha256')
287
367
  .update(sourceData)
288
368
  .update(process.arch)
289
- .digest('hex')
290
- .slice(0, 20);
369
+ .update(JSON.stringify({ appName: metadata.appName, appId: metadata.appId }));
370
+ if (metadata.iconPath) hashBuilder.update(await fs.promises.readFile(metadata.iconPath));
371
+ const hash = hashBuilder.digest('hex').slice(0, 20);
291
372
 
373
+ const executableName = sanitizeMacExecutableName(metadata.appName);
292
374
  const outputDirectory = path.join(os.tmpdir(), 'atomjs-native-host', hash);
293
- const executable = path.join(outputDirectory, 'AtomJSWindowHost');
375
+ const appBundle = path.join(outputDirectory, `${executableName}.app`);
376
+ const executable = path.join(appBundle, 'Contents', 'MacOS', executableName);
294
377
  if (fs.existsSync(executable)) return executable;
295
378
 
296
379
  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);
380
+ const temporaryBundle = `${appBundle}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
381
+ const contents = path.join(temporaryBundle, 'Contents');
382
+ const macosDirectory = path.join(contents, 'MacOS');
383
+ const resourcesDirectory = path.join(contents, 'Resources');
384
+ const temporaryExecutable = path.join(macosDirectory, executableName);
385
+
386
+ await fs.promises.mkdir(macosDirectory, { recursive: true });
387
+ await fs.promises.mkdir(resourcesDirectory, { recursive: true });
388
+
311
389
  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 });
390
+ await runCompiler([
391
+ 'clang',
392
+ '-fobjc-arc',
393
+ '-fmodules',
394
+ '-mmacosx-version-min=12.0',
395
+ '-framework', 'Cocoa',
396
+ '-framework', 'WebKit',
397
+ source,
398
+ '-o', temporaryExecutable
399
+ ]);
400
+ await fs.promises.chmod(temporaryExecutable, 0o755);
401
+
402
+ let iconEntry = '';
403
+ if (metadata.iconPath && path.extname(metadata.iconPath).toLowerCase() === '.icns') {
404
+ await fs.promises.copyFile(metadata.iconPath, path.join(resourcesDirectory, 'AppIcon.icns'));
405
+ iconEntry = '<key>CFBundleIconFile</key><string>AppIcon</string>';
406
+ }
407
+
408
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
409
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
410
+ <plist version="1.0"><dict>
411
+ <key>CFBundleExecutable</key><string>${xmlEscape(executableName)}</string>
412
+ <key>CFBundleIdentifier</key><string>${xmlEscape(metadata.appId)}.dev-host</string>
413
+ <key>CFBundleName</key><string>${xmlEscape(metadata.appName)}</string>
414
+ <key>CFBundleDisplayName</key><string>${xmlEscape(metadata.appName)}</string>
415
+ <key>CFBundlePackageType</key><string>APPL</string>
416
+ <key>LSMinimumSystemVersion</key><string>12.0</string>
417
+ <key>NSHighResolutionCapable</key><true/>
418
+ ${iconEntry}
419
+ </dict></plist>`;
420
+ await fs.promises.writeFile(path.join(contents, 'Info.plist'), plist, 'utf8');
421
+
422
+ try {
423
+ await fs.promises.rename(temporaryBundle, appBundle);
424
+ } catch (error) {
425
+ if (error.code !== 'EEXIST' && error.code !== 'ENOTEMPTY') throw error;
426
+ }
427
+ } finally {
428
+ await fs.promises.rm(temporaryBundle, { recursive: true, force: true });
316
429
  }
317
430
 
318
431
  return executable;
319
432
  }
320
433
 
434
+ function xmlEscape(value) {
435
+ return String(value)
436
+ .replace(/&/g, '&amp;')
437
+ .replace(/</g, '&lt;')
438
+ .replace(/>/g, '&gt;')
439
+ .replace(/"/g, '&quot;')
440
+ .replace(/'/g, '&apos;');
441
+ }
442
+
321
443
  async function runCompiler(args) {
322
444
  const xcrun = '/usr/bin/xcrun';
323
445
  if (!fs.existsSync(xcrun)) {
@@ -1,12 +1,16 @@
1
1
  #import <Cocoa/Cocoa.h>
2
2
  #import <WebKit/WebKit.h>
3
3
  #include <string.h>
4
+ #include <unistd.h>
5
+ #include <errno.h>
4
6
 
5
7
  static NSString *const AtomJSEventPrefix = @"__ATOMJS_EVENT__";
6
8
 
7
9
  @class AtomJSWindowController;
8
10
  static NSMutableDictionary<NSNumber *, AtomJSWindowController *> *atomWindows;
9
11
  static NSString *atomAppName = @"AtomJS App";
12
+ static NSString *atomAppIdentifier = @"com.atomjs.app";
13
+ static NSString *atomAppIconPath = nil;
10
14
 
11
15
  static void AtomJSEmit(NSDictionary *payload) {
12
16
  if (![NSJSONSerialization isValidJSONObject:payload]) return;
@@ -66,6 +70,46 @@ static NSColor *AtomJSColor(NSString *hex) {
66
70
  return [NSColor colorWithSRGBRed:red green:green blue:blue alpha:alpha];
67
71
  }
68
72
 
73
+ static NSImage *AtomJSDefaultApplicationIcon(void) {
74
+ NSSize size = NSMakeSize(512.0, 512.0);
75
+ NSImage *image = [[NSImage alloc] initWithSize:size];
76
+ [image lockFocus];
77
+
78
+ NSRect canvas = NSMakeRect(0.0, 0.0, size.width, size.height);
79
+ NSBezierPath *background = [NSBezierPath bezierPathWithRoundedRect:NSInsetRect(canvas, 18.0, 18.0) xRadius:112.0 yRadius:112.0];
80
+ [[NSColor colorWithSRGBRed:0.055 green:0.075 blue:0.11 alpha:1.0] setFill];
81
+ [background fill];
82
+
83
+ NSDictionary *letterAttributes = @{
84
+ NSFontAttributeName: [NSFont systemFontOfSize:252.0 weight:NSFontWeightSemibold],
85
+ NSForegroundColorAttributeName: [NSColor colorWithSRGBRed:0.42 green:0.78 blue:1.0 alpha:1.0]
86
+ };
87
+ NSString *letter = @"A";
88
+ NSSize letterSize = [letter sizeWithAttributes:letterAttributes];
89
+ [letter drawAtPoint:NSMakePoint((size.width - letterSize.width) / 2.0, 154.0) withAttributes:letterAttributes];
90
+
91
+ NSDictionary *suffixAttributes = @{
92
+ NSFontAttributeName: [NSFont systemFontOfSize:82.0 weight:NSFontWeightMedium],
93
+ NSForegroundColorAttributeName: [NSColor whiteColor]
94
+ };
95
+ NSString *suffix = @"JS";
96
+ NSSize suffixSize = [suffix sizeWithAttributes:suffixAttributes];
97
+ [suffix drawAtPoint:NSMakePoint((size.width - suffixSize.width) / 2.0, 82.0) withAttributes:suffixAttributes];
98
+
99
+ [image unlockFocus];
100
+ return image;
101
+ }
102
+
103
+ static void AtomJSConfigureApplicationIdentity(NSApplication *application) {
104
+ [[NSProcessInfo processInfo] setProcessName:atomAppName];
105
+
106
+ NSImage *icon = nil;
107
+ if (atomAppIconPath.length > 0) {
108
+ icon = [[NSImage alloc] initWithContentsOfFile:atomAppIconPath];
109
+ }
110
+ application.applicationIconImage = icon ?: AtomJSDefaultApplicationIcon();
111
+ }
112
+
69
113
  @interface AtomJSWindowController : NSObject <NSWindowDelegate, WKNavigationDelegate>
70
114
  @property(nonatomic, strong) NSNumber *windowId;
71
115
  @property(nonatomic, strong) NSWindow *window;
@@ -124,7 +168,9 @@ static NSColor *AtomJSColor(NSString *hex) {
124
168
  [self navigate:AtomJSString(config[@"url"], @"about:blank")];
125
169
 
126
170
  if (AtomJSBoolean(config[@"show"], YES)) {
171
+ [NSApp unhide:nil];
127
172
  [_window makeKeyAndOrderFront:nil];
173
+ [_window orderFrontRegardless];
128
174
  [NSApp activateIgnoringOtherApps:YES];
129
175
  }
130
176
 
@@ -355,10 +401,23 @@ static void AtomJSHandleMessage(NSDictionary *message) {
355
401
  if ([command isEqualToString:@"create"]) {
356
402
  NSNumber *windowId = AtomJSNumber(message[@"windowId"], nil);
357
403
  NSDictionary *config = [message[@"config"] isKindOfClass:[NSDictionary class]] ? message[@"config"] : @{};
358
- if (!windowId) return;
404
+ if (!windowId) {
405
+ AtomJSRespond(requestId, NO, nil, @"The create command did not include a valid window ID.");
406
+ return;
407
+ }
359
408
 
360
409
  AtomJSWindowController *controller = [[AtomJSWindowController alloc] initWithWindowId:windowId config:config];
410
+ if (!controller || !controller.window || !controller.webView) {
411
+ AtomJSRespond(requestId, NO, nil, @"AppKit could not create the AtomJS window.");
412
+ return;
413
+ }
414
+
361
415
  atomWindows[windowId] = controller;
416
+ AtomJSRespond(requestId, YES, @{ @"windowId": windowId }, nil);
417
+ AtomJSEmit(@{
418
+ @"type": @"created",
419
+ @"windowId": windowId
420
+ });
362
421
  return;
363
422
  }
364
423
 
@@ -391,12 +450,16 @@ static void AtomJSHandleMessage(NSDictionary *message) {
391
450
  if ([command isEqualToString:@"navigate"]) {
392
451
  [controller navigate:AtomJSString(message[@"url"], @"about:blank")];
393
452
  } else if ([command isEqualToString:@"show"]) {
453
+ [NSApp unhide:nil];
394
454
  [controller.window makeKeyAndOrderFront:nil];
455
+ [controller.window orderFrontRegardless];
395
456
  [NSApp activateIgnoringOtherApps:YES];
396
457
  } else if ([command isEqualToString:@"hide"]) {
397
458
  [controller.window orderOut:nil];
398
459
  } else if ([command isEqualToString:@"focus"]) {
460
+ [NSApp unhide:nil];
399
461
  [controller.window makeKeyAndOrderFront:nil];
462
+ [controller.window orderFrontRegardless];
400
463
  [NSApp activateIgnoringOtherApps:YES];
401
464
  } else if ([command isEqualToString:@"close"] || [command isEqualToString:@"destroy"]) {
402
465
  [controller.window close];
@@ -433,14 +496,22 @@ static void AtomJSHandleMessage(NSDictionary *message) {
433
496
 
434
497
  static void AtomJSConsumeInput(void) {
435
498
  dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
436
- NSFileHandle *input = [NSFileHandle fileHandleWithStandardInput];
437
499
  NSMutableData *buffer = [NSMutableData data];
438
500
 
439
501
  while (YES) {
440
502
  @autoreleasepool {
441
- NSData *chunk = [input readDataOfLength:4096];
442
- if (chunk.length == 0) break;
443
- [buffer appendData:chunk];
503
+ unsigned char bytes[4096];
504
+ ssize_t count = read(STDIN_FILENO, bytes, sizeof(bytes));
505
+ if (count < 0) {
506
+ if (errno == EINTR) continue;
507
+ AtomJSEmit(@{
508
+ @"type": @"host-error",
509
+ @"error": [NSString stringWithFormat:@"Could not read native-host input: %s", strerror(errno)]
510
+ });
511
+ break;
512
+ }
513
+ if (count == 0) break;
514
+ [buffer appendBytes:bytes length:(NSUInteger)count];
444
515
 
445
516
  while (YES) {
446
517
  const unsigned char *bytes = buffer.bytes;
@@ -492,26 +563,53 @@ static void AtomJSInstallMenu(void) {
492
563
  NSApp.mainMenu = mainMenu;
493
564
  }
494
565
 
566
+ @interface AtomJSApplicationDelegate : NSObject <NSApplicationDelegate>
567
+ @end
568
+
569
+ @implementation AtomJSApplicationDelegate
570
+
571
+ - (void)applicationWillFinishLaunching:(NSNotification *)notification {
572
+ AtomJSConfigureApplicationIdentity(NSApp);
573
+ AtomJSInstallMenu();
574
+ }
575
+
576
+ - (void)applicationDidFinishLaunching:(NSNotification *)notification {
577
+ AtomJSConsumeInput();
578
+ AtomJSEmit(@{
579
+ @"type": @"ready",
580
+ @"pid": @([[NSProcessInfo processInfo] processIdentifier]),
581
+ @"appName": atomAppName,
582
+ @"appId": atomAppIdentifier
583
+ });
584
+ }
585
+
586
+ - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender {
587
+ return NO;
588
+ }
589
+
590
+ @end
591
+
495
592
  int main(int argc, const char *argv[]) {
496
593
  @autoreleasepool {
497
594
  for (int index = 1; index + 1 < argc; index += 1) {
498
595
  if (strcmp(argv[index], "--app-name") == 0) {
499
596
  atomAppName = [NSString stringWithUTF8String:argv[index + 1]] ?: @"AtomJS App";
500
597
  index += 1;
598
+ } else if (strcmp(argv[index], "--app-id") == 0) {
599
+ atomAppIdentifier = [NSString stringWithUTF8String:argv[index + 1]] ?: @"com.atomjs.app";
600
+ index += 1;
601
+ } else if (strcmp(argv[index], "--app-icon") == 0) {
602
+ atomAppIconPath = [NSString stringWithUTF8String:argv[index + 1]];
603
+ index += 1;
501
604
  }
502
605
  }
503
606
 
607
+ [[NSProcessInfo processInfo] setProcessName:atomAppName];
504
608
  atomWindows = [NSMutableDictionary dictionary];
505
609
  NSApplication *application = [NSApplication sharedApplication];
610
+ AtomJSApplicationDelegate *delegate = [[AtomJSApplicationDelegate alloc] init];
611
+ application.delegate = delegate;
506
612
  [application setActivationPolicy:NSApplicationActivationPolicyRegular];
507
- AtomJSInstallMenu();
508
- AtomJSConsumeInput();
509
-
510
- AtomJSEmit(@{
511
- @"type": @"ready",
512
- @"pid": @([[NSProcessInfo processInfo] processIdentifier])
513
- });
514
-
515
613
  [application run];
516
614
  }
517
615