diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml new file mode 100644 index 0000000..263f8cb --- /dev/null +++ b/.github/workflows/code-check.yml @@ -0,0 +1,29 @@ +name: Code Check + +on: + push: + branches: + - "**" + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm install + + - name: Run lint and syntax check + run: npm run check diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..ade14c3 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,77 @@ +name: Release Build + +on: + workflow_dispatch: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + name: Build (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows + os: windows-latest + script: npm run pack:win + - name: macOS + os: macos-latest + script: npm run pack:mac + - name: Linux + os: ubuntu-latest + script: npm run pack:linux + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libarchive-tools rpm + + - name: Install dependencies + run: npm install + + - name: Run code check + run: npm run check + + - name: Build package + run: ${{ matrix.script }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ELXMOJ-${{ matrix.name }} + path: dist/** + if-no-files-found: error + + publish: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: release-assets/** + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..72b41e3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "XMOJ-Script"] + path = XMOJ-Script + url = https://github.com/XMOJ-Script-dev/XMOJ-Script diff --git a/README.md b/README.md index aaa65f7..f408623 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,74 @@ -# Electro-XMOJ -XMOJ exported to electron! +# ELXMOJ (Electron) + +在 Electron 中访问 `https://www.xmoj.tech`,自动加载 `XMOJ-Script/XMOJ.user.js`,并提供启动自检、脚本更新与设置持久化。 + +## 功能 + +- 启动后打开 `www.xmoj.tech` +- 自动注入子模块 `XMOJ-Script/XMOJ.user.js`(首次运行复制到用户数据目录) +- 每次启动可检查脚本更新 +- 正式版更新源:`https://xmoj-bbs.me/XMOJ.user.js` +- 预览版更新源:`https://dev.xmoj-bbs.me/XMOJ.user.js` +- App 下载更新源:GitHub Releases `https://github.com/XMOJ-Script-dev/ELXMOJ/releases/download/v{version}/ELXMOJ-{version}.{ext}` + - ext 按平台自动适配:Windows `.exe` / macOS `.dmg` / Linux `.AppImage` +- 发现新版本时弹窗提示用户是否更新 +- 设置持久化(通道、启动检查、自动注入) +- 提供启动自检和手动自检 + +## 运行 + +```bash +git submodule update --init --recursive +npm install +npm start +``` + +## 自检模式 + +```bash +npm run self-check +``` + +## 代码检查 + +```bash +npm run check +``` + +包含: + +- `eslint` 静态检查(`src`) +- Node 语法检查(`node --check`) + +## 全平台打包 + +```bash +npm run pack:win +npm run pack:mac +npm run pack:linux +``` + +打包产物默认输出到 `dist/`。 + +## GitHub Actions + +- 代码检查工作流:`.github/workflows/code-check.yml` + - 在 `push` / `pull_request` 触发 + - 执行 `npm ci` + `npm run check` +- 发布构建工作流:`.github/workflows/release-build.yml` + - 在手动触发或 `v*` tag 触发 + - Windows/macOS/Linux 矩阵并行打包 + - `v*` tag 时自动创建 GitHub Release 并上传产物 + +应用菜单 `ELXMOJ` 中也可以执行: + +- 设置 +- 执行自检 +- 检查脚本更新 + +## 持久化位置 + +应用会把运行数据写到 Electron 的 `userData` 目录,包括: + +- `settings.json` +- `XMOJ.user.js`(复制后的托管脚本文件,来源于 `XMOJ-Script/XMOJ.user.js`) diff --git a/XMOJ-Script b/XMOJ-Script new file mode 160000 index 0000000..e14e33c --- /dev/null +++ b/XMOJ-Script @@ -0,0 +1 @@ +Subproject commit e14e33c647f3afd19719d9b72001a004b7220657 diff --git a/build/icons/app.ico b/build/icons/app.ico new file mode 100644 index 0000000..74d8108 Binary files /dev/null and b/build/icons/app.ico differ diff --git a/build/icons/app.png b/build/icons/app.png new file mode 100644 index 0000000..23f636b Binary files /dev/null and b/build/icons/app.png differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c9d0b50 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +const js = require("@eslint/js"); +const globals = require("globals"); + +module.exports = [ + { + ignores: ["node_modules/**", "dist/**"] + }, + js.configs.recommended, + { + files: ["src/**/*.js"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "script", + globals: { + ...globals.browser, + ...globals.node + } + }, + rules: { + "no-console": "off" + } + } +]; diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..23f636b Binary files /dev/null and b/favicon.ico differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..c1d22d0 --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "electro-xmoj", + "version": "1.0.0", + "description": "ELXMOJ Electron launcher with userscript updater", + "main": "src/main.js", + "homepage": "https://app.xmoj-bbs.me", + "repository": { + "type": "git", + "url": "https://github.com/XMOJ-Script-dev/ELXMOJ.git" + }, + "license": "GPL-3.0-or-later", + "scripts": { + "start": "electron .", + "self-check": "electron . --self-check", + "lint": "eslint src --ext .js", + "check:syntax": "node --check src/main.js && node --check src/preload.js && node --check src/settings.js && node --check src/storage.js && node --check src/updater.js", + "check": "npm run lint && npm run check:syntax", + "pack:win": "electron-builder --win nsis portable --publish never", + "pack:mac": "electron-builder --mac zip --publish never", + "pack:linux": "electron-builder --linux AppImage tar.gz --publish never" + }, + "devDependencies": { + "@eslint/js": "^9.23.0", + "electron": "^37.2.0", + "electron-builder": "^26.0.12", + "eslint": "^9.23.0", + "globals": "^16.0.0" + }, + "build": { + "appId": "me.xmoj.elxmoj", + "productName": "ELXMOJ", + "directories": { + "output": "dist" + }, + "files": [ + "src/**/*", + "build/icons/**/*", + "XMOJ-Script/XMOJ.user.js", + "package.json", + "README.md", + "LICENSE" + ], + "asar": true, + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", + "win": { + "icon": "build/icons/app.ico", + "target": [ + "nsis", + "portable" + ] + }, + "mac": { + "icon": "build/icons/app.png", + "target": [ + "zip" + ], + "category": "public.app-category.developer-tools" + }, + "linux": { + "icon": "build/icons/app.png", + "target": [ + "AppImage", + "tar.gz" + ], + "category": "Development" + } + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..87a9035 --- /dev/null +++ b/src/main.js @@ -0,0 +1,1049 @@ +const path = require("node:path"); +const { app, BrowserWindow, dialog, ipcMain, Menu, net, session, shell } = require("electron"); + +const { + loadSettings, + saveSettings, + readManagedScript, + writeManagedScript, + ensureManagedScript +} = require("./storage"); +const { + getChannelUrl, + downloadText, + extractVersion, + extractName, + extractRequires, + isNewerVersion +} = require("./updater"); + +const ALLOWED_GM_XHR_HOSTS = new Set([ + "www.xmoj.tech", + "xmoj.tech", + "116.62.212.172", + "api.xmoj-bbs.me", + "api.xmoj-bbs.tech", + "cdnjs.cloudflare.com", + "cdn.jsdelivr.net", + "unpkg.com", + "raw.githubusercontent.com", + "gitee.com", + "challenges.cloudflare.com", + "cppinsights.io", + "127.0.0.1", + "localhost" +]); + +let mainWindow = null; +let settingsWindow = null; +let settingsCache = null; +let lastCheckResult = null; + +const LOCAL_SCRIPT_PATH = path.join(__dirname, "..", "XMOJ-Script", "XMOJ.user.js"); +const XMOJ_HOME = "https://www.xmoj.tech"; +const USER_SCRIPT_DEBUG_MODE_KEY = "UserScript-Setting-DebugMode"; +const APP_UPDATE_URL_TEMPLATE = "https://github.com/XMOJ-Script-dev/ELXMOJ/releases/download/v{version}/ELXMOJ-{version}.{ext}"; +const PRELOAD_PATH = path.join(__dirname, "preload.js"); +const APP_ICON_PATH = path.join( + __dirname, + "..", + "build", + "icons", + process.platform === "win32" ? "app.ico" : "app.png" +); + +function getPlatformPackageExtension() { + if (process.platform === "win32") return "exe"; + if (process.platform === "darwin") return "dmg"; + if (process.platform === "linux") return "AppImage"; + return "zip"; +} + +function getAppUpdateUrl() { + const version = app.getVersion(); + const ext = getPlatformPackageExtension(); + return APP_UPDATE_URL_TEMPLATE + .replace("{version}", version) + .replace("{ext}", ext); +} + +function getDebugModeFromChannel(channel) { + return String(channel || "stable") === "preview"; +} + +function getChannelFromDebugMode(debugMode) { + return debugMode ? "preview" : "stable"; +} + +function canReadMainWindowScriptSettings() { + if (!mainWindow || mainWindow.isDestroyed()) { + return false; + } + + const url = String(mainWindow.webContents.getURL() || ""); + if (!url) { + return false; + } + + try { + const parsed = new URL(url); + return ["www.xmoj.tech", "xmoj.tech", "116.62.212.172"].includes(parsed.hostname); + } catch { + return false; + } +} + +async function readScriptDebugModeFromMainWindow() { + if (!canReadMainWindowScriptSettings()) { + return null; + } + + const code = `(() => { + try { + const value = localStorage.getItem(${JSON.stringify(USER_SCRIPT_DEBUG_MODE_KEY)}); + if (value === "true") return true; + if (value === "false") return false; + return null; + } catch { + return null; + } + })()`; + + try { + return await mainWindow.webContents.executeJavaScript(code, true); + } catch { + return null; + } +} + +async function writeScriptDebugModeToMainWindow(debugMode) { + if (!canReadMainWindowScriptSettings()) { + return false; + } + + const normalized = Boolean(debugMode); + const code = `(() => { + try { + localStorage.setItem(${JSON.stringify(USER_SCRIPT_DEBUG_MODE_KEY)}, ${JSON.stringify(String(normalized))}); + return true; + } catch { + return false; + } + })()`; + + try { + return await mainWindow.webContents.executeJavaScript(code, true); + } catch { + return false; + } +} + +async function syncChannelFromScriptDebugMode() { + const debugMode = await readScriptDebugModeFromMainWindow(); + if (typeof debugMode !== "boolean") { + return { synced: false, debugMode: null, channel: null }; + } + + const targetChannel = getChannelFromDebugMode(debugMode); + const current = await getSettings(); + if (current.channel !== targetChannel) { + await setSettings({ ...current, channel: targetChannel }); + return { synced: true, debugMode, channel: targetChannel }; + } + + return { synced: false, debugMode, channel: targetChannel }; +} + +function createAppWebPreferences() { + return { + preload: PRELOAD_PATH, + contextIsolation: true, + nodeIntegration: false, + sandbox: false + }; +} + +function getPopupWindowOptions() { + return { + width: 1280, + height: 820, + minWidth: 980, + minHeight: 640, + title: "ELXMOJ", + icon: APP_ICON_PATH, + webPreferences: createAppWebPreferences() + }; +} + +function attachBrowserShortcutBehavior(targetWindow) { + if (!targetWindow || targetWindow.isDestroyed()) { + return; + } + + const webContents = targetWindow.webContents; + if (!webContents || webContents.__ELXMOJ_SHORTCUTS_ATTACHED__) { + return; + } + + webContents.__ELXMOJ_SHORTCUTS_ATTACHED__ = true; + + webContents.on("before-input-event", (event, input) => { + if (!input || input.type !== "keyDown") { + return; + } + + const key = String(input.key || ""); + const normalizedKey = key.length === 1 ? key.toLowerCase() : key; + const hasMeta = Boolean(input.meta); + const hasCtrlOrMeta = Boolean(input.control || input.meta); + const hasShift = Boolean(input.shift); + const hasAlt = Boolean(input.alt); + + const isHardReload = + (normalizedKey === "F5" && hasShift) || + (hasCtrlOrMeta && hasShift && normalizedKey === "r"); + if (isHardReload) { + event.preventDefault(); + webContents.reloadIgnoringCache(); + return; + } + + const isReload = normalizedKey === "F5" || (hasCtrlOrMeta && normalizedKey === "r"); + if (isReload) { + event.preventDefault(); + webContents.reload(); + return; + } + + const isGoBack = + (hasAlt && normalizedKey === "ArrowLeft") || + normalizedKey === "BrowserBack" || + (hasMeta && !hasShift && normalizedKey === "["); + if (isGoBack) { + event.preventDefault(); + if (webContents.canGoBack()) { + webContents.goBack(); + } + return; + } + + const isGoForward = + (hasAlt && normalizedKey === "ArrowRight") || + normalizedKey === "BrowserForward" || + (hasMeta && !hasShift && normalizedKey === "]"); + if (isGoForward) { + event.preventDefault(); + if (webContents.canGoForward()) { + webContents.goForward(); + } + return; + } + + const isOpenSettings = hasCtrlOrMeta && normalizedKey === ","; + if (isOpenSettings) { + event.preventDefault(); + openSettingsWindow(); + } + }); +} + +function attachPopupInjectionBehavior(targetWindow) { + if (!targetWindow || targetWindow.isDestroyed()) { + return; + } + + attachBrowserShortcutBehavior(targetWindow); + + targetWindow.webContents.setWindowOpenHandler(({ url }) => { + const nextUrl = String(url || ""); + + let parsedTargetUrl; + try { + parsedTargetUrl = new URL(nextUrl); + } catch { + return { action: "deny" }; + } + + if (parsedTargetUrl.protocol !== "http:" && parsedTargetUrl.protocol !== "https:") { + return { action: "deny" }; + } + + let trustedOrigin = ""; + try { + trustedOrigin = new URL(XMOJ_HOME).origin; + } catch { + trustedOrigin = ""; + } + + const targetOrigin = parsedTargetUrl.origin; + + if (!trustedOrigin || targetOrigin !== trustedOrigin) { + shell.openExternal(nextUrl).catch(() => { + // Ignore failures to open external URLs + }); + return { action: "deny" }; + } + + return { + action: "allow", + overrideBrowserWindowOptions: getPopupWindowOptions() + }; + }); + + targetWindow.webContents.on("did-create-window", (childWindow) => { + attachPopupInjectionBehavior(childWindow); + }); +} + +async function getPhpSessionIdFromCookieStore() { + try { + const cookies = await session.defaultSession.cookies.get({ + url: XMOJ_HOME, + name: "PHPSESSID" + }); + return cookies[0]?.value || ""; + } catch { + return ""; + } +} + +function isXmojScriptApiRequest(url) { + return /^https:\/\/api\.xmoj-bbs\.(me|tech)\//i.test(String(url || "")); +} + +async function patchApiAuthPayloadIfNeeded({ url, method, headers, body }) { + if (!isXmojScriptApiRequest(url) || String(method || "").toUpperCase() !== "POST") { + return body; + } + + if (typeof body !== "string" || !body.trim()) { + return body; + } + + let parsed; + try { + parsed = JSON.parse(body); + } catch { + return body; + } + + if (!parsed || typeof parsed !== "object") { + return body; + } + + const realSessionId = await getPhpSessionIdFromCookieStore(); + if (!realSessionId) { + return body; + } + + if (!parsed.Authentication || typeof parsed.Authentication !== "object") { + parsed.Authentication = {}; + } + + parsed.Authentication.SessionID = realSessionId; + + const userHeader = headers?.["XMOJ-UserID"] ?? headers?.["xmoj-userid"]; + if (!parsed.Authentication.Username && userHeader) { + parsed.Authentication.Username = String(userHeader); + } + + return JSON.stringify(parsed); +} + +function getScriptBootstrapOptions() { + return { + getInitialScriptContent: async () => downloadText(getChannelUrl("stable")) + }; +} + +function createMainMenu() { + const openConsole = (mode = "bottom") => { + const target = BrowserWindow.getFocusedWindow() || mainWindow; + if (!target || target.isDestroyed()) return; + target.webContents.openDevTools({ mode }); + }; + + const template = [ + { + label: "ELXMOJ", + submenu: [ + { + label: "打开主页", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.loadURL(XMOJ_HOME); + } + } + }, + { type: "separator" }, + { + label: "设置", + accelerator: "CmdOrCtrl+,", + click: () => openSettingsWindow() + }, + { + label: "执行自检", + click: () => runSelfCheck(true) + }, + { + label: "检查脚本更新", + click: () => checkForScriptUpdate({ showNoUpdateDialog: true }) + }, + { type: "separator" }, + { + label: "查看控制台", + accelerator: "F12", + click: () => openConsole("bottom") + }, + { + label: "分离控制台窗口", + accelerator: "Ctrl+Shift+I", + click: () => openConsole("detach") + }, + { + label: "关闭控制台", + click: () => { + const target = BrowserWindow.getFocusedWindow() || mainWindow; + if (target && !target.isDestroyed()) { + target.webContents.closeDevTools(); + } + } + }, + { type: "separator" }, + { role: "forceReload", label: "强制刷新" }, + { role: "reload", label: "刷新" }, + { role: "quit", label: "退出" } + ] + }, + { + label: "编辑", + submenu: [ + { role: "undo", label: "撤销" }, + { role: "redo", label: "重做" }, + { type: "separator" }, + { role: "cut", label: "剪切" }, + { role: "copy", label: "复制" }, + { role: "paste", label: "粘贴" }, + { role: "selectAll", label: "全选" } + ] + }, + { + label: "查看", + submenu: [ + { role: "zoomIn", label: "放大" }, + { role: "zoomOut", label: "缩小" }, + { role: "resetZoom", label: "重置缩放" }, + { type: "separator" }, + { role: "togglefullscreen", label: "全屏" }, + { + label: "查看控制台", + accelerator: "F12", + click: () => openConsole("bottom") + }, + { + label: "切换开发者工具", + role: "toggleDevTools" + } + ] + }, + { + label: "窗口", + submenu: [ + { role: "minimize", label: "最小化" }, + { role: "close", label: "关闭窗口" } + ] + }, + { + label: "帮助", + submenu: [ + { + label: "下载最新版本", + click: () => { + shell.openExternal(getAppUpdateUrl()).catch(() => { + // Ignore failures to open update page + }); + } + }, + { type: "separator" }, + { + label: "关于 ELXMOJ", + click: async () => { + await dialog.showMessageBox(mainWindow || undefined, { + type: "info", + title: "关于 ELXMOJ", + message: "ELXMOJ", + detail: "Electron 封装的 XMOJ 增强启动器。\n支持 userscript 自动注入、更新检查和自检。" + }); + } + } + ] + } + ]; + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} + +async function getSettings() { + if (!settingsCache) { + settingsCache = await loadSettings(app); + } + return settingsCache; +} + +async function setSettings(nextSettings) { + settingsCache = nextSettings; + await saveSettings(app, settingsCache); +} + +function createMainWindow() { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 1100, + minHeight: 680, + title: "ELXMOJ", + icon: APP_ICON_PATH, + webPreferences: createAppWebPreferences() + }); + + attachBrowserShortcutBehavior(mainWindow); + attachPopupInjectionBehavior(mainWindow); + mainWindow.loadURL(XMOJ_HOME); + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +function openSettingsWindow() { + if (settingsWindow && !settingsWindow.isDestroyed()) { + settingsWindow.focus(); + return; + } + + settingsWindow = new BrowserWindow({ + width: 520, + height: 520, + resizable: false, + minimizable: false, + maximizable: false, + autoHideMenuBar: true, + title: "ELXMOJ 设置", + icon: APP_ICON_PATH, + parent: mainWindow || undefined, + modal: Boolean(mainWindow), + webPreferences: createAppWebPreferences() + }); + + attachBrowserShortcutBehavior(settingsWindow); + settingsWindow.loadFile(path.join(__dirname, "settings.html")); + settingsWindow.on("closed", () => { + settingsWindow = null; + }); +} + +function buildSelfCheckReport({ + hasManagedScript, + localVersion, + hasVersionMeta, + urlReachable, + injectionReady, + channel +}) { + const lines = [ + "ELXMOJ 自检结果", + "", + `脚本文件: ${hasManagedScript ? "OK" : "失败"}`, + `脚本版本元数据(@version): ${hasVersionMeta ? `OK (${localVersion})` : "缺失"}`, + `更新源可访问: ${urlReachable ? "OK" : "失败"}`, + `注入状态: ${injectionReady ? "已就绪" : "未就绪"}`, + `更新通道: ${channel === "preview" ? "预览版" : "正式版"}`, + `App 更新下载: ${getAppUpdateUrl()}` + ]; + return lines.join("\n"); +} + +async function checkUpdateEndpoint(channel) { + try { + const text = await downloadText(getChannelUrl(channel)); + return { ok: true, text }; + } catch (error) { + return { ok: false, error: String(error?.message || error) }; + } +} + +async function runSelfCheck(showDialog = false) { + await syncChannelFromScriptDebugMode(); + const settings = await getSettings(); + await ensureManagedScript(app, LOCAL_SCRIPT_PATH, getScriptBootstrapOptions()); + const localScript = await readManagedScript(app, LOCAL_SCRIPT_PATH, getScriptBootstrapOptions()); + const localVersion = extractVersion(localScript); + const endpoint = await checkUpdateEndpoint(settings.channel); + + const report = buildSelfCheckReport({ + hasManagedScript: Boolean(localScript && localScript.length > 0), + localVersion, + hasVersionMeta: Boolean(localVersion), + urlReachable: endpoint.ok, + injectionReady: true, + channel: settings.channel + }); + + lastCheckResult = { + timestamp: Date.now(), + report, + endpointError: endpoint.ok ? "" : endpoint.error + }; + + if (showDialog && mainWindow) { + await dialog.showMessageBox(mainWindow, { + type: endpoint.ok ? "info" : "warning", + title: "ELXMOJ 自检", + message: report, + detail: endpoint.ok ? "" : `更新源访问失败:\n${endpoint.error}` + }); + } + + return lastCheckResult; +} + +async function checkForScriptUpdate({ showNoUpdateDialog = false } = {}) { + await syncChannelFromScriptDebugMode(); + const settings = await getSettings(); + await ensureManagedScript(app, LOCAL_SCRIPT_PATH, getScriptBootstrapOptions()); + + const localScript = await readManagedScript(app, LOCAL_SCRIPT_PATH, getScriptBootstrapOptions()); + const currentVersion = extractVersion(localScript) || "0.0.0"; + + let remoteScript; + try { + remoteScript = await downloadText(getChannelUrl(settings.channel)); + } catch (error) { + if (showNoUpdateDialog && mainWindow) { + await dialog.showMessageBox(mainWindow, { + type: "warning", + title: "更新检查失败", + message: "无法连接脚本更新源。", + detail: String(error?.message || error) + }); + } + return { updated: false, reason: "download_failed" }; + } + + const remoteVersion = extractVersion(remoteScript) || "0.0.0"; + const remoteName = extractName(remoteScript); + const shouldUpdate = isNewerVersion(currentVersion, remoteVersion); + + if (!shouldUpdate) { + if (showNoUpdateDialog && mainWindow) { + await dialog.showMessageBox(mainWindow, { + type: "info", + title: "已是最新", + message: `${remoteName} 当前已是最新版本 (${currentVersion})` + }); + } + return { updated: false, reason: "already_latest", currentVersion, remoteVersion }; + } + + if (settings.skipVersionPrompt === remoteVersion) { + return { updated: false, reason: "user_skipped", currentVersion, remoteVersion }; + } + + if (!mainWindow) { + return { updated: false, reason: "no_window" }; + } + + const prompt = await dialog.showMessageBox(mainWindow, { + type: "question", + title: "发现新版本脚本", + message: `${remoteName} 有新版本可用: ${currentVersion} -> ${remoteVersion}`, + detail: `来源: ${getChannelUrl(settings.channel)}\n是否更新并立即生效?`, + buttons: ["立即更新", "跳过本次版本", "暂不更新"], + cancelId: 2, + defaultId: 0, + noLink: true + }); + + if (prompt.response === 1) { + const next = { ...settings, skipVersionPrompt: remoteVersion }; + await setSettings(next); + return { updated: false, reason: "skip_this_version", currentVersion, remoteVersion }; + } + + if (prompt.response !== 0) { + return { updated: false, reason: "cancelled", currentVersion, remoteVersion }; + } + + await writeManagedScript(app, remoteScript); + const next = { ...settings, skipVersionPrompt: "" }; + await setSettings(next); + + if (mainWindow && !mainWindow.isDestroyed()) { + await mainWindow.webContents.reload(); + } + + return { updated: true, currentVersion, remoteVersion }; +} + +function isTrustedIpcSender(event) { + try { + const sender = event?.sender; + if (!sender || sender.isDestroyed()) { + return false; + } + + const ownerWindow = BrowserWindow.fromWebContents(sender); + if (!ownerWindow || ownerWindow.isDestroyed()) { + return false; + } + + const url = event?.senderFrame?.url || sender.getURL() || ""; + if (!url) { + return false; + } + + if (url.startsWith("file://") || url.startsWith("app://")) { + return true; + } + + let parsed; + try { + parsed = new URL(url); + } catch { + return false; + } + + if (!["http:", "https:"].includes(parsed.protocol)) { + return false; + } + + const allowedHosts = new Set(["www.xmoj.tech", "xmoj.tech", "116.62.212.172"]); + return allowedHosts.has(parsed.hostname); + } catch { + return false; + } +} + +function registerIpcHandlers() { + ipcMain.handle("elxmoj:get-settings", async (event) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + return getSettings(); + }); + + ipcMain.handle("elxmoj:update-settings", async (event, patch) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + const current = await getSettings(); + const next = { ...current, ...patch }; + await setSettings(next); + + if (Object.prototype.hasOwnProperty.call(patch || {}, "channel")) { + await writeScriptDebugModeToMainWindow(getDebugModeFromChannel(next.channel)); + } + + return next; + }); + + ipcMain.handle("elxmoj:get-script-debug-mode", async (event) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + return readScriptDebugModeFromMainWindow(); + }); + + ipcMain.handle("elxmoj:set-script-debug-mode", async (event, enabled) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + + const debugMode = Boolean(enabled); + const channel = getChannelFromDebugMode(debugMode); + const current = await getSettings(); + if (current.channel !== channel) { + await setSettings({ ...current, channel }); + } + + const updated = await writeScriptDebugModeToMainWindow(debugMode); + return { ok: updated, debugMode, channel }; + }); + + ipcMain.handle("elxmoj:sync-channel-from-script-debug", async (event) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + return syncChannelFromScriptDebugMode(); + }); + + ipcMain.handle("elxmoj:update-channel-by-script-debug", async (event, enabled) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + const debugMode = Boolean(enabled); + const channel = getChannelFromDebugMode(debugMode); + const current = await getSettings(); + if (current.channel !== channel) { + await setSettings({ ...current, channel }); + return { updated: true, channel, debugMode }; + } + return { updated: false, channel, debugMode }; + }); + + ipcMain.handle("elxmoj:get-script-payload", async () => { + const scriptText = await readManagedScript(app, LOCAL_SCRIPT_PATH, getScriptBootstrapOptions()); + return { + name: extractName(scriptText), + version: extractVersion(scriptText), + requires: extractRequires(scriptText), + scriptText + }; + }); + + ipcMain.handle("elxmoj:check-update", async (event) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + return checkForScriptUpdate({ showNoUpdateDialog: true }); + }); + ipcMain.handle("elxmoj:run-self-check", async (event) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + return runSelfCheck(true); + }); + ipcMain.handle("elxmoj:get-last-self-check", async (event) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + return lastCheckResult; + }); + + ipcMain.handle("elxmoj:get-app-update-url", async (event) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + return getAppUpdateUrl(); + }); + + ipcMain.handle("elxmoj:open-app-update-page", async (event) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + const url = getAppUpdateUrl(); + await shell.openExternal(url); + return { ok: true, url }; + }); + + ipcMain.handle("elxmoj:get-phpsessid", async () => { + const value = await getPhpSessionIdFromCookieStore(); + return value || ""; + }); + + ipcMain.handle("elxmoj:gm-xhr", async (event, request) => { + if (!isTrustedIpcSender(event)) { + throw new Error("Unauthorized IPC sender"); + } + + const req = request || {}; + const url = String(req.url || ""); + if (!url) { + return { + ok: false, + error: "GM_xmlhttpRequest requires a non-empty url" + }; + } + + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + return { + ok: false, + error: "GM_xmlhttpRequest requires a valid URL" + }; + } + + if (!["http:", "https:"].includes(parsedUrl.protocol) || !ALLOWED_GM_XHR_HOSTS.has(parsedUrl.hostname)) { + return { + ok: false, + error: "GM_xmlhttpRequest URL is not allowed" + }; + } + + const method = String(req.method || "GET").toUpperCase(); + const headers = req.headers && typeof req.headers === "object" ? req.headers : {}; + const timeout = Number.isFinite(req.timeout) ? Number(req.timeout) : 20000; + const body = await patchApiAuthPayloadIfNeeded({ + url, + method, + headers, + body: req.data + }); + + return new Promise((resolve) => { + + const client = net.request({ + method, + url, + session: session.defaultSession + }); + + for (const [k, v] of Object.entries(headers)) { + if (v !== undefined && v !== null) { + client.setHeader(k, String(v)); + } + } + + const timer = setTimeout(() => { + try { + client.abort(); + } catch { + // ignore abort errors + } + resolve({ + ok: false, + error: `Timeout after ${timeout}ms` + }); + }, timeout); + + client.on("response", (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + res.on("end", () => { + clearTimeout(timer); + resolve({ + ok: true, + status: res.statusCode, + statusText: res.statusMessage, + responseText: Buffer.concat(chunks).toString("utf8"), + finalUrl: url, + headers: res.headers || {} + }); + }); + }); + + client.on("error", (error) => { + clearTimeout(timer); + resolve({ + ok: false, + error: String(error?.message || error) + }); + }); + + if (body !== undefined && body !== null) { + if (typeof body === "string" || Buffer.isBuffer(body)) { + client.write(body); + } else { + client.write(String(body)); + } + } + + client.end(); + }); + }); + + ipcMain.handle("elxmoj:gm-cookie-list", async (_event, details) => { + const input = details && typeof details === "object" ? details : {}; + const query = { + url: typeof input.url === "string" ? input.url : XMOJ_HOME, + name: typeof input.name === "string" ? input.name : undefined, + domain: typeof input.domain === "string" ? input.domain : undefined, + path: typeof input.path === "string" ? input.path : undefined, + secure: typeof input.secure === "boolean" ? input.secure : undefined, + session: typeof input.session === "boolean" ? input.session : undefined, + httpOnly: typeof input.httpOnly === "boolean" ? input.httpOnly : undefined + }; + + try { + const cookies = await session.defaultSession.cookies.get(query); + return cookies; + } catch { + return []; + } + }); + + ipcMain.handle("elxmoj:gm-cookie-set", async (_event, details) => { + const input = details && typeof details === "object" ? details : {}; + const cookie = { + url: typeof input.url === "string" ? input.url : XMOJ_HOME, + name: String(input.name || ""), + value: String(input.value || ""), + domain: typeof input.domain === "string" ? input.domain : undefined, + path: typeof input.path === "string" ? input.path : "/", + secure: typeof input.secure === "boolean" ? input.secure : undefined, + httpOnly: typeof input.httpOnly === "boolean" ? input.httpOnly : undefined, + sameSite: typeof input.sameSite === "string" ? input.sameSite : undefined, + expirationDate: typeof input.expirationDate === "number" ? input.expirationDate : undefined + }; + + if (!cookie.name) { + return { success: false, error: "GM_cookie.set requires cookie name" }; + } + + try { + await session.defaultSession.cookies.set(cookie); + return { success: true }; + } catch (error) { + const message = String(error?.message || error); + const isHttpOnlyConflict = message.includes("EXCLUDE_OVERWRITE_HTTP_ONLY"); + + return { + success: false, + ignored: isHttpOnlyConflict, + error: message, + code: isHttpOnlyConflict ? "EXCLUDE_OVERWRITE_HTTP_ONLY" : "SET_FAILED" + }; + } + }); + + ipcMain.handle("elxmoj:gm-cookie-delete", async (_event, details) => { + const input = details && typeof details === "object" ? details : {}; + const targetUrl = typeof input.url === "string" ? input.url : XMOJ_HOME; + const targetName = String(input.name || ""); + + if (!targetName) { + return { success: false, error: "GM_cookie.delete requires cookie name" }; + } + + try { + await session.defaultSession.cookies.remove(targetUrl, targetName); + return { success: true }; + } catch (error) { + return { + success: false, + error: String(error?.message || error), + code: "DELETE_FAILED" + }; + } + }); +} + +async function bootstrap() { + createMainMenu(); + registerIpcHandlers(); + createMainWindow(); + + const settings = await getSettings(); + await syncChannelFromScriptDebugMode(); + await ensureManagedScript(app, LOCAL_SCRIPT_PATH, getScriptBootstrapOptions()); + await runSelfCheck(process.argv.includes("--self-check")); + + if (settings.checkUpdateOnStartup) { + await checkForScriptUpdate({ showNoUpdateDialog: false }); + } +} + +app.whenReady().then(bootstrap); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } +}); diff --git a/src/preload.js b/src/preload.js new file mode 100644 index 0000000..628ab86 --- /dev/null +++ b/src/preload.js @@ -0,0 +1,1006 @@ +const { contextBridge, ipcRenderer } = require("electron"); +const { createHash } = require("node:crypto"); +const vm = require("node:vm"); + +const REQUIRE_FALLBACKS = { + "https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js": [ + "https://cdn.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.min.js", + "https://unpkg.com/crypto-js@4.1.1/crypto-js.js" + ], + "https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.2/purify.min.js": [ + "https://cdn.jsdelivr.net/npm/dompurify@3.0.2/dist/purify.min.js", + "https://unpkg.com/dompurify@3.0.2/dist/purify.min.js" + ], + "https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js": [ + "https://cdn.jsdelivr.net/npm/marked@4.3.0/marked.min.js", + "https://unpkg.com/marked@4.3.0/marked.min.js" + ], + "https://gitee.com/mirrors_google/diff-match-patch/raw/master/javascript/diff_match_patch_uncompressed.js": [ + "https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch_uncompressed.js", + "https://cdn.jsdelivr.net/gh/google/diff-match-patch@master/javascript/diff_match_patch_uncompressed.js", + "https://raw.githubusercontent.com/google/diff-match-patch/master/javascript/diff_match_patch_uncompressed.js" + ] +}; + +const ELXMOJ_INJECTION_LOCK_KEY = "__ELXMOJ_INJECTION_LOCK__"; +const ELXMOJ_RELOAD_GUARD_INSTALLED_KEY = "__ELXMOJ_RELOAD_GUARD_INSTALLED__"; +const ELXMOJ_RELOAD_LOG_KEY = "__ELXMOJ_RELOAD_LOG__"; +const ELXMOJ_BLOCK_NEXT_RELOAD_UNTIL_KEY = "__ELXMOJ_BLOCK_NEXT_RELOAD_UNTIL__"; +const ELXMOJ_COOKIE_SHIM_INSTALLED_KEY = "__ELXMOJ_COOKIE_SHIM_INSTALLED__"; +const ELXMOJ_SHADOW_PHPSESSID_KEY = "__ELXMOJ_SHADOW_PHPSESSID__"; +const ELXMOJ_SAVED_CREDENTIAL_KEY = "__ELXMOJ_SAVED_CREDENTIAL__"; +const ELXMOJ_TURNSTILE_BRIDGE_INSTALLED_KEY = "__ELXMOJ_TURNSTILE_BRIDGE_INSTALLED__"; + +function shouldInjectUserscriptInThisFrame() { + try { + return window.top === window.self; + } catch { + return false; + } +} + +function acquireInjectionLock() { + if (window[ELXMOJ_INJECTION_LOCK_KEY]) { + return false; + } + window[ELXMOJ_INJECTION_LOCK_KEY] = true; + return true; +} + +function installReloadLoopGuard() { + if (window[ELXMOJ_RELOAD_GUARD_INSTALLED_KEY]) { + return; + } + window[ELXMOJ_RELOAD_GUARD_INSTALLED_KEY] = true; + + const MAX_RELOADS_IN_WINDOW = 3; + const WINDOW_MS = 15000; + + const locationObject = window.location; + const locationProto = Object.getPrototypeOf(locationObject); + const originalReload = typeof locationObject.reload === "function" + ? locationObject.reload.bind(locationObject) + : null; + + if (!originalReload) { + return; + } + + const isReloadAllowed = () => { + try { + const now = Date.now(); + const blockedUntil = Number.parseInt(sessionStorage.getItem(ELXMOJ_BLOCK_NEXT_RELOAD_UNTIL_KEY) || "0", 10); + if (Number.isFinite(blockedUntil) && blockedUntil > now) { + return false; + } + + const raw = sessionStorage.getItem(ELXMOJ_RELOAD_LOG_KEY); + const parsed = JSON.parse(raw || "[]"); + const history = Array.isArray(parsed) ? parsed : []; + const recent = history.filter((ts) => Number.isFinite(ts) && now - ts <= WINDOW_MS); + + if (recent.length >= MAX_RELOADS_IN_WINDOW) { + return false; + } + + recent.push(now); + sessionStorage.setItem(ELXMOJ_RELOAD_LOG_KEY, JSON.stringify(recent)); + sessionStorage.removeItem(ELXMOJ_BLOCK_NEXT_RELOAD_UNTIL_KEY); + return true; + } catch { + return true; + } + }; + + const patchedReload = function patchedReload(...args) { + if (!isReloadAllowed()) { + console.warn("ELXMOJ blocked excessive page reload to prevent refresh loop."); + return; + } + + return originalReload(...args); + }; + + try { + locationObject.reload = patchedReload; + } catch { + // ignore when Location.reload is not writable on instance + } + + try { + if (locationProto && typeof locationProto.reload === "function") { + locationProto.reload = patchedReload; + } + } catch { + // ignore when Location.prototype.reload is not writable + } +} + +function blockNextReload(milliseconds = 8000) { + try { + const until = Date.now() + Math.max(1000, Number(milliseconds) || 0); + sessionStorage.setItem(ELXMOJ_BLOCK_NEXT_RELOAD_UNTIL_KEY, String(until)); + } catch { + // ignore storage failures + } +} + +function setShadowPhpSessionId(value) { + const normalized = String(value || "").trim(); + if (!normalized) { + return; + } + + try { + sessionStorage.setItem(ELXMOJ_SHADOW_PHPSESSID_KEY, normalized); + } catch { + // ignore storage failures + } +} + +function getShadowPhpSessionId() { + try { + return String(sessionStorage.getItem(ELXMOJ_SHADOW_PHPSESSID_KEY) || "").trim(); + } catch { + return ""; + } +} + +function installCookieVisibilityShim(initialPhpSessionId) { + if (window[ELXMOJ_COOKIE_SHIM_INSTALLED_KEY]) { + return; + } + + if (initialPhpSessionId) { + setShadowPhpSessionId(initialPhpSessionId); + } + + const docProto = Object.getPrototypeOf(document); + const descriptor = Object.getOwnPropertyDescriptor(docProto, "cookie"); + if (!descriptor || typeof descriptor.get !== "function" || typeof descriptor.set !== "function") { + return; + } + + const nativeGet = descriptor.get.bind(document); + const nativeSet = descriptor.set.bind(document); + + try { + Object.defineProperty(document, "cookie", { + configurable: true, + enumerable: true, + get() { + const raw = nativeGet() || ""; + if (/\bPHPSESSID=/i.test(raw)) { + return raw; + } + + const shadow = getShadowPhpSessionId(); + if (!shadow) { + return raw; + } + + return raw ? `${raw}; PHPSESSID=${shadow}` : `PHPSESSID=${shadow}`; + }, + set(value) { + nativeSet(value); + } + }); + window[ELXMOJ_COOKIE_SHIM_INSTALLED_KEY] = true; + } catch { + // ignore non-configurable cookie descriptor + } +} + +function createStoragePrefix() { + return "ELXMOJ_GM_"; +} + +function setupHexMd5Polyfill() { + if (typeof window.hex_md5 === "function") return; + + window.hex_md5 = (input) => { + if (window.CryptoJS && typeof window.CryptoJS.MD5 === "function") { + return window.CryptoJS.MD5(String(input ?? "")).toString(); + } + + return createHash("md5").update(String(input ?? ""), "utf8").digest("hex"); + }; +} + +function setupCryptoJsFallback() { + if (window.CryptoJS && typeof window.CryptoJS.MD5 === "function") { + return; + } + + window.CryptoJS = { + MD5: (input) => { + const hex = createHash("md5").update(String(input ?? ""), "utf8").digest("hex"); + return { + toString: () => hex + }; + } + }; +} + +function setupMarkedFallback() { + if (window.marked && typeof window.marked.parse === "function") { + return; + } + + const escapeHtml = (input) => + String(input ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + const parse = (markdown) => { + const text = String(markdown ?? ""); + return text + .split(/\r?\n\r?\n/) + .map((block) => `

${escapeHtml(block).replace(/\r?\n/g, "
")}

`) + .join("\n"); + }; + + window.marked = { + parse, + lexer: (markdown) => [{ type: "paragraph", text: String(markdown ?? "") }] + }; +} + +function setupDomPurifyFallback() { + if (window.DOMPurify && typeof window.DOMPurify.sanitize === "function") { + return; + } + + window.DOMPurify = { + sanitize: (input, options = {}) => { + const raw = String(input ?? ""); + + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(`
${raw}
`, "text/html"); + const wrapper = doc.body.firstElementChild; + if (!wrapper) { + return ""; + } + + const allowedTagsInput = Array.isArray(options.ALLOWED_TAGS) ? options.ALLOWED_TAGS : []; + const allowedAttrsInput = Array.isArray(options.ALLOWED_ATTR) ? options.ALLOWED_ATTR : []; + const allowedTags = new Set(allowedTagsInput.map((value) => String(value).toLowerCase())); + const allowedAttrs = new Set(allowedAttrsInput.map((value) => String(value).toLowerCase())); + + const sanitizeElement = (element) => { + for (const child of Array.from(element.children)) { + const tagName = child.tagName.toLowerCase(); + const tagAllowed = allowedTags.size === 0 || allowedTags.has(tagName); + + if (!tagAllowed) { + child.replaceWith(doc.createTextNode(child.textContent || "")); + continue; + } + + for (const attr of Array.from(child.attributes)) { + const attrName = attr.name.toLowerCase(); + const value = String(attr.value || ""); + const isEventAttr = attrName.startsWith("on"); + const isJavascriptUrl = + /^(href|src|xlink:href)$/i.test(attrName) && /^\s*javascript:/i.test(value); + const attrAllowed = allowedAttrs.size === 0 || allowedAttrs.has(attrName); + + if (isEventAttr || isJavascriptUrl || !attrAllowed) { + child.removeAttribute(attr.name); + } + } + + sanitizeElement(child); + } + }; + + sanitizeElement(wrapper); + return wrapper.innerHTML; + } catch { + return raw.replace(/[\s\S]*?<\/script>/gi, ""); + } + } + }; +} + +function setupTurnstileCallbackBridge() { + if (window[ELXMOJ_TURNSTILE_BRIDGE_INSTALLED_KEY]) { + return; + } + + window[ELXMOJ_TURNSTILE_BRIDGE_INSTALLED_KEY] = true; + + const callbackStore = new Map(); + let callbackCounter = 0; + + const toPlainObject = (value) => { + if (!value || typeof value !== "object") { + return {}; + } + + const result = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === "function") { + continue; + } + result[key] = item; + } + return result; + }; + + const bridgedTurnstile = { + render: (target, options = {}) => { + const safeOptions = toPlainObject(options); + const callbackId = `elxmoj_turnstile_cb_${Date.now()}_${++callbackCounter}`; + if (typeof options?.callback === "function") { + callbackStore.set(callbackId, options.callback); + } + + window.postMessage( + { + __ELXMOJ_TURNSTILE_RENDER__: true, + target, + options: safeOptions, + callbackId + }, + "*" + ); + + return callbackId; + }, + reset: (widgetId) => { + window.postMessage( + { + __ELXMOJ_TURNSTILE_RESET__: true, + widgetId + }, + "*" + ); + }, + remove: (widgetId) => { + window.postMessage( + { + __ELXMOJ_TURNSTILE_REMOVE__: true, + widgetId + }, + "*" + ); + } + }; + + if (!window.turnstile || typeof window.turnstile !== "object") { + window.turnstile = {}; + } + + if (typeof window.turnstile.render !== "function") { + window.turnstile.render = bridgedTurnstile.render; + } + if (typeof window.turnstile.reset !== "function") { + window.turnstile.reset = bridgedTurnstile.reset; + } + if (typeof window.turnstile.remove !== "function") { + window.turnstile.remove = bridgedTurnstile.remove; + } + + window.addEventListener("message", (event) => { + const payload = event.data; + if (!payload || typeof payload !== "object") { + return; + } + + if (payload.__ELXMOJ_TURNSTILE_CALLBACK__ === true) { + try { + if (typeof window.CaptchaLoadedCallback === "function") { + window.CaptchaLoadedCallback(...(payload.args || [])); + } + } catch (error) { + console.error("ELXMOJ turnstile callback bridge error:", error); + } + return; + } + + if (payload.__ELXMOJ_TURNSTILE_TOKEN__ === true) { + const callbackId = String(payload.callbackId || ""); + if (!callbackId) { + return; + } + + const callback = callbackStore.get(callbackId); + if (typeof callback === "function") { + try { + callback(String(payload.token || "")); + } catch (error) { + console.error("ELXMOJ turnstile token callback error:", error); + } + } + return; + } + + if (payload.__ELXMOJ_TURNSTILE_ERROR__ === true) { + console.warn("ELXMOJ turnstile page render failed:", payload.message || "unknown error"); + } + }); + + try { + const script = document.createElement("script"); + script.textContent = ` + (function () { + if (window.__ELXMOJ_TURNSTILE_PAGE_BRIDGE__ === true) { + return; + } + window.__ELXMOJ_TURNSTILE_PAGE_BRIDGE__ = true; + + var originalCaptchaLoadedCallback = + typeof window.CaptchaLoadedCallback === "function" ? window.CaptchaLoadedCallback : null; + window.CaptchaLoadedCallback = function () { + if (typeof originalCaptchaLoadedCallback === "function") { + try { + originalCaptchaLoadedCallback.apply(window, arguments); + } catch (error) { + console.error("ELXMOJ page original CaptchaLoadedCallback error:", error); + } + } + window.postMessage({ + __ELXMOJ_TURNSTILE_CALLBACK__: true, + args: Array.prototype.slice.call(arguments) + }, "*"); + }; + + var renderWithBridge = function (target, options, callbackId) { + if (!window.turnstile || typeof window.turnstile.render !== "function") { + return false; + } + + var finalOptions = options && typeof options === "object" ? Object.assign({}, options) : {}; + finalOptions.callback = function (token) { + window.postMessage( + { + __ELXMOJ_TURNSTILE_TOKEN__: true, + callbackId: callbackId, + token: String(token || "") + }, + "*" + ); + }; + + try { + window.turnstile.render(target, finalOptions); + return true; + } catch (error) { + window.postMessage( + { + __ELXMOJ_TURNSTILE_ERROR__: true, + message: String((error && error.message) || error || "turnstile.render failed") + }, + "*" + ); + return true; + } + }; + + window.addEventListener("message", function (event) { + var payload = event && event.data; + if (!payload || typeof payload !== "object") { + return; + } + + if (payload.__ELXMOJ_TURNSTILE_RENDER__ === true) { + if (!renderWithBridge(payload.target, payload.options, payload.callbackId)) { + window.setTimeout(function () { + renderWithBridge(payload.target, payload.options, payload.callbackId); + }, 150); + } + return; + } + + if (payload.__ELXMOJ_TURNSTILE_RESET__ === true) { + if (window.turnstile && typeof window.turnstile.reset === "function") { + try { + window.turnstile.reset(payload.widgetId); + } catch { + // ignore turnstile reset failures + } + } + return; + } + + if (payload.__ELXMOJ_TURNSTILE_REMOVE__ === true) { + if (window.turnstile && typeof window.turnstile.remove === "function") { + try { + window.turnstile.remove(payload.widgetId); + } catch { + // ignore turnstile remove failures + } + } + } + }); + })(); + `; + (document.documentElement || document.head || document.body).appendChild(script); + script.remove(); + } catch { + // ignore bridge injection failures + } +} + +function setupCredentialManagementFallback() { + const hasNativeCredentialApi = + typeof navigator !== "undefined" && + navigator.credentials && + typeof navigator.credentials.store === "function" && + typeof navigator.credentials.get === "function"; + + if (hasNativeCredentialApi && typeof window.PasswordCredential === "function") { + return; + } + + class LocalPasswordCredential { + constructor(init = {}) { + this.id = String(init.id || ""); + this.password = String(init.password || ""); + this.type = "password"; + } + } + + if (typeof window.PasswordCredential !== "function") { + window.PasswordCredential = LocalPasswordCredential; + } + + const readSaved = () => { + try { + const raw = localStorage.getItem(ELXMOJ_SAVED_CREDENTIAL_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + if (!parsed.id || !parsed.password) return null; + return { + id: String(parsed.id), + password: String(parsed.password) + }; + } catch { + return null; + } + }; + + const writeSaved = (credential) => { + try { + localStorage.setItem( + ELXMOJ_SAVED_CREDENTIAL_KEY, + JSON.stringify({ + id: String(credential?.id || ""), + password: String(credential?.password || "") + }) + ); + } catch { + // ignore storage failures + } + }; + + const clearSaved = () => { + try { + localStorage.removeItem(ELXMOJ_SAVED_CREDENTIAL_KEY); + } catch { + // ignore storage failures + } + }; + + const fallbackCredentials = { + async store(credential) { + if (!credential || !credential.id || !credential.password) { + return null; + } + writeSaved(credential); + return credential; + }, + async get(options = {}) { + const wantsPassword = Boolean(options && options.password); + if (!wantsPassword) { + return null; + } + + const saved = readSaved(); + if (!saved) { + return null; + } + + return new window.PasswordCredential(saved); + }, + async preventSilentAccess() { + clearSaved(); + } + }; + + try { + if (!navigator.credentials) { + Object.defineProperty(navigator, "credentials", { + configurable: true, + enumerable: true, + value: fallbackCredentials + }); + return; + } + } catch { + // ignore and fall through to method patching + } + + try { + if (typeof navigator.credentials.store !== "function") { + navigator.credentials.store = fallbackCredentials.store; + } + if (typeof navigator.credentials.get !== "function") { + navigator.credentials.get = fallbackCredentials.get; + } + if (typeof navigator.credentials.preventSilentAccess !== "function") { + navigator.credentials.preventSilentAccess = fallbackCredentials.preventSilentAccess; + } + } catch { + // ignore non-writable navigator.credentials object + } +} + +function setupGmPolyfills(payload = null) { + const prefix = createStoragePrefix(); + + window.unsafeWindow = window; + + window.GM_getValue = (key, defaultValue = null) => { + const raw = localStorage.getItem(`${prefix}${key}`); + if (raw === null || raw === undefined) return defaultValue; + try { + return JSON.parse(raw); + } catch { + return raw; + } + }; + + window.GM_setValue = (key, value) => { + localStorage.setItem(`${prefix}${key}`, JSON.stringify(value)); + }; + + window.GM_setClipboard = async (text) => { + await navigator.clipboard.writeText(String(text ?? "")); + }; + + window.GM_registerMenuCommand = () => { + // Electron 版本先使用应用菜单提供命令,不在页面内重复注册。 + }; + + window.GM_xmlhttpRequest = (details = {}) => { + let aborted = false; + + ipcRenderer + .invoke("elxmoj:gm-xhr", { + url: details.url, + method: details.method || "GET", + headers: details.headers || {}, + data: details.data, + timeout: details.timeout + }) + .then((result) => { + if (aborted) return; + + if (!result?.ok) { + if (typeof details.onerror === "function") { + details.onerror({ + error: String(result?.error || "Unknown request error"), + readyState: 4 + }); + } + return; + } + + if (typeof details.onload === "function") { + details.onload({ + status: result.status, + statusText: result.statusText, + responseText: result.responseText, + finalUrl: result.finalUrl || details.url, + responseHeaders: result.headers || {}, + readyState: 4 + }); + } + }) + .catch((error) => { + if (aborted) return; + if (typeof details.onerror === "function") { + details.onerror({ error: String(error?.message || error), readyState: 4 }); + } + }); + + return { + abort: () => { + aborted = true; + } + }; + }; + + window.GM_cookie = { + list: async (details = {}, callback) => { + try { + const cookies = await ipcRenderer.invoke("elxmoj:gm-cookie-list", details || {}); + if (typeof callback === "function") { + callback(cookies, null); + } + return cookies; + } catch (error) { + const errMessage = String(error?.message || error); + if (typeof callback === "function") { + callback([], errMessage); + } + return []; + } + }, + set: async (details = {}, callback) => { + try { + const result = await ipcRenderer.invoke("elxmoj:gm-cookie-set", details || {}); + if (!result?.success) { + const isHttpOnlyConflict = result?.code === "EXCLUDE_OVERWRITE_HTTP_ONLY" || result?.ignored === true; + const errMessage = String(result?.error || "GM_cookie.set failed"); + if (isHttpOnlyConflict) { + const isPhpSessid = String(details?.name || "").toUpperCase() === "PHPSESSID"; + if (isPhpSessid) { + setShadowPhpSessionId(details?.value); + blockNextReload(); + if (typeof callback === "function") { + callback(result, null); + } + throw new Error("PHPSESSID_HTTPONLY_CONFLICT"); + //这个conflict非常奇妙,会直接阻断刷新风暴TAT + } + if (typeof callback === "function") { + callback(result, null); + } + return result; + } + if (typeof callback === "function") { + callback(result, errMessage); + } + throw new Error(errMessage); + } + if (typeof callback === "function") { + callback(result, null); + } + return result; + } catch (error) { + const errMessage = String(error?.message || error); + if (typeof callback === "function") { + callback({ success: false, error: errMessage }, errMessage); + } + throw new Error(errMessage); + } + }, + delete: async (details = {}, callback) => { + try { + const result = await ipcRenderer.invoke("elxmoj:gm-cookie-delete", details || {}); + if (!result?.success) { + const errMessage = String(result?.error || "GM_cookie.delete failed"); + if (typeof callback === "function") { + callback(result, errMessage); + } + throw new Error(errMessage); + } + if (typeof callback === "function") { + callback(result, null); + } + return result; + } catch (error) { + const errMessage = String(error?.message || error); + if (typeof callback === "function") { + callback({ success: false, error: errMessage }, errMessage); + } + throw new Error(errMessage); + } + } + }; + + const gmApi = { + getValue: window.GM_getValue, + setValue: window.GM_setValue, + setClipboard: window.GM_setClipboard, + registerMenuCommand: window.GM_registerMenuCommand, + xmlHttpRequest: window.GM_xmlhttpRequest, + xmlhttpRequest: window.GM_xmlhttpRequest, + cookie: window.GM_cookie, + info: { + script: { + name: payload?.name || "XMOJ", + version: payload?.version || "0.0.0" + }, + scriptHandler: "ELXMOJ", + version: "1.0.0" + } + }; + + window.GM = gmApi; + window.GM_info = gmApi.info; +} + +async function loadRequireScripts(urls) { + for (const url of urls) { + await loadRequireScriptWithFallback(url); + } +} + +function getRequireCandidates(url) { + const fallbacks = REQUIRE_FALLBACKS[url] || []; + return [url, ...fallbacks]; +} + +function loadScriptTag(url) { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = url; + script.async = false; + script.onload = () => resolve(url); + script.onerror = () => reject(new Error(`Failed to load @require: ${url}`)); + document.head.appendChild(script); + }); +} + +function executeRequireScriptInCurrentContext(source, url) { + const code = String(source ?? ""); + if (!code.trim()) { + throw new Error(`Empty script body for @require: ${url}`); + } + + // Run @require in the same isolated world so globals like CodeMirror are visible to userscript. + const script = new vm.Script(`${code}\n//# sourceURL=${url}`, { + filename: url, + displayErrors: true + }); + script.runInThisContext(); +} + +function executeUserscriptInCurrentContext(source) { + const code = String(source ?? ""); + if (!code.trim()) { + throw new Error("Userscript payload is empty"); + } + + // Run userscript in preload (isolated world) so GM_* polyfills are available. + const script = new vm.Script(`${code}\n//# sourceURL=elxmoj-userscript.js`, { + filename: "elxmoj-userscript.js", + displayErrors: true + }); + script.runInThisContext(); +} + +async function loadScriptInCurrentContext(url) { + const result = await ipcRenderer.invoke("elxmoj:gm-xhr", { + url, + method: "GET", + timeout: 20000 + }); + + if (!result?.ok) { + throw new Error(`Failed to download @require script: ${String(result?.error || "unknown error")}`); + } + + executeRequireScriptInCurrentContext(result.responseText, url); +} + +async function loadRequireScriptWithFallback(url) { + const candidates = getRequireCandidates(url); + let lastError = null; + + for (const candidate of candidates) { + try { + await loadScriptInCurrentContext(candidate); + return; + } catch (error) { + lastError = error; + + // Final backup path: still try regular script tag in case a library must execute in page world. + try { + await loadScriptTag(candidate); + return; + } catch { + // ignore and continue to next candidate + } + } + } + + throw new Error( + `Failed to load @require after trying ${candidates.length} source(s): ${url}. Last error: ${String(lastError?.message || lastError)}` + ); +} + +async function injectUserscriptWhenReady() { + if (!shouldInjectUserscriptInThisFrame()) { + return; + } + + if (!location.hostname.endsWith("xmoj.tech") && location.hostname !== "116.62.212.172") { + return; + } + + if (!acquireInjectionLock()) { + return; + } + + try { + const settings = await ipcRenderer.invoke("elxmoj:get-settings"); + if (settings && settings.autoInjectUserscript === false) { + window.__ELXMOJ_INJECTION_STATUS__ = { + ok: false, + reason: "auto_inject_disabled" + }; + return; + } + + const payload = await ipcRenderer.invoke("elxmoj:get-script-payload"); + const phpSessionId = await ipcRenderer.invoke("elxmoj:get-phpsessid"); + setShadowPhpSessionId(phpSessionId); + + if (!payload || !payload.scriptText) { + window.__ELXMOJ_INJECTION_STATUS__ = { + ok: false, + reason: "script_payload_empty" + }; + return; + } + + setupGmPolyfills(payload); + setupCredentialManagementFallback(); + installReloadLoopGuard(); + installCookieVisibilityShim(String(phpSessionId || "")); + await loadRequireScripts(payload.requires || []); + setupCryptoJsFallback(); + setupMarkedFallback(); + setupDomPurifyFallback(); + setupTurnstileCallbackBridge(); + setupHexMd5Polyfill(); + + executeUserscriptInCurrentContext(payload.scriptText); + + window.__ELXMOJ_INJECTION_STATUS__ = { + ok: true, + version: payload.version, + loadedAt: Date.now(), + requireCount: (payload.requires || []).length + }; + } catch (error) { + console.error("ELXMOJ injection failed:", error); + window.__ELXMOJ_INJECTION_STATUS__ = { + ok: false, + reason: String(error?.message || error) + }; + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + injectUserscriptWhenReady(); + }); +} else { + injectUserscriptWhenReady(); +} + +function isTrustedPreloadContext() { + try { + // Only expose the ELXMOJ bridge to local app pages (e.g., settings UI), + // and not to remote web content loaded over http/https. + return window.location && window.location.protocol === "file:"; + } catch { + return false; + } +} + +if (isTrustedPreloadContext()) { + contextBridge.exposeInMainWorld("ELXMOJ", { + getSettings: () => ipcRenderer.invoke("elxmoj:get-settings"), + updateSettings: (patch) => ipcRenderer.invoke("elxmoj:update-settings", patch), + getScriptDebugMode: () => ipcRenderer.invoke("elxmoj:get-script-debug-mode"), + setScriptDebugMode: (enabled) => ipcRenderer.invoke("elxmoj:set-script-debug-mode", enabled), + syncChannelFromScriptDebug: () => ipcRenderer.invoke("elxmoj:sync-channel-from-script-debug"), + checkUpdate: () => ipcRenderer.invoke("elxmoj:check-update"), + runSelfCheck: () => ipcRenderer.invoke("elxmoj:run-self-check"), + getLastSelfCheck: () => ipcRenderer.invoke("elxmoj:get-last-self-check"), + getAppUpdateUrl: () => ipcRenderer.invoke("elxmoj:get-app-update-url"), + openAppUpdatePage: () => ipcRenderer.invoke("elxmoj:open-app-update-page") + }); +} diff --git a/src/settings.html b/src/settings.html new file mode 100644 index 0000000..d00b757 --- /dev/null +++ b/src/settings.html @@ -0,0 +1,206 @@ + + + + + + ELXMOJ 设置 + + + +
+
+

ELXMOJ 设置

+

管理脚本更新通道、启动行为,以及手动自检。

+ +
+
更新通道
+
+
+
脚本来源
+
正式版: xmoj-bbs.me,预览版: dev.xmoj-bbs.me
+
+ +
+
+ +
+
启动行为
+
+ +
+
+ +
+
+ +
+
工具
+
+ + +
+
+ +
+
下载地址: 加载中...
+
等待操作...
+
+ + +
+
+ + + + diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 0000000..6ead55c --- /dev/null +++ b/src/settings.js @@ -0,0 +1,88 @@ +async function loadSettings() { + await window.ELXMOJ.syncChannelFromScriptDebug(); + const settings = await window.ELXMOJ.getSettings(); + document.getElementById("channel").value = settings.channel || "stable"; + document.getElementById("checkUpdateOnStartup").checked = Boolean(settings.checkUpdateOnStartup); + document.getElementById("autoInjectUserscript").checked = Boolean(settings.autoInjectUserscript); + + const last = await window.ELXMOJ.getLastSelfCheck(); + if (last?.report) { + setStatus(`${last.report}\n\n时间: ${new Date(last.timestamp).toLocaleString()}`); + } +} + +function collectSettings() { + return { + channel: document.getElementById("channel").value, + checkUpdateOnStartup: document.getElementById("checkUpdateOnStartup").checked, + autoInjectUserscript: document.getElementById("autoInjectUserscript").checked + }; +} + +function setStatus(text) { + document.getElementById("status").textContent = text; +} + +async function saveSettings() { + try { + const patch = collectSettings(); + const next = await window.ELXMOJ.updateSettings(patch); + setStatus(`已保存\n通道: ${next.channel}\n启动更新检查: ${next.checkUpdateOnStartup}\n自动注入: ${next.autoInjectUserscript}`); + } catch (error) { + setStatus(`保存设置失败: ${String(error)}`); + } +} + +async function checkUpdateNow() { + setStatus("正在检查更新..."); + try { + const result = await window.ELXMOJ.checkUpdate(); + setStatus(`更新检查结果:\n${JSON.stringify(result, null, 2)}`); + } catch (error) { + setStatus(`检查更新失败: ${String(error)}`); + } +} + +async function openAppUpdatePage() { + setStatus("正在打开 App 更新下载页..."); + try { + const info = await window.ELXMOJ.openAppUpdatePage(); + setStatus(`已打开 App 更新下载页:\n${info.url}`); + } catch (error) { + setStatus(`打开 App 更新下载页失败: ${String(error)}`); + } +} + +async function showAppUpdateUrl() { + try { + const url = await window.ELXMOJ.getAppUpdateUrl(); + const hint = document.getElementById("appUpdateUrl"); + if (hint) { + hint.textContent = `下载地址: ${url}`; + } + } catch { + // Ignore optional hint failures + } +} + +async function runSelfCheckNow() { + setStatus("正在执行自检..."); + try { + const result = await window.ELXMOJ.runSelfCheck(); + setStatus(result.report || "自检完成"); + } catch (error) { + setStatus(`自检失败: ${String(error)}`); + } +} + +document.getElementById("btnSave").addEventListener("click", saveSettings); +document.getElementById("btnCheckUpdate").addEventListener("click", checkUpdateNow); +document.getElementById("btnSelfCheck").addEventListener("click", runSelfCheckNow); +document.getElementById("btnAppUpdate").addEventListener("click", openAppUpdatePage); +document.getElementById("btnClose").addEventListener("click", () => window.close()); + +loadSettings().catch((error) => { + setStatus(`加载设置失败: ${String(error)}`); +}); + +showAppUpdateUrl(); diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..4751ad0 --- /dev/null +++ b/src/storage.js @@ -0,0 +1,99 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); + +const DEFAULT_SETTINGS = { + channel: "stable", + checkUpdateOnStartup: true, + autoInjectUserscript: true, + skipVersionPrompt: "" +}; + +const APP_SCRIPT_NAME = "XMOJ.user.js"; +const SETTINGS_FILE_NAME = "settings.json"; + +function getPaths(app) { + const userDataDir = app.getPath("userData"); + return { + userDataDir, + settingsFile: path.join(userDataDir, SETTINGS_FILE_NAME), + managedScriptFile: path.join(userDataDir, APP_SCRIPT_NAME) + }; +} + +async function ensureDir(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); +} + +async function readJsonOrDefault(filePath, fallback) { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); + } catch { + return fallback; + } +} + +async function writeJson(filePath, value) { + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +async function loadSettings(app) { + const paths = getPaths(app); + await ensureDir(paths.userDataDir); + const saved = await readJsonOrDefault(paths.settingsFile, {}); + return { ...DEFAULT_SETTINGS, ...saved }; +} + +async function saveSettings(app, settings) { + const paths = getPaths(app); + await ensureDir(paths.userDataDir); + await writeJson(paths.settingsFile, settings); +} + +async function ensureManagedScript(app, localScriptPath, options = {}) { + const paths = getPaths(app); + await ensureDir(paths.userDataDir); + + try { + await fs.access(paths.managedScriptFile); + return paths.managedScriptFile; + } catch { + let initialContent = ""; + + if (typeof options.getInitialScriptContent === "function") { + try { + initialContent = String((await options.getInitialScriptContent()) || ""); + } catch { + initialContent = ""; + } + } + + if (!initialContent) { + initialContent = await fs.readFile(localScriptPath, "utf8"); + } + + await fs.writeFile(paths.managedScriptFile, initialContent, "utf8"); + return paths.managedScriptFile; + } +} + +async function readManagedScript(app, localScriptPath, options = {}) { + const managedPath = await ensureManagedScript(app, localScriptPath, options); + return fs.readFile(managedPath, "utf8"); +} + +async function writeManagedScript(app, content) { + const paths = getPaths(app); + await ensureDir(paths.userDataDir); + await fs.writeFile(paths.managedScriptFile, content, "utf8"); +} + +module.exports = { + DEFAULT_SETTINGS, + getPaths, + loadSettings, + saveSettings, + readManagedScript, + writeManagedScript, + ensureManagedScript +}; diff --git a/src/updater.js b/src/updater.js new file mode 100644 index 0000000..9d3aa7b --- /dev/null +++ b/src/updater.js @@ -0,0 +1,79 @@ +const https = require("node:https"); + +const STABLE_URL = "https://xmoj-bbs.me/XMOJ.user.js"; +const PREVIEW_URL = "https://dev.xmoj-bbs.me/XMOJ.user.js"; + +function getChannelUrl(channel) { + return channel === "preview" ? PREVIEW_URL : STABLE_URL; +} + +function downloadText(url) { + return new Promise((resolve, reject) => { + const req = https.get(url, { timeout: 15000 }, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} while downloading ${url}`)); + res.resume(); + return; + } + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); + + req.on("timeout", () => { + req.destroy(new Error(`Timeout while downloading ${url}`)); + }); + req.on("error", reject); + }); +} + +function extractMetaValue(scriptText, key) { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`^\\s*//\\s*@${escaped}\\s+(.+)$`, "m"); + const match = scriptText.match(regex); + return match ? match[1].trim() : ""; +} + +function extractVersion(scriptText) { + return extractMetaValue(scriptText, "version"); +} + +function extractName(scriptText) { + return extractMetaValue(scriptText, "name") || "XMOJ"; +} + +function extractRequires(scriptText) { + const lines = scriptText.split(/\r?\n/); + const list = []; + for (const line of lines) { + const match = line.match(/^\s*\/\/\s*@require\s+(.+)$/); + if (match) { + list.push(match[1].trim()); + } + } + return list; +} + +function isNewerVersion(currentVersion, remoteVersion) { + const c = currentVersion.split(".").map((n) => Number.parseInt(n, 10) || 0); + const r = remoteVersion.split(".").map((n) => Number.parseInt(n, 10) || 0); + const maxLen = Math.max(c.length, r.length); + for (let i = 0; i < maxLen; i += 1) { + const cv = c[i] ?? 0; + const rv = r[i] ?? 0; + if (rv > cv) return true; + if (rv < cv) return false; + } + return false; +} + +module.exports = { + STABLE_URL, + PREVIEW_URL, + getChannelUrl, + downloadText, + extractVersion, + extractName, + extractRequires, + isNewerVersion +};