nekostream 2026.4.4 → 2026.11.1

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.
Files changed (48) hide show
  1. package/assets/icon.ico +0 -0
  2. package/assets/icon.png +0 -0
  3. package/desktop-main.js +530 -0
  4. package/desktop-preload.js +14 -0
  5. package/dist/cli/application.js +53 -8
  6. package/dist/cli/domain-startup.js +6 -5
  7. package/dist/cli/feedback.js +13 -8
  8. package/dist/cli/flows/anime-flow.js +58 -38
  9. package/dist/cli/flows/auth-flow.js +5 -4
  10. package/dist/cli/flows/provider-lists.js +3 -3
  11. package/dist/cli/menus/history-menu.js +13 -10
  12. package/dist/cli/menus/home-menu.js +51 -37
  13. package/dist/cli/menus/settings-menu.js +32 -29
  14. package/dist/cli/party/room-manager.js +284 -0
  15. package/dist/cli/party/tailscale-service.js +276 -0
  16. package/dist/cli/party/tunnel-service.js +245 -0
  17. package/dist/cli/party/types.js +5 -0
  18. package/dist/cli/text.js +26 -0
  19. package/dist/cli/theme.js +49 -19
  20. package/dist/cli/update-check.js +47 -7
  21. package/dist/cli/web-server.js +1000 -52
  22. package/dist/index.js +3 -2
  23. package/dist/player.js +231 -51
  24. package/dist/prompts-wrapper.js +798 -154
  25. package/dist/providers.js +36 -5
  26. package/dist/scrapers/adblock-policy.js +57 -22
  27. package/dist/scrapers/auth/animevietsub/lists.js +48 -22
  28. package/dist/scrapers/auth/animevietsub/login.js +50 -21
  29. package/dist/scrapers/auth/animevietsub/parsers.js +197 -46
  30. package/dist/scrapers/auth/auth-session-service.js +1 -0
  31. package/dist/scrapers/auth/browser-login.js +41 -13
  32. package/dist/scrapers/crawler.js +35 -2
  33. package/dist/scrapers/domain-registry.js +1 -1
  34. package/dist/scrapers/domain-resolver.js +90 -7
  35. package/dist/scrapers/http-client.js +2 -2
  36. package/dist/scrapers/providers/anime47.js +0 -10
  37. package/dist/scrapers/providers/animehay.js +2 -2
  38. package/dist/scrapers/providers/animevietsub/streams/extractors.js +1 -1
  39. package/dist/scrapers/providers/animevietsub.js +0 -10
  40. package/dist/storage.js +66 -1
  41. package/dist/ui.js +104 -55
  42. package/package.json +95 -79
  43. package/player.html +420 -23
  44. package/web/dist/assets/index-CslHcpEL.css +1 -0
  45. package/web/dist/assets/index-DqlrnkCb.js +2257 -0
  46. package/web/dist/index.html +27 -22
  47. package/web/dist/assets/index-BByTtXWy.css +0 -1
  48. package/web/dist/assets/index-Y4PiRbR7.js +0 -2171
