diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f7e0ebe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release (Windows) + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build-windows-installer: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive version from tag + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + $version = $tag -replace '^v', '' + if ([string]::IsNullOrWhiteSpace($version)) { + throw "Invalid tag: '$tag' (expected like v1.0.0)" + } + "TAG_NAME=$tag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: | + frontend/package-lock.json + desktop/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install uv + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m pip install uv + + - name: Install frontend deps + working-directory: frontend + run: npm ci + + - name: Install desktop deps + working-directory: desktop + run: npm ci + + - name: Set desktop app version (electron-builder) + working-directory: desktop + shell: pwsh + run: | + npm version $env:VERSION --no-git-tag-version + + - name: Build Windows installer + working-directory: desktop + run: npm run dist + + - name: Create GitHub Release and upload installer + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.TAG_NAME }} + name: ${{ env.TAG_NAME }} + generate_release_notes: true + files: | + desktop/dist/*Setup*.exe + desktop/dist/*Setup*.exe.blockmap + diff --git a/desktop/package.json b/desktop/package.json index a798250..19bd486 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "build": { "appId": "com.lifearchive.wechatdataanalysis", "productName": "WeChatDataAnalysis", - "icon": "resources/icon.ico", + "icon": "build/icon.ico", "asar": true, "directories": { "output": "dist" @@ -34,15 +34,18 @@ } ], "win": { - "icon": "resources/icon.ico", + "icon": "build/icon.ico", "target": [ "nsis" ] }, "nsis": { - "installerIcon": "resources/icon.ico", - "uninstallerIcon": "resources/icon.ico", - "installerHeaderIcon": "resources/icon.ico" + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "include": "scripts/installer-custom.nsh", + "installerIcon": "build/installerIcon.ico", + "uninstallerIcon": "build/uninstallerIcon.ico", + "installerHeaderIcon": "build/installerHeaderIcon.ico" } }, "devDependencies": { diff --git a/desktop/scripts/build-icon.cjs b/desktop/scripts/build-icon.cjs index 498b57d..be84340 100644 --- a/desktop/scripts/build-icon.cjs +++ b/desktop/scripts/build-icon.cjs @@ -6,7 +6,15 @@ const { PNG } = require("pngjs"); const repoRoot = path.resolve(__dirname, "..", ".."); const srcPng = path.join(repoRoot, "frontend", "public", "logo.png"); -const dstIco = path.join(repoRoot, "desktop", "resources", "icon.ico"); +// Write the generated ICO to the locations electron-builder expects for app+installer icons. +// Also keep a copy in desktop/resources for convenience. +const dstIcos = [ + path.join(repoRoot, "desktop", "resources", "icon.ico"), + path.join(repoRoot, "desktop", "build", "icon.ico"), + path.join(repoRoot, "desktop", "build", "installerIcon.ico"), + path.join(repoRoot, "desktop", "build", "uninstallerIcon.ico"), + path.join(repoRoot, "desktop", "build", "installerHeaderIcon.ico"), +]; async function main() { if (!fs.existsSync(srcPng)) { @@ -35,11 +43,13 @@ async function main() { fs.writeFileSync(tmpPng, PNG.sync.write(square)); const buf = await pngToIco(tmpPng); - fs.mkdirSync(path.dirname(dstIco), { recursive: true }); - fs.writeFileSync(dstIco, buf); + for (const dstIco of dstIcos) { + fs.mkdirSync(path.dirname(dstIco), { recursive: true }); + fs.writeFileSync(dstIco, buf); + } // eslint-disable-next-line no-console - console.log(`Generated icon: ${dstIco}`); + console.log(`Generated icon(s):\n${dstIcos.map((p) => `- ${p}`).join("\n")}`); } main().catch((err) => { diff --git a/desktop/scripts/installer-custom.nsh b/desktop/scripts/installer-custom.nsh new file mode 100644 index 0000000..35ad4a5 --- /dev/null +++ b/desktop/scripts/installer-custom.nsh @@ -0,0 +1,80 @@ +; This file is included for both installer and uninstaller builds. +; Guard installer-only pages/functions to avoid "function not referenced" warnings +; when electron-builder compiles the standalone uninstaller. +!ifndef BUILD_UNINSTALLER +!include nsDialogs.nsh +!include LogicLib.nsh + +; Directory page is a "parent folder" picker. When users browse to a new folder, +; NSIS will set $INSTDIR to exactly what they pick (without app sub-folder), +; and electron-builder later appends "\${APP_FILENAME}" before installation. +; Make this explicit on the directory page to reduce confusion. +!define /ifndef MUI_DIRECTORYPAGE_TEXT_TOP "请选择安装位置(将自动创建并使用“${APP_FILENAME}”子文件夹)。" +!define /ifndef MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装位置:" + +Var WDA_InstallDirPage + +!macro customPageAfterChangeDir + ; Add a confirmation page after the directory picker so users clearly see + ; the final install location (includes the app sub-folder). + !ifdef allowToChangeInstallationDirectory + Page custom WDA_InstallDirPageCreate WDA_InstallDirPageLeave + !endif +!macroend + +Function WDA_EnsureAppSubDir + ; Normalize $INSTDIR to always end with "\${APP_FILENAME}" (avoid cluttering a parent folder). + StrCpy $0 "$INSTDIR" + + ; Trim trailing "\" (except for drive root like "C:\"). + StrLen $1 "$0" + ${If} $1 > 3 + StrCpy $2 "$0" 1 -1 + ${If} $2 == "\" + IntOp $1 $1 - 1 + StrCpy $0 "$0" $1 + ${EndIf} + ${EndIf} + + ; If already ends with APP_FILENAME, keep it. + StrLen $3 "$0" + StrLen $4 "${APP_FILENAME}" + ${If} $3 >= $4 + IntOp $5 $3 - $4 + StrCpy $6 "$0" $4 $5 + ${If} $6 == "${APP_FILENAME}" + StrCpy $INSTDIR "$0" + Return + ${EndIf} + ${EndIf} + + ; Otherwise append the app folder name. + StrCpy $INSTDIR "$0\${APP_FILENAME}" +FunctionEnd + +Function WDA_InstallDirPageCreate + Call WDA_EnsureAppSubDir + + nsDialogs::Create 1018 + Pop $WDA_InstallDirPage + + ${If} $WDA_InstallDirPage == error + Abort + ${EndIf} + + ${NSD_CreateLabel} 0u 0u 100% 24u "程序将安装到:" + Pop $0 + + ${NSD_CreateLabel} 0u 22u 100% 24u "$INSTDIR" + Pop $0 + + ${NSD_CreateLabel} 0u 50u 100% 36u "为避免把文件直接安装到父目录,安装程序会自动创建“${APP_FILENAME}”子文件夹。" + Pop $0 + + nsDialogs::Show +FunctionEnd + +Function WDA_InstallDirPageLeave +FunctionEnd + +!endif