yichael 1 settimana fa
parent
commit
a1f07dc80b

+ 4 - 0
.gitignore

@@ -32,3 +32,7 @@ Thumbs.db
 
 # Electron
 *.asar
+
+#undefined
+
+#dist

+ 6 - 2
doc/打包流程.md

@@ -4,6 +4,7 @@
 2. 项目根目录执行:`npm run build`,再执行 `npx electron-builder --win --arm64`(或 `--x64`)。
 3. 产物在 `dist\win-arm64-unpacked`(或 `dist\win-unpacked`)。
 4. 运行:`dist\win-arm64-unpacked\AndroidRemoteController.exe`。
+5. **发给他人的用法**:把整个 **`win-arm64-unpacked`** 文件夹打包发过去即可,开包即用。前端资源(`dist/index.html`、`dist/assets/`)已打进该目录下的 `resources\app.asar.unpacked\dist\`,exe 会从这里加载页面,无需再带项目里的 `dist`。
 
 ---
 
@@ -15,10 +16,10 @@
   打包输出目录。例如 `"dist"` 时,产物在 `dist\win-arm64-unpacked` 或 `dist\win-unpacked`。
 
 - **files**
-  打进 asar 的内容。当前为 `["dist/**","electron/**","configs/**","nodejs/**","python/**","lib/**"]`,不要漏掉运行时用到的目录。
+  打进 asar 的内容。当前为 `["dist/**","electron/**","configs/**","nodejs/**","python/**","lib/**"]`,不要漏掉运行时用到的目录。**不要**把 `static` 放进 files,static 不打包。
 
 - **asarUnpack**
-  不压进 asar、解出到 `app.asar.unpacked` 的目录。当前为 `nodejs/**`、`configs/**`、`lib/**`、`python/**`,主进程和 Node 脚本会从这里读
+  不压进 asar、解出到 `app.asar.unpacked` 的目录。当前为 **`dist/**`**、`nodejs/**`、`configs/**`、`lib/**`、`python/**`。`dist/**` 保证前端页面和 `assets`(JS/CSS)在 `win-arm64-unpacked\resources\app.asar.unpacked\dist\` 下,exe 从该路径加载,包可拷贝到任意电脑使用
 
 - **win.target**
   - `target: "dir"`:只输出目录,不打安装包。
@@ -37,6 +38,9 @@
 
 ### 目录与运行时
 
+- **static/**(沙盒目录,不打包)
+  应用数据与工作流等存放目录。**不**列入 `build.files`,打包后不会在 asar 内。运行时使用与 exe **同级**的 `static` 目录(即 `dist\win-arm64-unpacked\static`),首次运行会自动创建;主进程通过 `STATIC_ROOT` 环境变量传给子进程。
+
 - **lib/scrcpy-adb/**
   需存在 adb.exe、scrcpy.exe 及 scrcpy 依赖 dll/bat;会被 `files` 打进包,在 unpacked 中供主进程和 node 脚本调用。
 

+ 72 - 16
electron/main.js

@@ -6,17 +6,22 @@ const fs = require('fs')
 const unpackedRoot = app.isPackaged
   ? path.join(path.dirname(process.execPath), 'resources', 'app.asar.unpacked')
   : path.join(__dirname, '..')
-const config = require(path.join(unpackedRoot, 'configs', 'config.js'))
-const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
-
-const staticDir = path.join(unpackedRoot, 'static')
+// static 不打包,放在打包根目录(与 exe 同级)作为沙盒目录
+const sandboxRoot = app.isPackaged ? path.dirname(process.execPath) : path.join(__dirname, '..')
+const staticDir = path.join(sandboxRoot, 'static')
 if (!fs.existsSync(staticDir)) {
   fs.mkdirSync(staticDir, { recursive: true })
 }
 
-// 修复缓存权限:userData 与缓存目录设到临时目录,避免安装目录只读导致 "Unable to move the cache (0x5)"
+const config = require(path.join(unpackedRoot, 'configs', 'config.js'))
+const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
+
+// 打包后:userData 放在 exe 同目录 UserData,实现「开包即用」— 整包复制到任意电脑即可使用
+// 开发时:使用临时目录,避免污染项目
 if (process.platform === 'win32') {
-  const userDataPath = path.join(os.tmpdir(), 'AndroidRemoteController')
+  const userDataPath = app.isPackaged
+    ? path.join(sandboxRoot, 'UserData')
+    : path.join(os.tmpdir(), 'AndroidRemoteController')
   if (!fs.existsSync(userDataPath)) {
     fs.mkdirSync(userDataPath, { recursive: true })
   }
@@ -34,7 +39,19 @@ if (process.platform === 'win32') {
 // 保存主窗口引用,用于推送消息
 let mainWindowInstance = null
 
+// 启动日志:写入 userData/startup.log,便于排查“无 UI”问题
+const startupLogPath = path.join(app.getPath('userData'), 'startup.log')
+function startupLog (msg) {
+  const line = `[${new Date().toISOString()}] ${msg}\n`
+  try {
+    fs.appendFileSync(startupLogPath, line)
+  } catch (e) {}
+  console.log(msg)
+}
+
 function createWindow() {
+  startupLog(`createWindow: isPackaged=${app.isPackaged} isDev=${isDev} unpackedRoot=${unpackedRoot}`)
+
   const mainWindow = new BrowserWindow({
     width: config.window.width,
     height: config.window.height,
@@ -42,7 +59,7 @@ function createWindow() {
     webPreferences: {
       nodeIntegration: false,
       contextIsolation: true,
-      preload: path.join(__dirname, 'preload.js') 
+      preload: path.join(__dirname, 'preload.js')
     }
   })
 
@@ -52,16 +69,33 @@ function createWindow() {
   if (isDev) {
     const vitePort = config.vite?.port || 5173
     const viteHost = config.vite?.host || 'localhost'
-    console.log(`Loading Vite dev server at http://${viteHost}:${vitePort}`)
+    startupLog(`Loading Vite dev server at http://${viteHost}:${vitePort}`)
     mainWindow.loadURL(`http://${viteHost}:${vitePort}`)
-    
+
     // 根据配置文件决定是否打开调试侧边栏
     if (config.devTools.enabled) {
       mainWindow.webContents.openDevTools()
     }
   } else {
-    mainWindow.loadFile(path.join(unpackedRoot, 'dist', 'index.html'))
+    // 前端 dist(含 assets)通过 extraFiles 拷贝到 exe 同级 dist/,从沙盒根加载
+    const indexPath = path.join(sandboxRoot, 'dist', 'index.html')
+    const indexExists = fs.existsSync(indexPath)
+    startupLog(`Packaged load: index.html path=${indexPath} exists=${indexExists}`)
+    if (!indexExists) {
+      startupLog('ERROR: dist/index.html 不存在,请先 npm run build 再打包,并确认 extraFiles 含 dist')
+    }
+
+    mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
+      startupLog(`did-fail-load: mainFrame=${isMainFrame} code=${errorCode} desc=${errorDescription} url=${validatedURL}`)
+    })
+    mainWindow.webContents.on('did-finish-load', () => {
+      startupLog(`did-finish-load: url=${mainWindow.webContents.getURL()}`)
+    })
+
+    mainWindow.loadFile(indexPath)
   }
+
+  startupLog(`启动日志文件: ${startupLogPath}`)
 }
 
 const { spawn } = require('child_process')
@@ -82,9 +116,12 @@ ipcMain.handle('run-nodejs-script', async (event, scriptName, ...parameters) =>
       runningProcesses.delete(processKey)
     }
     
-    const nodeProcess = spawn('node', [scriptPath, ...parameters])
+    const nodeProcess = spawn('node', [scriptPath, ...parameters], {
+      env: { ...process.env, STATIC_ROOT: staticDir },
+      cwd: unpackedRoot
+    })
     runningProcesses.set(processKey, nodeProcess)
-    
+
     let stdout = ''
     let stderr = ''
     let resolved = false
@@ -166,7 +203,7 @@ ipcMain.handle('kill-nodejs-script', async (event, scriptName, ...parameters) =>
 
 /** 检查 scrcpy 是否仍在运行(读 pid 文件并用 process.kill(pid,0) 探测),用于用户直接关窗口时同步按钮状态 */
 ipcMain.handle('check-scrcpy-running', async () => {
-  const pidFile = path.join(unpackedRoot, 'static', 'scrcpy-pid.json')
+  const pidFile = path.join(staticDir, 'scrcpy-pid.json')
   if (!fs.existsSync(pidFile)) return { running: false }
   let pid
   try {
@@ -255,13 +292,16 @@ ipcMain.handle('ipc-request', async (event, channel, data) => {
         runningProcesses.delete(processKey)
       }
       
-      const nodeProcess = spawn('node', [scriptPath, ...Object.values(parameters)])
+      const nodeProcess = spawn('node', [scriptPath, ...Object.values(parameters)], {
+        env: { ...process.env, STATIC_ROOT: staticDir },
+        cwd: unpackedRoot
+      })
       runningProcesses.set(processKey, nodeProcess)
-      
+
       let stdout = ''
       let stderr = ''
       let resolved = false
-      
+
       nodeProcess.stdout.on('data', (chunk) => {
         stdout += chunk.toString()
         try {
@@ -326,6 +366,7 @@ function pushToFrontend(channel, data) {
 global.pushToFrontend = pushToFrontend
 
 app.whenReady().then(() => {
+  startupLog('========== 本次启动 ==========')
   createWindow()
 
   app.on('activate', () => {
@@ -335,6 +376,21 @@ app.whenReady().then(() => {
   })
 })
 
+// 退出时结束 adb.exe,避免残留在后台
+function killAdbOnExit() {
+  try {
+    if (process.platform === 'win32') {
+      require('child_process').execSync('taskkill /IM adb.exe /F', { stdio: 'ignore', windowsHide: true })
+    } else {
+      require('child_process').execSync('pkill -x adb || true', { stdio: 'ignore' })
+    }
+  } catch (e) {}
+}
+
+app.on('before-quit', () => {
+  killAdbOnExit()
+})
+
 app.on('window-all-closed', () => {
   if (process.platform !== 'darwin') {
     app.quit()

+ 2 - 1
nodejs/adb/screenshot.js

@@ -11,7 +11,8 @@ if (projectRoot.includes('app.asar') && !projectRoot.includes('app.asar.unpacked
 const adbPath = path.resolve(projectRoot, config.adbPath.path)
 const scrcpyDir = path.dirname(adbPath)
 const scrcpyPath = path.join(scrcpyDir, 'scrcpy.exe')
-const pidFile = path.join(projectRoot, 'static', 'scrcpy-pid.json')
+const staticRoot = process.env.STATIC_ROOT ? path.resolve(process.env.STATIC_ROOT) : path.join(projectRoot, 'static')
+const pidFile = path.join(staticRoot, 'scrcpy-pid.json')
 
 const action = process.argv[2]
 const pidArg = process.argv[3]

+ 3 - 1
nodejs/directory-parser.js

@@ -14,7 +14,9 @@ if (!operation || !relativePath) {
   process.exit(1)
 }
 
-const staticDir = path.resolve(__dirname, '..', 'static')
+const staticDir = process.env.STATIC_ROOT
+  ? path.resolve(process.env.STATIC_ROOT)
+  : path.resolve(__dirname, '..', 'static')
 const normalizedPath = path.normalize(relativePath).replace(/\\/g, '/')
 
 if (normalizedPath.includes('..') || normalizedPath.startsWith('/')) {

+ 3 - 1
nodejs/json-parser.js

@@ -15,7 +15,9 @@ if (!operation || !relativePath) {
   process.exit(1)
 }
 
-const staticDir = path.resolve(__dirname, '..', 'static')
+const staticDir = process.env.STATIC_ROOT
+  ? path.resolve(process.env.STATIC_ROOT)
+  : path.resolve(__dirname, '..', 'static')
 const normalizedPath = path.normalize(relativePath).replace(/\\/g, '/')
 if (normalizedPath.includes('..') || normalizedPath.startsWith('/')) {
   process.stdout.write(JSON.stringify({

+ 2 - 1
nodejs/run-process.js

@@ -12,6 +12,7 @@ const fs = require('fs')
 const { execSync } = require('child_process')
 
 const projectRoot = path.resolve(path.join(__dirname, '..'))
+const staticRoot = process.env.STATIC_ROOT ? path.resolve(process.env.STATIC_ROOT) : path.join(projectRoot, 'static')
 const config = require(path.join(projectRoot, 'configs', 'config.js'))
 const adbPath = config.adbPath?.path
   ? path.resolve(projectRoot, config.adbPath.path)
@@ -25,7 +26,7 @@ if (!scriptName) {
   process.exit(1)
 }
 
-const folderPath = path.resolve(path.join(__dirname, '..', 'static', 'process', scriptName))
+const folderPath = path.resolve(path.join(staticRoot, 'process', scriptName))
 
 function writeLog(folderPath, message) {
   const logFile = path.join(folderPath, 'log.txt')

+ 8 - 4
package.json

@@ -2,14 +2,17 @@
   "name": "electron-react-vite-app",
   "version": "1.0.0",
   "description": "A basic Electron + React + Vite application",
+  "author": "",
   "main": "electron/main.js",
   "build": {
     "appId": "com.electron.android-remote-controller",
     "productName": "AndroidRemoteController",
-    "directories": { "output": "build" },
-    "files": ["dist/**", "electron/**", "configs/**", "nodejs/**", "python/**", "lib/**"],
+    "directories": { "output": "dist" },
+    "files": ["electron/**", "configs/**", "nodejs/**", "python/**", "lib/**"],
     "asarUnpack": ["nodejs/**", "configs/**", "lib/**", "python/**"],
-    "win": { "target": [{ "target": "dir", "arch": ["x64"] }] }
+    "extraFiles": [{ "from": "dist", "to": "dist", "filter": ["index.html", "assets/**"] }],
+    "afterPack": "package/afterPack.js",
+    "win": { "target": [{ "target": "dir", "arch": ["arm64"] }] }
   },
   "scripts": {
     "dev": "vite",
@@ -17,7 +20,8 @@
     "preview": "vite preview",
     "electron": "electron .",
     "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:9527 && electron .\"",
-    "electron:build": "npm run build && electron-builder"
+    "electron:build": "npm run build && electron-builder && node -e \"require('./package/package-arm64.js').cleanDist('win-arm64-unpacked')\"",
+    "force-delete-dist": "node package/force-delete-dist.js"
   },
   "dependencies": {
     "react": "^18.2.0",

+ 24 - 0
package/afterPack.js

@@ -0,0 +1,24 @@
+/**
+ * electron-builder afterPack:若缺少 browser_v8_context_snapshot.bin,
+ * 则从 v8_context_snapshot.bin 复制一份,避免「缺少v8 startup snapshot文件」报错。
+ */
+const path = require('path')
+const fs = require('fs')
+
+function tryCopySnapshot(dir) {
+  const v8 = path.join(dir, 'v8_context_snapshot.bin')
+  const browser = path.join(dir, 'browser_v8_context_snapshot.bin')
+  if (fs.existsSync(v8) && !fs.existsSync(browser)) {
+    fs.copyFileSync(v8, browser)
+    console.log('[afterPack] 已复制 v8_context_snapshot.bin -> browser_v8_context_snapshot.bin')
+    return true
+  }
+  return false
+}
+
+module.exports = async function afterPack(context) {
+  if (context.electronPlatformName !== 'win32') return
+
+  const resourcesDir = path.join(context.appOutDir, 'resources')
+  tryCopySnapshot(resourcesDir) || tryCopySnapshot(context.appOutDir)
+}

+ 7 - 0
package/delete-dist.bat

@@ -0,0 +1,7 @@
+@echo off
+cd /d "%~dp0.."
+if not exist dist (echo dist not found & exit /b 0)
+echo Deleting dist...
+rmdir /s /q dist
+if exist dist (echo Failed. Close Explorer and any app using dist, then run this again.) else (echo dist deleted.)
+pause

+ 5 - 0
package/force-delete-dist.bat

@@ -0,0 +1,5 @@
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0.."
+node package/force-delete-dist.js
+if errorlevel 1 pause

+ 78 - 0
package/force-delete-dist.js

@@ -0,0 +1,78 @@
+#!/usr/bin/env node
+/**
+ * 强制删除项目下的 dist 目录(含长路径/占用时多轮重试)
+ */
+const fs = require('fs')
+const path = require('path')
+const { execSync } = require('child_process')
+
+const projectRoot = path.resolve(__dirname, '..')
+const distPath = path.join(projectRoot, 'dist')
+const maxRetries = 5
+const retryDelayMs = 1500
+
+function sleep(ms) {
+  return new Promise((r) => setTimeout(r, ms))
+}
+
+function deleteWithNode() {
+  try {
+    fs.rmSync(distPath, { recursive: true, force: true, maxRetries: 3 })
+    return true
+  } catch (e) {
+    return false
+  }
+}
+
+function deleteWithLongPath() {
+  if (process.platform !== 'win32') return false
+  try {
+    const longPath = '\\\\?\\' + path.resolve(projectRoot, 'dist').replace(/\//g, '\\')
+    fs.rmSync(longPath, { recursive: true, force: true, maxRetries: 3 })
+    return true
+  } catch (e) {
+    return false
+  }
+}
+
+function deleteWithCmd() {
+  try {
+    execSync('rmdir /s /q dist', { cwd: projectRoot, stdio: 'pipe', windowsHide: true })
+    return true
+  } catch (e) {
+    return false
+  }
+}
+
+async function main() {
+  if (!fs.existsSync(distPath)) {
+    console.log('dist 不存在,无需删除。')
+    process.exit(0)
+  }
+
+  console.log('正在强制删除 dist...')
+
+  for (let i = 0; i < maxRetries; i++) {
+    if (deleteWithNode()) {
+      console.log('已删除 dist(Node)。')
+      process.exit(0)
+    }
+    if (process.platform === 'win32' && deleteWithLongPath()) {
+      console.log('已删除 dist(长路径)。')
+      process.exit(0)
+    }
+    if (deleteWithCmd()) {
+      console.log('已删除 dist(cmd rmdir)。')
+      process.exit(0)
+    }
+    if (i < maxRetries - 1) {
+      console.log(`第 ${i + 1} 次未完全删除,${retryDelayMs / 1000} 秒后重试...`)
+      await sleep(retryDelayMs)
+    }
+  }
+
+  console.error('无法删除 dist,请关闭占用该目录的程序(如 Explorer、本应用)后重试,或手动删除。')
+  process.exit(1)
+}
+
+main()

+ 8 - 0
package/package-arm64.bat

@@ -0,0 +1,8 @@
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0.."
+node "%~dp0package-arm64.js"
+if errorlevel 1 (
+  echo Pack failed. Close AndroidRemoteController.exe if running, then retry.
+)
+pause

+ 96 - 0
package/package-arm64.js

@@ -0,0 +1,96 @@
+const { execSync, spawn } = require('child_process')
+const path = require('path')
+const fs = require('fs')
+
+function runElectronBuilder(cwd, env) {
+  const binName = process.platform === 'win32' ? 'electron-builder.cmd' : 'electron-builder'
+  const electronBuilderBin = path.join(cwd, 'node_modules', '.bin', binName)
+  if (!fs.existsSync(electronBuilderBin)) {
+    return Promise.reject(new Error('未找到 electron-builder,请先在项目根目录执行 npm install。'))
+  }
+  return new Promise((resolve, reject) => {
+    const args = ['--win', '--arm64']
+    const opts = {
+      cwd,
+      stdio: ['inherit', 'inherit', 'inherit'],
+      env: { ...process.env, ...env, DEBUG: 'electron-builder' },
+      windowsHide: false
+    }
+    if (process.platform === 'win32') {
+      opts.shell = true
+    }
+    const child = spawn(electronBuilderBin, args, opts)
+    let elapsed = 0
+    const progressInterval = setInterval(() => {
+      elapsed += 10
+      console.log(`  → 打包进行中... (已等待 ${elapsed} 秒,请勿关闭)`)
+    }, 10000)
+    child.on('close', (code) => {
+      clearInterval(progressInterval)
+      if (code !== 0) {
+        const err = new Error(`electron-builder 退出码: ${code}`)
+        err.exitCode = code
+        reject(err)
+      } else resolve()
+    })
+    child.on('error', (err) => {
+      clearInterval(progressInterval)
+      reject(err)
+    })
+  })
+}
+
+const projectRoot = path.resolve(__dirname, '..')
+const distDir = path.join(projectRoot, 'dist')
+
+function cleanDist(keepDir) {
+  if (!fs.existsSync(distDir)) return
+  for (const name of fs.readdirSync(distDir)) {
+    if (name === keepDir) continue
+    const full = path.join(distDir, name)
+    try {
+      if (fs.statSync(full).isDirectory()) fs.rmSync(full, { recursive: true })
+      else fs.unlinkSync(full)
+    } catch (e) {
+      console.warn('clean:', e.message)
+    }
+  }
+}
+
+async function main() {
+  const keepDir = 'win-arm64-unpacked'
+  try {
+    console.log('[1/3] Vite build...')
+    execSync('npm run build', { cwd: projectRoot, stdio: 'inherit' })
+  } catch (e) {
+    console.error('\n[错误] 第 1 步 Vite build 失败,已停止打包。')
+    console.error(e.message || e)
+    process.exit(1)
+  }
+  try {
+    console.log('[2/3] Electron builder win arm64...(每 10 秒会打印进度,请勿关闭)')
+    await runElectronBuilder(projectRoot, {})
+  } catch (e) {
+    console.error('\n[错误] 第 2 步 electron-builder 失败,已停止打包。')
+    console.error(e.message || e)
+    process.exit(1)
+  }
+  try {
+    console.log('[3/3] 清理 dist,只保留 win-arm64-unpacked...')
+    cleanDist(keepDir)
+    console.log('Done. Output: dist\\win-arm64-unpacked')
+  } catch (e) {
+    console.error('\n[错误] 第 3 步 清理 dist 失败。')
+    console.error(e.message || e)
+    process.exit(1)
+  }
+}
+
+if (require.main === module) {
+  main().catch((e) => {
+    console.error(e)
+    process.exit(1)
+  })
+} else {
+  module.exports = { cleanDist }
+}

+ 8 - 0
package/package-x64.bat

@@ -0,0 +1,8 @@
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0.."
+node "%~dp0package-x64.js"
+if errorlevel 1 (
+  echo Pack failed. Close AndroidRemoteController.exe if running, then retry.
+)
+pause

+ 127 - 0
package/package-x64.js

@@ -0,0 +1,127 @@
+const { execSync, spawn } = require('child_process')
+const path = require('path')
+const fs = require('fs')
+
+function runElectronBuilder(cwd) {
+  const binName = process.platform === 'win32' ? 'electron-builder.cmd' : 'electron-builder'
+  const electronBuilderBin = path.join(cwd, 'node_modules', '.bin', binName)
+  if (!fs.existsSync(electronBuilderBin)) {
+    return Promise.reject(new Error('未找到 electron-builder,请先在项目根目录执行 npm install。'))
+  }
+  const env = {
+    ...process.env,
+    DEBUG: 'electron-builder',
+    // 使用国内镜像,避免 x64 Electron 下载不完整导致「缺少v8 startup snapshot」
+    ELECTRON_MIRROR: process.env.ELECTRON_MIRROR || 'https://npmmirror.com/mirrors/electron/'
+  }
+  return new Promise((resolve, reject) => {
+    const args = ['--win', '--x64']
+    const opts = {
+      cwd,
+      stdio: ['inherit', 'inherit', 'inherit'],
+      env,
+      windowsHide: false
+    }
+    if (process.platform === 'win32') {
+      opts.shell = true
+    }
+    const child = spawn(electronBuilderBin, args, opts)
+    let elapsed = 0
+    const progressInterval = setInterval(() => {
+      elapsed += 10
+      console.log(`  → 打包进行中... (已等待 ${elapsed} 秒,请勿关闭)`)
+    }, 10000)
+    child.on('close', (code) => {
+      clearInterval(progressInterval)
+      if (code !== 0) {
+        const err = new Error(`electron-builder 退出码: ${code}`)
+        err.exitCode = code
+        reject(err)
+      } else resolve()
+    })
+    child.on('error', (err) => {
+      clearInterval(progressInterval)
+      reject(err)
+    })
+  })
+}
+
+const projectRoot = path.resolve(__dirname, '..')
+const distDir = path.join(projectRoot, 'dist')
+
+function cleanDist(keepDir) {
+  if (!fs.existsSync(distDir)) return
+  for (const name of fs.readdirSync(distDir)) {
+    if (name === keepDir) continue
+    const full = path.join(distDir, name)
+    try {
+      if (fs.statSync(full).isDirectory()) fs.rmSync(full, { recursive: true })
+      else fs.unlinkSync(full)
+    } catch (e) {
+      console.warn('clean:', e.message)
+    }
+  }
+}
+
+async function main() {
+  const keepDir = 'win-unpacked'
+  try {
+    console.log('[1/3] Vite build...')
+    execSync('npm run build', { cwd: projectRoot, stdio: 'inherit' })
+  } catch (e) {
+    console.error('\n[错误] 第 1 步 Vite build 失败,已停止打包。')
+    console.error(e.message || e)
+    process.exit(1)
+  }
+  try {
+    console.log('[2/3] Electron builder win x64...(每 10 秒会打印进度,请勿关闭)')
+    console.log('  若出现「缺少v8 startup snapshot」:请执行  rd /s /q "%LOCALAPPDATA%\\electron\\Cache"  后重试。')
+    await runElectronBuilder(projectRoot)
+  } catch (e) {
+    console.error('\n[错误] 第 2 步 electron-builder 失败,已停止打包。')
+    console.error(e.message || e)
+    if (/v8|snapshot|startup/i.test(String(e.message))) {
+      console.error('\n提示:可尝试清理 Electron 缓存后重试:')
+      console.error('  rd /s /q "%LOCALAPPDATA%\\electron\\Cache"')
+    }
+    process.exit(1)
+  }
+  try {
+    console.log('[3/3] 清理 dist,只保留 win-unpacked...')
+    cleanDist(keepDir)
+    const unpackedDir = path.join(distDir, keepDir)
+    const readmePath = path.join(unpackedDir, '使用说明.txt')
+    const readme = [
+      'AndroidRemoteController - 开包即用',
+      '',
+      '使用:直接双击运行 AndroidRemoteController.exe',
+      '首次运行会在本目录自动创建 static 文件夹(用于存放运行时数据)。',
+      '',
+      '分发:可将本「win-unpacked」整个文件夹复制到任意 Windows 电脑使用,无需安装。',
+      '请勿单独只复制 .exe,必须复制整个文件夹。',
+      ''
+    ].join('\r\n')
+    try {
+      fs.writeFileSync(readmePath, readme, 'utf8')
+      console.log('  已写入 使用说明.txt')
+    } catch (e) {
+      console.warn('  写入使用说明.txt 失败:', e.message)
+    }
+    console.log('')
+    console.log('Done. 输出目录: dist\\win-unpacked')
+    console.log('  可将该文件夹整体复制到任意 Windows 电脑,直接运行其中的 .exe 即可(开包即用)。')
+  } catch (e) {
+    console.error('\n[错误] 第 3 步 清理 dist 失败。')
+    console.error(e.message || e)
+    process.exit(1)
+  }
+}
+
+if (require.main === module) {
+  main().catch((e) => {
+    console.error(e)
+    process.exit(1)
+  })
+} else {
+  module.exports = { cleanDist }
+}

+ 22 - 8
src/index.jsx

@@ -1,21 +1,35 @@
+// 启动日志:便于在控制台/DevTools 查看“无 UI”原因
+console.log('[UI] 渲染进程脚本开始执行')
+
 import React from 'react'
 import ReactDOM from 'react-dom/client'
-import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
+import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'
 import Home from './page/home.jsx'
 
+// 打包后为 file:// 协议,BrowserRouter 的 pathname 是文件路径,路由不匹配会白屏;用 HashRouter 以 #/ 做路由
 function App() {
   return (
-    <BrowserRouter>
+    <HashRouter>
       <Routes>
         <Route path="/" element={<Navigate to="/page" replace />} />
         <Route path="/page" element={<Home />} />
       </Routes>
-    </BrowserRouter>
+    </HashRouter>
   )
 }
 
-ReactDOM.createRoot(document.getElementById('root')).render(
-  <React.StrictMode>
-    <App />
-  </React.StrictMode>
-)
+try {
+  const rootEl = document.getElementById('root')
+  if (!rootEl) {
+    console.error('[UI] 未找到 #root 节点')
+  } else {
+    ReactDOM.createRoot(rootEl).render(
+      <React.StrictMode>
+        <App />
+      </React.StrictMode>
+    )
+    console.log('[UI] React 已挂载')
+  }
+} catch (e) {
+  console.error('[UI] React 挂载失败:', e)
+}

+ 1 - 0
vite.config.mjs

@@ -16,6 +16,7 @@ const vitePort = config.vite?.port || 5173
 const viteHost = config.vite?.host || 'localhost'
 
 export default defineConfig({
+  base: './',
   plugins: [react()],
   server: {
     host: viteHost,