Binary file
Binary file
@@ -0,0 +1,530 @@
1
+ /**
2
+ * NekoStream Desktop Application Main Process (Electron)
3
+ * Bridges the Material 3 Web interface with native Windows desktop capabilities.
4
+ */
5
+
6
+ globalThis.__NEKO_DESKTOP__ = true
7
+ process.env.NEKO_DESKTOP = '1'
8
+
9
+ const { app, BrowserWindow, session, ipcMain, shell, webFrameMain } = require('electron')
10
+ const path = require('path')
11
+ const {
12
+ AD_PROBE_HOSTS,
13
+ isIntrusiveAdHost,
14
+ hostnameOf,
15
+ hostMatches,
16
+ isProviderHost,
17
+ isStreamHost,
18
+ isPlaybackHost,
19
+ } = require('./dist/player-policy')
20
+ const { getResolvedBaseUrl } = require('./dist/scrapers/domain-resolver')
21
+
22
+ // Disable Blink automation features for smoother provider playback
23
+ app.commandLine.appendSwitch('disable-blink-features', 'AutomationControlled')
24
+ app.commandLine.appendSwitch('enable-features', 'VaapiVideoDecoder')
25
+
26
+ // Single instance lock
27
+ const gotTheLock = app.requestSingleInstanceLock()
28
+ if (!gotTheLock) {
29
+ app.quit()
30
+ process.exit(0)
31
+ }
32
+
33
+ let mainWindow = null
34
+ let serverInstance = null
35
+
36
+ function getIconPath() {
37
+ const iconExt = process.platform === 'win32' ? 'icon.ico' : 'icon.png'
38
+ const iconPath = path.join(__dirname, 'assets', iconExt)
39
+ return iconPath
40
+ }
41
+
42
+ async function startBackendServer() {
43
+ try {
44
+ const { ensureDataDir } = require('./dist/storage')
45
+ const { resolveProviderDomains } = require('./dist/cli/domain-startup')
46
+ const { initDiscord } = require('./dist/discord')
47
+ const { startWebServer, printWebServerBanner } = require('./dist/cli/web-server')
48
+
49
+ ensureDataDir()
50
+ await resolveProviderDomains().catch((err) => {
51
+ console.warn('[Desktop Startup] Warning resolving domains:', err.message)
52
+ })
53
+ await initDiscord().catch(() => {})
54
+
55
+ // Bind on 0.0.0.0 so other LAN devices can connect and test
56
+ const instance = await startWebServer(3000, '0.0.0.0')
57
+ if (typeof printWebServerBanner === 'function') {
58
+ printWebServerBanner({ mode: 'desktop', port: instance.port })
59
+ }
60
+ return instance
61
+ } catch (err) {
62
+ console.error('[Desktop Startup] Failed to start internal web server:', err)
63
+ throw err
64
+ }
65
+ }
66
+
67
+ function setupSessionSecurity() {
68
+ const sess = session.defaultSession
69
+
70
+ // Network-level ad-blocking (like browser extensions)
71
+ sess.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => {
72
+ const hostname = hostnameOf(details.url)
73
+ const lowerUrl = details.url.toLowerCase()
74
+
75
+ // 1. Unconditional block for intrusive / gambling / popup ad networks
76
+ const isIntrusiveAd =
77
+ isIntrusiveAdHost(hostname) ||
78
+ (lowerUrl.includes('banner') &&
79
+ (lowerUrl.includes('casino') ||
80
+ lowerUrl.includes('tai-xiu') ||
81
+ lowerUrl.includes('keo-nha-cai')))
82
+
83
+ if (isIntrusiveAd) {
84
+ return callback({ cancel: true })
85
+ }
86
+
87
+ // 2. Block ad tracking domains in players / iframes
88
+ if (details.url && details.url.includes('devtools-warning')) {
89
+ return callback({ cancel: true })
90
+ }
91
+
92
+ // Probes from local app or providers are allowed; other ad probes blocked
93
+ const fromLocal =
94
+ hostname === 'localhost' ||
95
+ hostname === '127.0.0.1' ||
96
+ isProviderHost(hostnameOf(details.referrer)) ||
97
+ isProviderHost(hostnameOf(details.initiator))
98
+
99
+ if (!fromLocal && hostMatches(hostname, AD_PROBE_HOSTS)) {
100
+ return callback({ cancel: true })
101
+ }
102
+
103
+ return callback({ cancel: false })
104
+ })
105
+
106
+ // Strip CSP and X-Frame-Options on playback hosts
107
+ sess.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
108
+ if (!isPlaybackHost(hostnameOf(details.url))) {
109
+ return callback({ cancel: false })
110
+ }
111
+ const responseHeaders = { ...details.responseHeaders }
112
+ for (const key of Object.keys(responseHeaders)) {
113
+ const lowerKey = key.toLowerCase()
114
+ if (
115
+ lowerKey === 'content-security-policy' ||
116
+ lowerKey === 'content-security-policy-report-only' ||
117
+ lowerKey === 'x-frame-options'
118
+ ) {
119
+ delete responseHeaders[key]
120
+ }
121
+ }
122
+ callback({ cancel: false, responseHeaders })
123
+ })
124
+
125
+ // Relax CORS and inject appropriate Referer for video streaming CDNs
126
+ sess.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
127
+ const headers = { ...details.requestHeaders }
128
+ const url = details.url.toLowerCase()
129
+ let avsBase = 'https://animevietsub.news/'
130
+ try {
131
+ avsBase = getResolvedBaseUrl('animevietsub') || avsBase
132
+ if (!avsBase.endsWith('/')) avsBase += '/'
133
+ } catch {}
134
+
135
+ // Provider stream CDN referers
136
+ if (
137
+ url.includes('abyss') ||
138
+ url.includes('hydrax') ||
139
+ url.includes('iamcdn') ||
140
+ url.includes('short.ink')
141
+ ) {
142
+ headers['Referer'] = avsBase
143
+ headers['Origin'] = new URL(avsBase).origin
144
+ } else if (url.includes('anime47')) {
145
+ headers['Referer'] = 'https://anime47.com/'
146
+ }
147
+
148
+ callback({ requestHeaders: headers })
149
+ })
150
+
151
+ // Block permission requests except fullscreen and clipboard
152
+ sess.setPermissionRequestHandler((webContents, permission, callback) => {
153
+ if (permission === 'fullscreen' || permission.includes('clipboard')) {
154
+ return callback(true)
155
+ }
156
+ callback(false)
157
+ })
158
+ }
159
+
160
+ async function createMainWindow(appUrl) {
161
+ mainWindow = new BrowserWindow({
162
+ title: 'NekoStream',
163
+ width: 1360,
164
+ height: 860,
165
+ minWidth: 1024,
166
+ minHeight: 680,
167
+ center: true,
168
+ frame: true, // Native Windows Title Bar as requested
169
+ autoHideMenuBar: true, // Clean look without traditional Alt menu bar
170
+ backgroundColor: '#0f0b13', // Material 3 surface color (zero white flash)
171
+ icon: getIconPath(),
172
+ show: false,
173
+ webPreferences: {
174
+ preload: path.join(__dirname, 'desktop-preload.js'),
175
+ contextIsolation: true,
176
+ nodeIntegration: false,
177
+ sandbox: false,
178
+ webSecurity: false, // Allows HLS playback across diverse provider CDNs without browser CORS blocks
179
+ },
180
+ })
181
+
182
+ mainWindow.once('ready-to-show', () => {
183
+ if (mainWindow) {
184
+ mainWindow.show()
185
+ }
186
+ })
187
+
188
+ // Deny all popup windows and ad tabs from players/iframes
189
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
190
+ if (url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1')) {
191
+ return { action: 'allow' }
192
+ }
193
+ // Block popup windows from stream players
194
+ return { action: 'deny' }
195
+ })
196
+
197
+ // Prevent embedded iframes or external scripts from navigating the top-level main window
198
+ mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
199
+ const isAllowed =
200
+ targetUrl.startsWith('http://localhost') ||
201
+ targetUrl.startsWith('http://127.0.0.1') ||
202
+ targetUrl.includes('.trycloudflare.com') ||
203
+ /^https?:\/\/(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(targetUrl)
204
+
205
+ if (!isAllowed) {
206
+ event.preventDefault()
207
+ console.warn('[Security] Blocked unauthorized top-level window navigation to:', targetUrl)
208
+ }
209
+ })
210
+
211
+ // Frame script injector to neutralize anti-adblock detection & popup triggers in HDX / Abyss / Hydrax
212
+ const injectIntoFrame = (frame) => {
213
+ try {
214
+ if (!frame || (frame.isDestroyed && frame.isDestroyed())) return
215
+ // Never inject into the top-level main window
216
+ if (!frame.parent) return
217
+
218
+ const frameUrl = frame.url || ''
219
+ const host = hostnameOf(frameUrl)
220
+ // Only inject into stream hosts or any iframe under playback hosts
221
+ if (host && !isStreamHost(host) && !isPlaybackHost(host)) {
222
+ return
223
+ }
224
+
225
+ frame
226
+ .executeJavaScript(
227
+ `
228
+ (function() {
229
+ if (window.__neko_cleaner_active__) return;
230
+ window.__neko_cleaner_active__ = true;
231
+
232
+ // --- 0. Neutralize ServiceWorker in frames to prevent Mojo cross-origin crash (reason 123) ---
233
+ try {
234
+ if (window.navigator) {
235
+ Object.defineProperty(window.navigator, 'serviceWorker', {
236
+ get: function() {
237
+ return {
238
+ register: function() { return Promise.reject(new Error('ServiceWorker disabled')); },
239
+ getRegistration: function() { return Promise.resolve(undefined); },
240
+ getRegistrations: function() { return Promise.resolve([]); },
241
+ addEventListener: function() {},
242
+ removeEventListener: function() {},
243
+ controller: null,
244
+ ready: new Promise(function() {})
245
+ };
246
+ },
247
+ configurable: true
248
+ });
249
+ }
250
+ } catch(e) {}
251
+
252
+ // --- 1. Neutralize window.open to prevent popup blockers from throwing on .focus() ---
253
+ // Anti-adblock scripts on Abyss/Hydrax catch .focus() TypeError on blocked windows to trigger
254
+ // "Due to certain reasons (AdBlock/Sandbox), ads are not being displayed..."
255
+ try {
256
+ window.open = function(url) {
257
+ return {
258
+ closed: false,
259
+ focus: function() {},
260
+ blur: function() {},
261
+ close: function() {},
262
+ postMessage: function() {},
263
+ location: { href: url || '' }
264
+ };
265
+ };
266
+ } catch(e) {}
267
+
268
+ // --- 2. Prevent anti-adblock document.write from wiping out the player ---
269
+ try {
270
+ const _origDocWrite = document.write.bind(document);
271
+ document.write = function(content) {
272
+ if (
273
+ typeof content === 'string' &&
274
+ (content.includes('AdBlock/Sandbox') ||
275
+ content.includes('Due to certain reasons') ||
276
+ content.includes('disable AdBlock') ||
277
+ content.includes('removing the Sandbox'))
278
+ ) {
279
+ return;
280
+ }
281
+ return _origDocWrite(content);
282
+ };
283
+ } catch(e) {}
284
+
285
+ // --- 3. CSS block for ads ---
286
+ if (!document.getElementById('__neko_adblock__')) {
287
+ const style = document.createElement('style');
288
+ style.id = '__neko_adblock__';
289
+ style.textContent = [
290
+ 'img[src*="in88"],img[src*="quayhu"],img[src*="nohu"],',
291
+ 'img[src*="188bet"],img[src*="kubet"],img[src*="w88"],img[src*="fun88"],img[src*="sin88"],',
292
+ 'div[style*="sin88"],div[style*="in88"],',
293
+ '[class*="art-ad"],[class*="artplayer-ad"],[class*="art-ads"],[class*="artplayer-ads"],',
294
+ '[class*="ads-container"],[class*="ad-container"],[class*="pause-ad"],[id*="pause-ad"],',
295
+ '[class*="ads-pause"],[id*="ads-pause"],[class*="overlay-ad"],[id*="overlay-ad"],',
296
+ '[class*="popup-ad"],[id*="popup-ad"],[class*="quangcao"],[id*="quangcao"],',
297
+ '[class*="qc-"],[id*="qc-"] { display:none!important }'
298
+ ].join('');
299
+ (document.head || document.documentElement).appendChild(style);
300
+ }
301
+
302
+ // --- 4. DOM cleaner & Abyss Overlay Bypass ---
303
+ const AD_TEXTS = ['Đóng quảng cáo', 'Đóng và xem tiếp', 'Quảng cáo'];
304
+ const AD_SRCS = ['in88', 'quayhu', 'nohu', '188bet', 'kubet', 'w88', 'fun88', 'sin88'];
305
+
306
+ const clean = () => {
307
+ try {
308
+ // Bypass Abyss/Hydrax popup click-jacking by rewriting the overlay click event
309
+ const overlay = document.getElementById('overlay');
310
+ if (overlay) {
311
+ overlay.onclick = (e) => {
312
+ if (e) {
313
+ e.stopPropagation();
314
+ e.preventDefault();
315
+ }
316
+ if (window.jwplayer && typeof window.jwplayer === 'function') {
317
+ try {
318
+ window.jwplayer().play();
319
+ } catch(err) {}
320
+ }
321
+ overlay.remove();
322
+ };
323
+ overlay.ontouchend = overlay.onclick;
324
+
325
+ // Auto-remove overlay if player is active
326
+ if (document.querySelector('video') || (window.jwplayer && typeof window.jwplayer === 'function')) {
327
+ try {
328
+ window.jwplayer().play();
329
+ } catch(e) {}
330
+ overlay.remove();
331
+ }
332
+ }
333
+
334
+ if (!document.body) return;
335
+ document.querySelectorAll('div,section,article,button,a,span,img').forEach(el => {
336
+ const txt = (el.innerText || el.textContent || '').trim();
337
+ const src = (el.src || el.getAttribute('src') || el.getAttribute('href') || '').toLowerCase();
338
+ const isAdTxt = AD_TEXTS.some(t => txt === t || txt.startsWith(t));
339
+ const isAdSrc = AD_SRCS.some(k => src.includes(k));
340
+ if (!isAdTxt && !isAdSrc) return;
341
+
342
+ let node = el;
343
+ for (let i = 0; i < 10; i++) {
344
+ const p = node.parentElement;
345
+ if (!p || p === document.body || p === document.documentElement) break;
346
+ if (p.querySelector('video') || p.querySelector('canvas')) break;
347
+
348
+ const className = (p.className || '').toString().toLowerCase();
349
+ const id = (p.id || '').toString().toLowerCase();
350
+ const isAdContainer = className.includes('ad') || className.includes('qc') || className.includes('banner') || className.includes('popup') || className.includes('overlay') ||
351
+ id.includes('ad') || id.includes('qc') || id.includes('banner') || id.includes('popup') || id.includes('overlay');
352
+
353
+ const cs = getComputedStyle(p);
354
+ if (cs.position === 'fixed' || cs.position === 'absolute' || isAdContainer) {
355
+ p.remove(); return;
356
+ }
357
+ node = p;
358
+ }
359
+ el.remove();
360
+ });
361
+ } catch(e) {}
362
+ };
363
+
364
+ clean();
365
+ setInterval(clean, 250);
366
+ })();
367
+ `
368
+ )
369
+ .catch(() => {})
370
+ } catch (err) {
371
+ console.error('[Desktop] Failed to inject frame:', err)
372
+ }
373
+ }
374
+
375
+ mainWindow.webContents.on('did-frame-finish-load', (e, isMainFrame, frameProcessId, frameRoutingId) => {
376
+ if (!isMainFrame) {
377
+ const frame = webFrameMain.fromId(frameProcessId, frameRoutingId)
378
+ if (frame) {
379
+ injectIntoFrame(frame)
380
+ }
381
+ }
382
+ })
383
+
384
+ mainWindow.webContents.on('frame-created', (e, details) => {
385
+ const frame = details.frame
386
+ injectIntoFrame(frame)
387
+ setTimeout(() => {
388
+ injectIntoFrame(frame)
389
+ }, 300)
390
+ setTimeout(() => {
391
+ injectIntoFrame(frame)
392
+ }, 1000)
393
+ })
394
+
395
+ // Recover gracefully if a subframe or renderer process encounters an unexpected termination
396
+ mainWindow.webContents.on('render-process-gone', (event, details) => {
397
+ console.error('[Desktop] Renderer process gone:', details)
398
+ if (details.reason !== 'clean-exit' && details.reason !== 'killed') {
399
+ console.warn('[Desktop] Attempting automatic window reload after unexpected crash...')
400
+ setTimeout(() => {
401
+ if (mainWindow && !mainWindow.isDestroyed()) {
402
+ mainWindow.reload()
403
+ }
404
+ }, 500)
405
+ }
406
+ })
407
+
408
+ mainWindow.on('closed', () => {
409
+ mainWindow = null
410
+ })
411
+
412
+ await mainWindow.loadURL(appUrl)
413
+ }
414
+
415
+ // IPC Handlers
416
+ ipcMain.on('window:minimize', () => {
417
+ if (mainWindow && !mainWindow.isDestroyed()) mainWindow.minimize()
418
+ })
419
+
420
+ ipcMain.on('window:maximize', () => {
421
+ if (mainWindow && !mainWindow.isDestroyed()) {
422
+ if (mainWindow.isMaximized()) mainWindow.unmaximize()
423
+ else mainWindow.maximize()
424
+ }
425
+ })
426
+
427
+ ipcMain.on('window:close', () => {
428
+ if (mainWindow && !mainWindow.isDestroyed()) mainWindow.close()
429
+ })
430
+
431
+ ipcMain.on('shell:openExternal', (event, url) => {
432
+ if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
433
+ shell.openExternal(url)
434
+ }
435
+ })
436
+
437
+ ipcMain.on('window:navigate', (_event, targetUrl) => {
438
+ if (mainWindow && !mainWindow.isDestroyed() && typeof targetUrl === 'string') {
439
+ const isAllowed =
440
+ targetUrl.startsWith('http://localhost') ||
441
+ targetUrl.startsWith('http://127.0.0.1') ||
442
+ targetUrl.includes('.trycloudflare.com') ||
443
+ /^https?:\/\/(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(targetUrl)
444
+
445
+ if (isAllowed) {
446
+ mainWindow.loadURL(targetUrl)
447
+ }
448
+ }
449
+ })
450
+
451
+ ipcMain.on('window:returnHome', () => {
452
+ if (mainWindow && !mainWindow.isDestroyed() && serverInstance?.url) {
453
+ mainWindow.loadURL(serverInstance.url)
454
+ }
455
+ })
456
+
457
+ // Protocol Registration
458
+ if (process.defaultApp) {
459
+ if (process.argv.length >= 2) {
460
+ app.setAsDefaultProtocolClient('nekostream', process.execPath, [path.resolve(process.argv[1])])
461
+ }
462
+ } else {
463
+ app.setAsDefaultProtocolClient('nekostream')
464
+ }
465
+
466
+ function handleDeepLink(link) {
467
+ if (!mainWindow || mainWindow.isDestroyed()) return
468
+ try {
469
+ if (link.startsWith('nekostream://')) {
470
+ const parsed = new URL(link)
471
+ const targetUrl = parsed.searchParams.get('url')
472
+ const code = parsed.searchParams.get('code')
473
+ if (targetUrl) {
474
+ mainWindow.loadURL(targetUrl)
475
+ return
476
+ }
477
+ if (code && serverInstance?.port) {
478
+ mainWindow.loadURL(`http://localhost:${serverInstance.port}/?party=${encodeURIComponent(code)}`)
479
+ return
480
+ }
481
+ } else if (link.includes('.trycloudflare.com') || link.includes('?party=')) {
482
+ mainWindow.loadURL(link)
483
+ }
484
+ } catch (err) {
485
+ console.warn('[Desktop] Error handling deep link:', err)
486
+ }
487
+ }
488
+
489
+ // App Lifecycle
490
+ app.on('second-instance', (_event, commandLine) => {
491
+ if (mainWindow) {
492
+ if (mainWindow.isMinimized()) mainWindow.restore()
493
+ mainWindow.focus()
494
+
495
+ const deepLink = commandLine.find(
496
+ (arg) => typeof arg === 'string' && (arg.startsWith('nekostream://') || arg.includes('trycloudflare.com') || arg.includes('?party='))
497
+ )
498
+ if (deepLink) {
499
+ handleDeepLink(deepLink)
500
+ }
501
+ }
502
+ })
503
+
504
+ app.whenReady().then(async () => {
505
+ try {
506
+ setupSessionSecurity()
507
+ serverInstance = await startBackendServer()
508
+ await createMainWindow(serverInstance.url)
509
+ } catch (err) {
510
+ console.error('Fatal Desktop launch error:', err)
511
+ app.quit()
512
+ }
513
+ })
514
+
515
+ app.on('window-all-closed', () => {
516
+ if (process.platform !== 'darwin') {
517
+ app.quit()
518
+ }
519
+ })
520
+
521
+ app.on('before-quit', async () => {
522
+ try {
523
+ const { clearDiscordPresence } = require('./dist/discord')
524
+ clearDiscordPresence()
525
+ } catch {}
526
+ if (serverInstance && typeof serverInstance.close === 'function') {
527
+ await serverInstance.close().catch(() => {})
528
+ }
529
+ })
530
+
@@ -0,0 +1,14 @@
1
+ const { contextBridge, ipcRenderer } = require('electron')
2
+
3
+ contextBridge.exposeInMainWorld('desktopApi', {
4
+ isElectron: true,
5
+ platform: process.platform,
6
+ version: process.env.NEKOSTREAM_VERSION || '2026.4.5',
7
+ minimize: () => ipcRenderer.send('window:minimize'),
8
+ maximize: () => ipcRenderer.send('window:maximize'),
9
+ close: () => ipcRenderer.send('window:close'),
10
+ openExternal: (url) => ipcRenderer.send('shell:openExternal', url),
11
+ navigate: (url) => ipcRenderer.send('window:navigate', url),
12
+ returnHome: () => ipcRenderer.send('window:returnHome')
13
+ })
14
+
@@ -8,10 +8,34 @@ const chalk_1 = __importDefault(require("chalk"));
8
8
  const discord_1 = require("../discord");
9
9
  const logger_1 = require("../logger");
10
10
  const storage_1 = require("../storage");
11
+ const ui_1 = require("../ui");
12
+ const theme_1 = require("./theme");
11
13
  const domain_startup_1 = require("./domain-startup");
12
14
  const home_menu_1 = require("./menus/home-menu");
13
15
  const update_check_1 = require("./update-check");
14
- const web_server_1 = require("./web-server");
16
+ const player_1 = require("../player");
17
+ function printHelp(version) {
18
+ const heading = (text) => chalk_1.default.hex(theme_1.palette.muted).bold(text);
19
+ const flag = (name) => chalk_1.default.hex(theme_1.palette.soft)(name);
20
+ console.log(`
21
+ ${chalk_1.default.hex(theme_1.palette.text).bold('nekostream')} ${chalk_1.default.hex(theme_1.palette.subtle)(`v${version}`)}
22
+ ${chalk_1.default.hex(theme_1.palette.muted)('stream anime from vietnamese providers, right in your terminal.')}
23
+
24
+ ${heading('USAGE')}
25
+ ${flag('$ nekostream [command|flags]')}
26
+
27
+ ${heading('FLAGS')}
28
+ ${flag('-v, --version')} show version number and exit
29
+ ${flag('-h, --help')} show this help message and exit
30
+ ${flag('--desktop, --app')} launch native desktop player application (recommended)
31
+ ${flag('--web')} launch desktop application (web redirected to desktop)
32
+
33
+ ${heading('EXAMPLES')}
34
+ ${flag('$ nekostream')} launch interactive terminal UI
35
+ ${flag('$ nekostream --desktop')} launch native desktop application
36
+ ${flag('$ nekostream --version')} print installed version
37
+ `);
38
+ }
15
39
  /**
16
40
  * Application entry point: bring up the runtime services, then hand control to
17
41
  * the home menu loop, which owns the interactive session until exit.
@@ -20,17 +44,38 @@ const web_server_1 = require("./web-server");
20
44
  * process when a newer version exists, so there is no point probing first.
21
45
  */
22
46
  async function runApplication() {
47
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
48
+ console.log(`nekostream v${(0, ui_1.getVersion)()}`);
49
+ return;
50
+ }
51
+ if (process.argv.includes('--help') || process.argv.includes('-h') || process.argv.includes('help')) {
52
+ printHelp((0, ui_1.getVersion)());
53
+ return;
54
+ }
55
+ if (process.argv.includes('--desktop') ||
56
+ process.argv.includes('desktop') ||
57
+ process.argv.includes('--app') ||
58
+ process.argv.includes('app')) {
59
+ console.log(`\n${chalk_1.default.hex(theme_1.palette.accent)('✶')} ${chalk_1.default.hex(theme_1.palette.soft)('desktop — đang khởi chạy ứng dụng native...')}`);
60
+ await (0, player_1.launchDesktopApp)();
61
+ return;
62
+ }
63
+ if (process.argv.includes('--web') || process.argv.includes('web')) {
64
+ console.log(`\n${chalk_1.default.hex(theme_1.palette.accent)('✶')} ${chalk_1.default.hex(theme_1.palette.soft)('NekoStream đã chuyển nền tảng sang Full Desktop app (tránh chặn iframe/CORS từ video providers).')}`);
65
+ console.log(`${chalk_1.default.hex(theme_1.palette.muted)(' Đang tự động chuyển hướng khởi chạy NekoStream Desktop...')}\n`);
66
+ await (0, player_1.launchDesktopApp)();
67
+ return;
68
+ }
69
+ if (!process.stdin.isTTY) {
70
+ console.error(chalk_1.default.hex(theme_1.palette.yellow)('nekostream requires an interactive TTY terminal to run the interactive UI.'));
71
+ console.error(chalk_1.default.hex(theme_1.palette.subtle)("run 'nekostream --help' for available flags and options."));
72
+ process.exitCode = 2;
73
+ return;
74
+ }
23
75
  (0, storage_1.ensureDataDir)();
24
76
  await (0, update_check_1.checkUpdate)();
25
77
  await (0, domain_startup_1.resolveProviderDomains)();
26
78
  await (0, discord_1.initDiscord)();
27
79
  (0, logger_1.debugLog)('NekoStream CLI started');
28
- if (process.argv.includes('--web') || process.argv.includes('web')) {
29
- const instance = await (0, web_server_1.startWebServer)();
30
- console.log(chalk_1.default.cyan(`\n[NekoStream Web] Giao diện Web đang chạy tại: ${chalk_1.default.bold.underline(instance.url)}`));
31
- console.log(chalk_1.default.gray('Nhấn Ctrl+C để dừng server.\n'));
32
- (0, web_server_1.openBrowser)(instance.url);
33
- return new Promise(() => { });
34
- }
35
80
  await (0, home_menu_1.showHomeMenu)();
36
81
  }
