@atom-js-org/runtime 0.5.3-alpha.1 → 0.5.3-alpha.2

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.5.3-alpha.1",
3
+ "version": "0.5.3-alpha.2",
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",
@@ -220,6 +220,7 @@ class BridgeServer {
220
220
 
221
221
  const match = url.pathname.match(/^\/__atom\/window\/(\d+)\/(.*)$/);
222
222
  if (!match) {
223
+ if (await redirectRootRelativeApplicationAsset(request, response, url)) return;
223
224
  response.statusCode = 404;
224
225
  response.end('Not found');
225
226
  return;
@@ -271,6 +272,58 @@ class BridgeServer {
271
272
  }
272
273
  }
273
274
 
275
+ async function redirectRootRelativeApplicationAsset(request, response, url) {
276
+ if (!['GET', 'HEAD'].includes(String(request.method || 'GET').toUpperCase())) return false;
277
+ if (url.pathname.startsWith('/__atom/')) return false;
278
+
279
+ const referer = request.headers && request.headers.referer;
280
+ if (!referer) return false;
281
+
282
+ let refererUrl;
283
+ try {
284
+ refererUrl = new URL(referer, 'http://127.0.0.1');
285
+ } catch {
286
+ return false;
287
+ }
288
+
289
+ const refererMatch = refererUrl.pathname.match(/^\/__atom\/window\/(\d+)\//);
290
+ if (!refererMatch) return false;
291
+
292
+ const windowId = Number(refererMatch[1]);
293
+ const win = state.windows.get(windowId);
294
+ if (!win || !win._contentRoot) return false;
295
+
296
+ let requested;
297
+ try {
298
+ requested = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
299
+ } catch {
300
+ return false;
301
+ }
302
+ if (!requested) return false;
303
+
304
+ const root = path.resolve(win._contentRoot);
305
+ const absolute = path.resolve(root, requested);
306
+ if (absolute !== root && !absolute.startsWith(root + path.sep)) return false;
307
+
308
+ try {
309
+ const stat = await fs.promises.stat(absolute);
310
+ if (!stat.isFile() && !stat.isDirectory()) return false;
311
+ } catch {
312
+ return false;
313
+ }
314
+
315
+ const encodedPath = requested
316
+ .split(/[\\/]+/)
317
+ .filter(Boolean)
318
+ .map((part) => encodeURIComponent(part))
319
+ .join('/');
320
+ response.statusCode = 307;
321
+ response.setHeader('location', `/__atom/window/${windowId}/${encodedPath}${url.search}`);
322
+ response.setHeader('cache-control', 'no-store');
323
+ response.end();
324
+ return true;
325
+ }
326
+
274
327
  function createIpcEvent(win) {
275
328
  return Object.freeze({
276
329
  sender: win.webContents,
@@ -289,4 +342,4 @@ function serializeError(error) {
289
342
  };
290
343
  }
291
344
 
292
- module.exports = { BridgeServer };
345
+ module.exports = { BridgeServer, redirectRootRelativeApplicationAsset };
@@ -1,11 +1,17 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
3
7
  let singleton = null;
4
8
 
5
9
  class WindowsNativeHost {
6
10
  constructor() {
7
11
  this.binding = null;
8
12
  this.application = null;
13
+ this.webContext = null;
14
+ this.webviewDataDirectory = null;
9
15
  this.startPromise = null;
10
16
  this.windows = new Map();
11
17
  this.stopping = false;
@@ -42,6 +48,23 @@ class WindowsNativeHost {
42
48
  this.binding = binding;
43
49
  this.application = new binding.Application();
44
50
  await this.application.whenReady({ interval: 16, ref: true });
51
+
52
+ this.webviewDataDirectory = resolveWritableWebViewDataDirectory();
53
+ try {
54
+ this.webContext = this.application.createWebContext({
55
+ dataDirectory: this.webviewDataDirectory,
56
+ allowsAutomation: false
57
+ });
58
+ } catch (error) {
59
+ const wrapped = new Error([
60
+ 'AtomJS could not create a writable WebView2 data directory.',
61
+ `Directory: ${this.webviewDataDirectory}`,
62
+ 'Check that the current Windows user can write to LOCALAPPDATA.',
63
+ error && error.message ? error.message : String(error)
64
+ ].join('\n'));
65
+ wrapped.cause = error;
66
+ throw wrapped;
67
+ }
45
68
  }
46
69
 
47
70
  async createWindow(atomWindow, config) {
@@ -52,6 +75,7 @@ class WindowsNativeHost {
52
75
  title: String(config.title || 'AtomJS App'),
53
76
  width: positive(config.width, 800),
54
77
  height: positive(config.height, 600),
78
+ logical: true,
55
79
  resizable: config.resizable !== false,
56
80
  visible: false,
57
81
  decorations: config.frame !== false,
@@ -85,10 +109,23 @@ class WindowsNativeHost {
85
109
  url: String(config.url),
86
110
  preload: String(config.bridgeScript || ''),
87
111
  enableDevtools: Boolean(config.debug),
88
- transparent: Boolean(config.transparent)
112
+ transparent: Boolean(config.transparent),
113
+ webContext: this.webContext
89
114
  });
90
115
 
91
- const record = { atomWindow, nativeWindow, webview };
116
+ const record = {
117
+ atomWindow,
118
+ nativeWindow,
119
+ webview,
120
+ dragRegions: [],
121
+ dragViewport: {
122
+ width: positive(config.width, 800),
123
+ height: positive(config.height, 600)
124
+ },
125
+ lastCursorPhysical: null,
126
+ dragState: null,
127
+ lastDragClick: null
128
+ };
92
129
  this.windows.set(Number(config.windowId), record);
93
130
  this._attachEvents(Number(config.windowId), record);
94
131
 
@@ -107,22 +144,82 @@ class WindowsNativeHost {
107
144
  this.windows.delete(windowId);
108
145
  });
109
146
  record.nativeWindow.on('focus', () => emit({ type: 'focus' }));
110
- record.nativeWindow.on('blur', () => emit({ type: 'blur' }));
111
- record.nativeWindow.on('move', (event) => emit({
112
- type: 'bounds-changed',
113
- reason: 'move',
114
- bounds: { x: Number(event.x), y: Number(event.y) }
115
- }));
116
- record.nativeWindow.on('resize', (event) => emit({
117
- type: 'bounds-changed',
118
- reason: 'resize',
119
- bounds: { width: Number(event.width), height: Number(event.height) }
120
- }));
147
+ record.nativeWindow.on('blur', () => {
148
+ record.dragState = null;
149
+ emit({ type: 'blur' });
150
+ });
151
+ record.nativeWindow.on('move', (event) => {
152
+ const scale = safeScaleFactor(record.nativeWindow);
153
+ emit({
154
+ type: 'bounds-changed',
155
+ reason: 'move',
156
+ bounds: { x: Number(event.x) / scale, y: Number(event.y) / scale }
157
+ });
158
+ });
159
+ record.nativeWindow.on('resize', (event) => {
160
+ const scale = safeScaleFactor(record.nativeWindow);
161
+ emit({
162
+ type: 'bounds-changed',
163
+ reason: 'resize',
164
+ bounds: { width: Number(event.width) / scale, height: Number(event.height) / scale }
165
+ });
166
+ });
167
+ record.nativeWindow.on('mouse-move', (event) => {
168
+ const point = physicalPoint(event);
169
+ if (!point) return;
170
+ record.lastCursorPhysical = point;
171
+ if (record.dragState) this._continueWindowDrag(record, point);
172
+ });
173
+ record.nativeWindow.on('mouse-down', (event) => {
174
+ if (Number(event.button) !== 0) return;
175
+ const point = physicalPoint(event);
176
+ if (!point) return;
177
+ record.lastCursorPhysical = point;
178
+ if (!isDraggablePoint(record, point)) return;
179
+
180
+ const now = Date.now();
181
+ const previous = record.lastDragClick;
182
+ record.lastDragClick = { time: now, x: point.x, y: point.y };
183
+ if (previous && now - previous.time <= 450 && distanceSquared(previous, point) <= 64) {
184
+ record.dragState = null;
185
+ record.lastDragClick = null;
186
+ try { record.nativeWindow.setMaximized(!record.nativeWindow.isMaximized()); } catch {}
187
+ return;
188
+ }
189
+
190
+ this._beginWindowDrag(record, point);
191
+ });
192
+ record.nativeWindow.on('mouse-up', (event) => {
193
+ if (Number(event.button) === 0) record.dragState = null;
194
+ });
121
195
  record.webview.on('page-load-started', (event) => emit({ type: 'did-start-loading', url: event.url || '' }));
122
196
  record.webview.on('page-load-finished', (event) => emit({ type: 'did-finish-load', url: event.url || '' }));
123
197
  record.webview.on('title-changed', (event) => emit({ type: 'page-title-updated', title: event.title || '' }));
124
198
  }
125
199
 
200
+ _beginWindowDrag(record, point) {
201
+ if (!point) return false;
202
+ record.dragState = { offsetX: point.x, offsetY: point.y };
203
+ return true;
204
+ }
205
+
206
+ _continueWindowDrag(record, point) {
207
+ const state = record.dragState;
208
+ if (!state) return;
209
+ try {
210
+ const current = record.nativeWindow.getPosition(false);
211
+ const globalX = Number(current.x) + point.x;
212
+ const globalY = Number(current.y) + point.y;
213
+ record.nativeWindow.setPosition(
214
+ Math.round(globalX - state.offsetX),
215
+ Math.round(globalY - state.offsetY),
216
+ false
217
+ );
218
+ } catch {
219
+ record.dragState = null;
220
+ }
221
+ }
222
+
126
223
  send(message) {
127
224
  const record = this.windows.get(Number(message.windowId));
128
225
  if (!record) return false;
@@ -154,6 +251,12 @@ class WindowsNativeHost {
154
251
  win.setMaximized(false);
155
252
  win.show();
156
253
  return true;
254
+ case 'set-drag-regions':
255
+ record.dragRegions = normalizeDragRegions(message.regions);
256
+ record.dragViewport = normalizeViewport(message.viewport, win);
257
+ return true;
258
+ case 'start-drag':
259
+ return this._beginWindowDrag(record, record.lastCursorPhysical);
157
260
  case 'set-bounds': {
158
261
  const bounds = message.bounds || {};
159
262
  if (Number.isFinite(Number(bounds.width)) && Number.isFinite(Number(bounds.height))) {
@@ -176,9 +279,14 @@ class WindowsNativeHost {
176
279
  try { record.nativeWindow.dispose(); } catch {}
177
280
  }
178
281
  this.windows.clear();
282
+ if (this.webContext) {
283
+ try { this.webContext.dispose(); } catch {}
284
+ }
179
285
  if (this.application) {
180
286
  try { this.application.exit(); } catch {}
181
287
  }
288
+ this.webContext = null;
289
+ this.webviewDataDirectory = null;
182
290
  this.application = null;
183
291
  this.startPromise = null;
184
292
  this.stopping = false;
@@ -190,6 +298,108 @@ function positive(value, fallback) {
190
298
  return Number.isFinite(number) && number > 0 ? Math.round(number) : fallback;
191
299
  }
192
300
 
301
+ function safeScaleFactor(win) {
302
+ try {
303
+ const scale = Number(win.scaleFactor());
304
+ return Number.isFinite(scale) && scale > 0 ? scale : 1;
305
+ } catch {
306
+ return 1;
307
+ }
308
+ }
309
+
310
+ function physicalPoint(event) {
311
+ const x = Number(event && event.x);
312
+ const y = Number(event && event.y);
313
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
314
+ return { x, y };
315
+ }
316
+
317
+ function normalizeDragRegions(regions) {
318
+ const normalized = [];
319
+ for (const region of Array.isArray(regions) ? regions.slice(0, 4096) : []) {
320
+ const x = Number(region && region.x);
321
+ const y = Number(region && region.y);
322
+ const width = Number(region && region.width);
323
+ const height = Number(region && region.height);
324
+ if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) continue;
325
+ normalized.push({ x, y, width, height, draggable: region.draggable === true });
326
+ }
327
+ return normalized;
328
+ }
329
+
330
+ function normalizeViewport(viewport, win) {
331
+ let fallback = { width: 1, height: 1 };
332
+ try {
333
+ const size = win.getInnerSize(true);
334
+ fallback = { width: positive(size.width, 1), height: positive(size.height, 1) };
335
+ } catch {}
336
+ const width = Number(viewport && viewport.width);
337
+ const height = Number(viewport && viewport.height);
338
+ return {
339
+ width: Number.isFinite(width) && width > 0 ? width : fallback.width,
340
+ height: Number.isFinite(height) && height > 0 ? height : fallback.height
341
+ };
342
+ }
343
+
344
+ function isDraggablePoint(record, physical) {
345
+ if (!record.dragRegions.length) return false;
346
+ const scale = safeScaleFactor(record.nativeWindow);
347
+ const logical = { x: physical.x / scale, y: physical.y / scale };
348
+ let inner = { width: record.dragViewport.width, height: record.dragViewport.height };
349
+ try { inner = record.nativeWindow.getInnerSize(true); } catch {}
350
+ const scaleX = record.dragViewport.width > 0 ? Number(inner.width) / record.dragViewport.width : 1;
351
+ const scaleY = record.dragViewport.height > 0 ? Number(inner.height) / record.dragViewport.height : 1;
352
+ let draggable = false;
353
+
354
+ for (const region of record.dragRegions) {
355
+ const inside = logical.x >= region.x * scaleX &&
356
+ logical.x < (region.x + region.width) * scaleX &&
357
+ logical.y >= region.y * scaleY &&
358
+ logical.y < (region.y + region.height) * scaleY;
359
+ if (!inside) continue;
360
+ if (!region.draggable) return false;
361
+ draggable = true;
362
+ }
363
+ return draggable;
364
+ }
365
+
366
+ function distanceSquared(a, b) {
367
+ const dx = Number(a.x) - Number(b.x);
368
+ const dy = Number(a.y) - Number(b.y);
369
+ return (dx * dx) + (dy * dy);
370
+ }
371
+
372
+ function resolveWritableWebViewDataDirectory() {
373
+ const identity = sanitizePathSegment(
374
+ process.env.ATOM_APP_ID || process.env.ATOM_APP_NAME || 'AtomJS.App'
375
+ );
376
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
377
+ const candidates = [
378
+ path.join(localAppData, identity, 'AtomJS', 'WebView2'),
379
+ path.join(os.tmpdir(), identity, 'AtomJS', 'WebView2')
380
+ ];
381
+ let lastError;
382
+
383
+ for (const candidate of candidates) {
384
+ try {
385
+ fs.mkdirSync(candidate, { recursive: true });
386
+ fs.accessSync(candidate, fs.constants.R_OK | fs.constants.W_OK);
387
+ return candidate;
388
+ } catch (error) {
389
+ lastError = error;
390
+ }
391
+ }
392
+
393
+ throw new Error(`No writable WebView2 data directory was available: ${lastError ? lastError.message : 'unknown error'}`);
394
+ }
395
+
396
+ function sanitizePathSegment(value) {
397
+ return String(value || 'AtomJS.App')
398
+ .replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-')
399
+ .replace(/[. ]+$/g, '')
400
+ .slice(0, 120) || 'AtomJS.App';
401
+ }
402
+
193
403
  function sanitizeWindowsClass(value) {
194
404
  return String(value || 'AtomJS.App').replace(/[^A-Za-z0-9._-]+/g, '.').slice(0, 120) || 'AtomJS.App';
195
405
  }
@@ -206,4 +416,11 @@ async function stopWindowsNativeHost() {
206
416
  await current.stop();
207
417
  }
208
418
 
209
- module.exports = { WindowsNativeHost, getWindowsNativeHost, stopWindowsNativeHost };
419
+ module.exports = {
420
+ WindowsNativeHost,
421
+ getWindowsNativeHost,
422
+ stopWindowsNativeHost,
423
+ isDraggablePoint,
424
+ normalizeDragRegions,
425
+ resolveWritableWebViewDataDirectory
426
+ };