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, "
")}