@@ -9,6 +9,7 @@ const ora_1 = __importDefault(require("ora"));
9
9
  const logger_1 = require("../logger");
10
10
  const domain_resolver_1 = require("../scrapers/domain-resolver");
11
11
  const feedback_1 = require("./feedback");
12
+ const theme_1 = require("./theme");
12
13
  /**
13
14
  * The fast path — probing the domain already in use — normally answers well
14
15
  * inside this window, so no spinner is drawn at all and startup looks unchanged.
@@ -32,7 +33,7 @@ function summarize(outcomes) {
32
33
  function delayedSpinner(text) {
33
34
  let spinner;
34
35
  const timer = setTimeout(() => {
35
- spinner = (0, ora_1.default)(text).start();
36
+ spinner = (0, ora_1.default)({ text, spinner: theme_1.spinnerStyle, color: 'yellow' }).start();
36
37
  }, SPINNER_DELAY_MS);
37
38
  return {
38
39
  stop: () => {
@@ -53,10 +54,10 @@ function delayedSpinner(text) {
53
54
  * simply mean the cached or seeded domain is used as-is.
54
55
  */
55
56
  async function resolveProviderDomains() {
56
- const spinner = delayedSpinner('Đang dò domain provider...');
57
+ const spinner = delayedSpinner('đang dò domain provider...');
57
58
  let outcomes;
58
59
  try {
59
- outcomes = await (0, domain_resolver_1.resolveAllDomains)();
60
+ outcomes = await (0, domain_resolver_1.resolveAllDomains)(undefined, { onlyStale: true });
60
61
  }
61
62
  finally {
62
63
  spinner.stop();
@@ -69,7 +70,7 @@ async function resolveProviderDomains() {
69
70
  // expected case and the whole point of the fast path.
70
71
  if (changed.length > 0) {
71
72
  for (const outcome of changed) {
72
- console.log(chalk_1.default.cyan(` Domain ${outcome.provider} đã đổi -> ${outcome.baseUrl}`));
73
+ console.log(`${chalk_1.default.hex(theme_1.palette.cyan)(` domain ${outcome.provider} đã đổi`)} ${chalk_1.default.hex(theme_1.palette.subtle)('→')} ${chalk_1.default.hex(theme_1.palette.soft)(outcome.baseUrl)}`);
73
74
  }
74
75
  await (0, feedback_1.sleep)(feedback_1.CONFIRM_DELAY_MS);
75
76
  return;
@@ -79,7 +80,7 @@ async function resolveProviderDomains() {
79
80
  // cannot act on; with it, the line only follows visible work.
80
81
  if (failed.length > 0 && spinner.wasShown()) {
81
82
  const names = failed.map((o) => o.provider).join(', ');
82
- console.log(chalk_1.default.yellow(` Không dò được domain sống cho: ${names}. Dùng domain đã lưu.`));
83
+ console.log(chalk_1.default.hex(theme_1.palette.yellow)(` không dò được domain sống cho: ${names}. dùng domain đã lưu.`));
83
84
  await (0, feedback_1.sleep)(feedback_1.CONFIRM_DELAY_MS);
84
85
  }
85
86
  }