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.
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/desktop-main.js +530 -0
- package/desktop-preload.js +14 -0
- package/dist/cli/application.js +53 -8
- package/dist/cli/domain-startup.js +6 -5
- package/dist/cli/feedback.js +13 -8
- package/dist/cli/flows/anime-flow.js +58 -38
- package/dist/cli/flows/auth-flow.js +5 -4
- package/dist/cli/flows/provider-lists.js +3 -3
- package/dist/cli/menus/history-menu.js +13 -10
- package/dist/cli/menus/home-menu.js +51 -37
- package/dist/cli/menus/settings-menu.js +32 -29
- package/dist/cli/party/room-manager.js +284 -0
- package/dist/cli/party/tailscale-service.js +276 -0
- package/dist/cli/party/tunnel-service.js +245 -0
- package/dist/cli/party/types.js +5 -0
- package/dist/cli/text.js +26 -0
- package/dist/cli/theme.js +49 -19
- package/dist/cli/update-check.js +47 -7
- package/dist/cli/web-server.js +1000 -52
- package/dist/index.js +3 -2
- package/dist/player.js +231 -51
- package/dist/prompts-wrapper.js +798 -154
- package/dist/providers.js +36 -5
- package/dist/scrapers/adblock-policy.js +57 -22
- package/dist/scrapers/auth/animevietsub/lists.js +48 -22
- package/dist/scrapers/auth/animevietsub/login.js +50 -21
- package/dist/scrapers/auth/animevietsub/parsers.js +197 -46
- package/dist/scrapers/auth/auth-session-service.js +1 -0
- package/dist/scrapers/auth/browser-login.js +41 -13
- package/dist/scrapers/crawler.js +35 -2
- package/dist/scrapers/domain-registry.js +1 -1
- package/dist/scrapers/domain-resolver.js +90 -7
- package/dist/scrapers/http-client.js +2 -2
- package/dist/scrapers/providers/anime47.js +0 -10
- package/dist/scrapers/providers/animehay.js +2 -2
- package/dist/scrapers/providers/animevietsub/streams/extractors.js +1 -1
- package/dist/scrapers/providers/animevietsub.js +0 -10
- package/dist/storage.js +66 -1
- package/dist/ui.js +104 -55
- package/package.json +95 -79
- package/player.html +420 -23
- package/web/dist/assets/index-CslHcpEL.css +1 -0
- package/web/dist/assets/index-DqlrnkCb.js +2257 -0
- package/web/dist/index.html +27 -22
- package/web/dist/assets/index-BByTtXWy.css +0 -1
- package/web/dist/assets/index-Y4PiRbR7.js +0 -2171
package/assets/icon.ico
ADDED
|
Binary file
|
package/assets/icon.png
ADDED
|
Binary file
|
package/desktop-main.js
ADDED
|
@@ -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
|
+
|
package/dist/cli/application.js
CHANGED
|
@@ -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
|
|
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('
|
|
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(`
|
|
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(`
|
|
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
|
}
|