pinokiod 3.134.0 → 3.135.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/kernel/util.js CHANGED
@@ -30,6 +30,70 @@ const {
30
30
 
31
31
 
32
32
  const platform = os.platform()
33
+ const WINDOWS_TOAST_APP_ID = process.env.PINOKIO_WINDOWS_APP_ID || 'computer.pinokio'
34
+ const DEFAULT_CHIME_URL_PATH = '/chime.mp3'
35
+ const pushListeners = new Set()
36
+
37
+ function registerPushListener(listener) {
38
+ if (typeof listener !== 'function') {
39
+ throw new TypeError('push listener must be a function')
40
+ }
41
+ pushListeners.add(listener)
42
+ return () => pushListeners.delete(listener)
43
+ }
44
+
45
+ function emitPushEvent(event) {
46
+ if (!event) {
47
+ return
48
+ }
49
+ pushListeners.forEach((listener) => {
50
+ try {
51
+ listener(event)
52
+ } catch (err) {
53
+ console.error('Push listener error:', err)
54
+ }
55
+ })
56
+ }
57
+
58
+ function resolvePublicAssetUrl(filePath) {
59
+ if (!filePath) {
60
+ return null
61
+ }
62
+ if (/^https?:\/\//i.test(filePath)) {
63
+ return filePath
64
+ }
65
+ try {
66
+ const absolute = path.resolve(filePath)
67
+ const publicRoot = path.resolve(__dirname, '../server/public')
68
+ if (absolute === publicRoot) {
69
+ return '/'
70
+ }
71
+ if (absolute.startsWith(publicRoot + path.sep) || absolute === publicRoot) {
72
+ const relative = path.relative(publicRoot, absolute).replace(/\\/g, '/')
73
+ return '/' + relative
74
+ }
75
+ } catch (_) {
76
+ // ignore resolution failures
77
+ }
78
+ return null
79
+ }
80
+
81
+ function soundTargetToClientUrl(target) {
82
+ if (!target) {
83
+ return null
84
+ }
85
+ if (target.kind === 'url') {
86
+ return target.value
87
+ }
88
+ if (target.kind === 'file') {
89
+ const resolved = path.resolve(target.value)
90
+ if (resolved === DEFAULT_CHIME_PATH) {
91
+ return DEFAULT_CHIME_URL_PATH
92
+ }
93
+ return resolvePublicAssetUrl(resolved)
94
+ }
95
+ return null
96
+ }
33
97
  function ensureNotifierBinaries() {
34
98
  if (platform !== 'darwin') {
35
99
  return
@@ -729,10 +793,39 @@ function push(params) {
729
793
  if (!notifyParams.contentImage) {
730
794
  notifyParams.contentImage = path.resolve(__dirname, "../server/public/pinokio-black.png")
731
795
  }
796
+ if (platform === 'win32') {
797
+ // Ensure Windows toast branding aligns with Pinokio assets.
798
+ if (!notifyParams.icon && notifyParams.contentImage) {
799
+ notifyParams.icon = notifyParams.contentImage
800
+ }
801
+ if (!notifyParams.appID && !notifyParams.appName) {
802
+ notifyParams.appID = WINDOWS_TOAST_APP_ID
803
+ }
804
+ }
805
+
806
+ const clientSoundUrl = soundTargetToClientUrl(customSoundTarget)
732
807
  if (customSoundTarget) {
733
808
  notifyParams.sound = false
734
- scheduleSoundPlayback(customSoundTarget)
809
+ const shouldPlayLocally = platform !== 'win32' || !clientSoundUrl
810
+ if (shouldPlayLocally) {
811
+ scheduleSoundPlayback(customSoundTarget)
812
+ }
813
+ }
814
+
815
+ const clientImage = resolvePublicAssetUrl(notifyParams.contentImage) || resolvePublicAssetUrl(notifyParams.image)
816
+ const eventPayload = {
817
+ id: randomUUID(),
818
+ title: notifyParams.title,
819
+ subtitle: notifyParams.subtitle || null,
820
+ message: notifyParams.message || '',
821
+ image: clientImage,
822
+ sound: clientSoundUrl,
823
+ timestamp: Date.now(),
824
+ platform,
735
825
  }
826
+
827
+ emitPushEvent(eventPayload)
828
+
736
829
  console.log("notifyParams", notifyParams)
737
830
  notifier.notify(notifyParams)
738
831
  }
@@ -1076,5 +1169,34 @@ const rewrite_localhost= (kernel, obj, source) => {
1076
1169
 
1077
1170
 
1078
1171
  module.exports = {
1079
- parse_env, log_path, api_path, api_name, update_env, parse_env_detail, openfs, port_running, du, is_port_available, find_python, find_venv, fill_object, run, openURL, u2p, p2u, log, diffLinesWithContext, classifyChange, push, filepicker, exists, clipboard, mergeLines, ignore_subrepos, rewrite_localhost, symlink, file_type
1172
+ parse_env,
1173
+ log_path,
1174
+ api_path,
1175
+ api_name,
1176
+ update_env,
1177
+ parse_env_detail,
1178
+ openfs,
1179
+ port_running,
1180
+ du,
1181
+ is_port_available,
1182
+ find_python,
1183
+ find_venv,
1184
+ fill_object,
1185
+ run,
1186
+ openURL,
1187
+ u2p,
1188
+ p2u,
1189
+ log,
1190
+ diffLinesWithContext,
1191
+ classifyChange,
1192
+ push,
1193
+ filepicker,
1194
+ exists,
1195
+ clipboard,
1196
+ mergeLines,
1197
+ ignore_subrepos,
1198
+ rewrite_localhost,
1199
+ symlink,
1200
+ file_type,
1201
+ registerPushListener,
1080
1202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "3.134.0",
3
+ "version": "3.135.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1398,14 +1398,323 @@ const open_url2 = (href, target, features) => {
1398
1398
  window.open(href, "_self", features)
1399
1399
  }
1400
1400
  }
1401
- hotkeys("ctrl+t,cmd+t,ctrl+n,cmd+n", (e) => {
1402
- let agent = document.body.getAttribute("data-agent")
1403
- if (agent === "electron") {
1404
- window.open(location.href, "_blank", "pinokio")
1401
+ if (typeof hotkeys === 'function') {
1402
+ hotkeys("ctrl+t,cmd+t,ctrl+n,cmd+n", (e) => {
1403
+ let agent = document.body.getAttribute("data-agent")
1404
+ if (agent === "electron") {
1405
+ window.open(location.href, "_blank", "pinokio")
1406
+ } else {
1407
+ window.open(location.href, "_blank")
1408
+ }
1409
+ })
1410
+ }
1411
+
1412
+ (function initNotificationAudioBridge() {
1413
+ if (typeof window === 'undefined') {
1414
+ return;
1415
+ }
1416
+ if (window.__pinokioNotificationAudioInitialized) {
1417
+ return;
1418
+ }
1419
+ window.__pinokioNotificationAudioInitialized = true;
1420
+
1421
+ const CHANNEL_ID = 'kernel.notifications';
1422
+ const pendingSounds = [];
1423
+ let isPlaying = false;
1424
+ let currentSocket = null;
1425
+ let reconnectTimeout = null;
1426
+ let activeAudio = null;
1427
+
1428
+ const leaderStorageKey = 'pinokio.notification.leader';
1429
+ const leaderHeartbeatMs = 5000;
1430
+ const leaderStaleMs = 15000;
1431
+ const tabId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
1432
+ const storageEnabled = (() => {
1433
+ try {
1434
+ const testKey = '__pinokio_notification_test__';
1435
+ localStorage.setItem(testKey, '1');
1436
+ localStorage.removeItem(testKey);
1437
+ return true;
1438
+ } catch (_) {
1439
+ return false;
1440
+ }
1441
+ })();
1442
+ let isLeader = false;
1443
+ let heartbeatTimer = null;
1444
+ let leadershipCheckTimer = null;
1445
+
1446
+ const parseLeaderValue = (value) => {
1447
+ if (!value) return null;
1448
+ try {
1449
+ return JSON.parse(value);
1450
+ } catch (_) {
1451
+ return null;
1452
+ }
1453
+ };
1454
+
1455
+ const writeHeartbeat = () => {
1456
+ try {
1457
+ localStorage.setItem(leaderStorageKey, JSON.stringify({ id: tabId, ts: Date.now() }));
1458
+ } catch (err) {
1459
+ console.warn('Notification leader heartbeat failed:', err);
1460
+ }
1461
+ };
1462
+
1463
+ const clearHeartbeat = () => {
1464
+ if (heartbeatTimer) {
1465
+ clearInterval(heartbeatTimer);
1466
+ heartbeatTimer = null;
1467
+ }
1468
+ };
1469
+
1470
+ const stopSocket = () => {
1471
+ if (currentSocket && typeof currentSocket.close === 'function') {
1472
+ try {
1473
+ currentSocket.close();
1474
+ } catch (_) {
1475
+ // ignore
1476
+ }
1477
+ }
1478
+ currentSocket = null;
1479
+ };
1480
+
1481
+ const stopAudio = () => {
1482
+ pendingSounds.length = 0;
1483
+ if (activeAudio) {
1484
+ try {
1485
+ activeAudio.pause();
1486
+ activeAudio.currentTime = 0;
1487
+ } catch (_) {
1488
+ // ignore
1489
+ }
1490
+ activeAudio = null;
1491
+ }
1492
+ isPlaying = false;
1493
+ };
1494
+
1495
+ const resignLeadership = () => {
1496
+ if (!isLeader) {
1497
+ return;
1498
+ }
1499
+ isLeader = false;
1500
+ clearHeartbeat();
1501
+ if (reconnectTimeout != null) {
1502
+ clearTimeout(reconnectTimeout);
1503
+ reconnectTimeout = null;
1504
+ }
1505
+ stopSocket();
1506
+ stopAudio();
1507
+ try {
1508
+ const stored = parseLeaderValue(localStorage.getItem(leaderStorageKey));
1509
+ if (stored && stored.id === tabId) {
1510
+ localStorage.removeItem(leaderStorageKey);
1511
+ }
1512
+ } catch (_) {
1513
+ // ignore
1514
+ }
1515
+ };
1516
+
1517
+ const startLeadership = () => {
1518
+ if (isLeader) {
1519
+ return;
1520
+ }
1521
+ isLeader = true;
1522
+ writeHeartbeat();
1523
+ if (!heartbeatTimer) {
1524
+ heartbeatTimer = setInterval(writeHeartbeat, leaderHeartbeatMs);
1525
+ }
1526
+ connect();
1527
+ };
1528
+
1529
+ const playNextSound = () => {
1530
+ if (isPlaying) {
1531
+ return;
1532
+ }
1533
+ const next = pendingSounds.shift();
1534
+ if (!next) {
1535
+ return;
1536
+ }
1537
+ isPlaying = true;
1538
+ activeAudio = new Audio(next);
1539
+ activeAudio.preload = 'auto';
1540
+ const cleanup = () => {
1541
+ if (!activeAudio) {
1542
+ return;
1543
+ }
1544
+ activeAudio.removeEventListener('ended', handleEnded);
1545
+ activeAudio.removeEventListener('error', handleError);
1546
+ activeAudio = null;
1547
+ isPlaying = false;
1548
+ playNextSound();
1549
+ };
1550
+ const handleEnded = () => cleanup();
1551
+ const handleError = (err) => {
1552
+ console.error('Notification audio playback failed:', err);
1553
+ cleanup();
1554
+ };
1555
+ activeAudio.addEventListener('ended', handleEnded, { once: true });
1556
+ activeAudio.addEventListener('error', handleError, { once: true });
1557
+ const playPromise = activeAudio.play();
1558
+ if (playPromise && typeof playPromise.catch === 'function') {
1559
+ playPromise.catch((err) => {
1560
+ console.error('Notification audio play() rejected:', err);
1561
+ cleanup();
1562
+ });
1563
+ }
1564
+ };
1565
+
1566
+ const enqueueSound = (url) => {
1567
+ if (!url) {
1568
+ return;
1569
+ }
1570
+ pendingSounds.push(url);
1571
+ playNextSound();
1572
+ };
1573
+
1574
+ const handlePacket = (packet) => {
1575
+ if (!packet || packet.id !== CHANNEL_ID || packet.type !== 'notification') {
1576
+ return;
1577
+ }
1578
+ const payload = packet.data || {};
1579
+ if (typeof payload.sound === 'string' && payload.sound) {
1580
+ enqueueSound(payload.sound);
1581
+ }
1582
+ };
1583
+
1584
+ const attemptLeadership = () => {
1585
+ if (isLeader) {
1586
+ writeHeartbeat();
1587
+ return;
1588
+ }
1589
+ let current = null;
1590
+ try {
1591
+ current = parseLeaderValue(localStorage.getItem(leaderStorageKey));
1592
+ } catch (_) {
1593
+ current = null;
1594
+ }
1595
+ const now = Date.now();
1596
+ const isStale = !current || !current.ts || (now - current.ts) > leaderStaleMs;
1597
+ if (isStale || (current && current.id === tabId)) {
1598
+ writeHeartbeat();
1599
+ const freshlyStored = parseLeaderValue(localStorage.getItem(leaderStorageKey));
1600
+ if (freshlyStored && freshlyStored.id === tabId) {
1601
+ startLeadership();
1602
+ }
1603
+ }
1604
+ };
1605
+
1606
+ const scheduleReconnect = (delay) => {
1607
+ if (!isLeader) {
1608
+ return;
1609
+ }
1610
+ if (reconnectTimeout != null) {
1611
+ return;
1612
+ }
1613
+ reconnectTimeout = setTimeout(() => {
1614
+ reconnectTimeout = null;
1615
+ connect();
1616
+ }, delay);
1617
+ };
1618
+
1619
+ const connect = () => {
1620
+ if (!isLeader) {
1621
+ return;
1622
+ }
1623
+ const SocketCtor = typeof window.Socket === 'function' ? window.Socket : (typeof Socket === 'function' ? Socket : null);
1624
+ if (!SocketCtor) {
1625
+ return;
1626
+ }
1627
+ if (typeof WebSocket === 'undefined') {
1628
+ return;
1629
+ }
1630
+ if (currentSocket && currentSocket.ws && currentSocket.ws.readyState === WebSocket.OPEN) {
1631
+ return;
1632
+ }
1633
+ if (currentSocket && typeof currentSocket.close === 'function') {
1634
+ try {
1635
+ currentSocket.close();
1636
+ } catch (_) {
1637
+ // ignore
1638
+ }
1639
+ }
1640
+ const socket = new SocketCtor();
1641
+ try {
1642
+ const promise = socket.run(
1643
+ {
1644
+ method: CHANNEL_ID,
1645
+ mode: 'listen',
1646
+ },
1647
+ handlePacket
1648
+ );
1649
+ currentSocket = socket;
1650
+ promise.then(() => {
1651
+ // Attempt to reconnect after a brief delay when the socket closes normally.
1652
+ if (currentSocket === socket) {
1653
+ currentSocket = null;
1654
+ scheduleReconnect(1500);
1655
+ }
1656
+ }).catch((err) => {
1657
+ console.warn('Notification listener socket closed with error:', err);
1658
+ if (currentSocket === socket) {
1659
+ currentSocket = null;
1660
+ scheduleReconnect(2500);
1661
+ }
1662
+ });
1663
+ window.__pinokioNotificationSocket = socket;
1664
+ } catch (err) {
1665
+ console.error('Failed to establish notification listener socket:', err);
1666
+ scheduleReconnect(3000);
1667
+ }
1668
+ };
1669
+
1670
+ if (!storageEnabled) {
1671
+ isLeader = true;
1672
+ const start = () => connect();
1673
+ if (document.readyState === 'loading') {
1674
+ document.addEventListener('DOMContentLoaded', start, { once: true });
1675
+ } else {
1676
+ connect();
1677
+ }
1678
+ window.addEventListener('beforeunload', () => {
1679
+ stopSocket();
1680
+ stopAudio();
1681
+ });
1682
+ return;
1683
+ }
1684
+
1685
+ if (document.readyState === 'loading') {
1686
+ document.addEventListener('DOMContentLoaded', connect, { once: true });
1405
1687
  } else {
1406
- window.open(location.href, "_blank")
1688
+ connect();
1407
1689
  }
1408
- })
1690
+
1691
+ if (!leadershipCheckTimer) {
1692
+ leadershipCheckTimer = setInterval(attemptLeadership, leaderHeartbeatMs);
1693
+ }
1694
+
1695
+ window.addEventListener('storage', (event) => {
1696
+ if (event.key !== leaderStorageKey) {
1697
+ return;
1698
+ }
1699
+ const data = parseLeaderValue(event.newValue);
1700
+ if (data && data.id === tabId) {
1701
+ startLeadership();
1702
+ } else {
1703
+ resignLeadership();
1704
+ }
1705
+ });
1706
+
1707
+ window.addEventListener('beforeunload', () => {
1708
+ if (leadershipCheckTimer) {
1709
+ clearInterval(leadershipCheckTimer);
1710
+ leadershipCheckTimer = null;
1711
+ }
1712
+ resignLeadership();
1713
+ });
1714
+
1715
+ // Attempt to become leader immediately on load.
1716
+ attemptLeadership();
1717
+ })();
1409
1718
  const refreshParent = (e) => {
1410
1719
  // if (window.parent === window.top) {
1411
1720
  window.parent.postMessage(e, "*")
package/server/socket.js CHANGED
@@ -3,6 +3,7 @@ const WebSocket = require('ws');
3
3
  const path = require('path')
4
4
  const Util = require("../kernel/util")
5
5
  const Environment = require("../kernel/environment")
6
+ const NOTIFICATION_CHANNEL = 'kernel.notifications'
6
7
  class Socket {
7
8
  constructor(parent) {
8
9
  this.buffer = {}
@@ -15,6 +16,8 @@ class Socket {
15
16
  // this.kernel = parent.kernel
16
17
  const wss = new WebSocket.Server({ server: this.parent.server })
17
18
  this.subscriptions = new Map(); // Initialize a Map to store the WebSocket connections interested in each event
19
+ this.notificationChannel = NOTIFICATION_CHANNEL
20
+ this.notificationBridgeDispose = null
18
21
  this.parent.kernel.api.listen("server.socket", this.trigger.bind(this))
19
22
  wss.on('connection', (ws, request) => {
20
23
  ws._headers = request.headers;
@@ -25,8 +28,15 @@ class Socket {
25
28
  ws._origin = request.headers.origin;
26
29
  ws.on('close', () => {
27
30
  this.subscriptions.forEach((set, eventName) => {
31
+ if (!set) {
32
+ return;
33
+ }
28
34
  set.delete(ws);
35
+ if (set.size === 0) {
36
+ this.subscriptions.delete(eventName);
37
+ }
29
38
  });
39
+ this.checkNotificationBridge();
30
40
  });
31
41
  ws.on('message', async (message, isBinary) => {
32
42
  let req
@@ -217,7 +227,11 @@ class Socket {
217
227
  if (!this.subscriptions.has(id)) {
218
228
  this.subscriptions.set(id, new Set());
219
229
  }
220
- this.subscriptions.get(id).add(ws);
230
+ const set = this.subscriptions.get(id)
231
+ set.add(ws);
232
+ if (set.size === 1 && id === this.notificationChannel) {
233
+ this.ensureNotificationBridge();
234
+ }
221
235
  }
222
236
  trigger(e) {
223
237
  // send to id session
@@ -321,6 +335,49 @@ class Socket {
321
335
  }
322
336
  }
323
337
  }
338
+
339
+ broadcastNotification(payload) {
340
+ if (!payload) {
341
+ return
342
+ }
343
+ const subscribers = this.subscriptions.get(this.notificationChannel)
344
+ if (!subscribers || subscribers.size === 0) {
345
+ return
346
+ }
347
+ const envelope = {
348
+ id: this.notificationChannel,
349
+ type: 'notification',
350
+ data: payload,
351
+ }
352
+ const frame = JSON.stringify(envelope)
353
+ subscribers.forEach((subscriber) => {
354
+ if (subscriber.readyState === WebSocket.OPEN) {
355
+ subscriber.send(frame)
356
+ }
357
+ })
358
+ }
359
+
360
+ ensureNotificationBridge() {
361
+ if (this.notificationBridgeDispose) {
362
+ return
363
+ }
364
+ this.notificationBridgeDispose = Util.registerPushListener((payload) => {
365
+ this.broadcastNotification(payload)
366
+ })
367
+ }
368
+
369
+ checkNotificationBridge() {
370
+ const subscribers = this.subscriptions.get(this.notificationChannel)
371
+ if ((!subscribers || subscribers.size === 0) && this.notificationBridgeDispose) {
372
+ try {
373
+ this.notificationBridgeDispose()
374
+ } catch (err) {
375
+ console.error('Failed to dispose notification bridge:', err)
376
+ }
377
+ this.notificationBridgeDispose = null
378
+ }
379
+ }
380
+
324
381
  async log_buffer(key, buf) {
325
382
 
326
383
  /*