From 35358f6cd09b49cc3801a5d5399a4a1aa9371a8d Mon Sep 17 00:00:00 2001 From: suu <46421931+0suu@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:17:37 +0900 Subject: [PATCH 1/2] Add auto-update support with electron-updater - Add electron-updater dependency and configure GitHub as publish provider - Implement auto-update check/download/install flow in main process - Expose autoUpdater API via preload bridge - Add update notification UI (download progress bar, restart prompt) - Add "Check for updates" button in Settings screen - Add i18n translations (EN/JA) for update-related strings - Add release workflow for Windows/macOS builds on tag push - Clean up ci.yml by removing unused packaging steps and Linux target Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 11 -- .github/workflows/release.yml | 45 ++++++ package-lock.json | 152 ++++++++++++++---- package.json | 15 +- src/main/main.ts | 80 ++++++++- src/preload/preload.ts | 48 +++++- src/renderer/src/App.tsx | 79 ++++++++- .../src/components/SettingsScreen.tsx | 47 +++++- src/renderer/src/global.d.ts | 12 +- src/renderer/src/i18n.ts | 20 +++ 10 files changed, 452 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e17c9c1..4d72664 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,14 +38,3 @@ jobs: - name: Build application run: npm run build - - name: Package application (Linux AppImage) - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') - run: npm run package -- --linux AppImage --dir - - # Optional: Upload build artifacts (e.g., the AppImage) - # - name: Upload Linux Build Artifact - # uses: actions/upload-artifact@v3 - # with: - # name: switchbot-client-linux-appimage - # path: release/*.AppImage # Adjust path as needed - # if-no-files-found: error # Optional: fail if no file is found diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..beb8af0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Build & Release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" # e.g. 1.2.0 + +permissions: + contents: write # needed to create GitHub Releases + +jobs: + release: + strategy: + matrix: + include: + - os: windows-latest + platform: win + - os: macos-latest + platform: mac + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build application + run: npm run build + + - name: Package & publish to GitHub Releases + run: npx electron-builder --${{ matrix.platform }} --publish always + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package-lock.json b/package-lock.json index cb2572b..f6faab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "axios": "^1.9.0", "crypto-js": "^4.2.0", "electron-store": "^4.0.0", + "electron-updater": "^6.8.3", "framer-motion": "^12.23.24", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -90,7 +91,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1009,6 +1009,7 @@ "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", "dev": true, "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1029,6 +1030,7 @@ "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", "dev": true, "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1044,6 +1046,7 @@ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dev": true, "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1057,6 +1060,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1126,7 +1130,6 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1167,7 +1170,6 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2255,7 +2257,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.5", @@ -3000,7 +3001,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3424,7 +3426,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3625,8 +3626,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { "version": "5.3.0", @@ -3915,7 +3915,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -4640,7 +4639,8 @@ "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -4950,7 +4950,6 @@ "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.0.12.tgz", "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -5028,7 +5027,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -5292,12 +5292,89 @@ "integrity": "sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==", "dev": true }, + "node_modules/electron-updater": { + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", + "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", "dev": true, "hasInstallScript": true, + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -5317,6 +5394,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6108,8 +6186,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/harmony-reflect": { "version": "1.6.2", @@ -6744,7 +6821,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7652,7 +7728,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -7836,8 +7911,7 @@ "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==" }, "node_modules/leven": { "version": "3.1.0", @@ -7956,6 +8030,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8012,6 +8099,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8840,7 +8928,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -8990,6 +9077,7 @@ "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", "dev": true, "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -9006,6 +9094,7 @@ "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", "dev": true, "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -9015,6 +9104,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9029,6 +9119,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -9040,7 +9131,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/proc-log": { "version": "2.0.1", @@ -9182,7 +9274,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9191,7 +9282,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -9209,7 +9299,6 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9463,8 +9552,7 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "peer": true + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -9729,8 +9817,7 @@ "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" }, "node_modules/saxes": { "version": "6.0.0", @@ -10269,6 +10356,7 @@ "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -10327,6 +10415,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -10340,6 +10429,7 @@ "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -10401,6 +10491,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -10634,7 +10730,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10796,7 +10891,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package.json b/package.json index 4c0ac53..6ea2b50 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "axios": "^1.9.0", "crypto-js": "^4.2.0", "electron-store": "^4.0.0", + "electron-updater": "^6.8.3", "framer-motion": "^12.23.24", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -70,6 +71,11 @@ "build": { "appId": "com.switchbot-client.app", "productName": "SwitchBot Client", + "publish": { + "provider": "github", + "owner": "0suu", + "repo": "SwitchBotController" + }, "files": [ "dist_electron/**/*", "dist_renderer/**/*", @@ -101,16 +107,11 @@ }, "mac": { "target": [ - "dmg" + "dmg", + "zip" ], "icon": "assets/icon.icns", "category": "public.utility" - }, - "linux": { - "target": [ - "AppImage" - ], - "icon": "assets/icon.png" } } } diff --git a/src/main/main.ts b/src/main/main.ts index cdf7a54..5f56a89 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3,6 +3,7 @@ import path from "path"; import Store from "electron-store"; import axios from "axios"; import crypto from "crypto"; +import { autoUpdater } from "electron-updater"; // Define a schema for your store if you want type safety and defaults interface AppSettings { @@ -66,7 +67,7 @@ const store = new Store({ }); -function createWindow() { +function createWindow(): BrowserWindow { const mainWindow = new BrowserWindow({ width: 1000, // Increased width a bit height: 700, // Increased height a bit @@ -87,12 +88,28 @@ function createWindow() { console.log("[Main] Loading renderer from:", rendererPath); mainWindow.loadFile(rendererPath); } + + return mainWindow; } app.whenReady().then(() => { - createWindow(); + const win = createWindow(); + setupAutoUpdater(win); + + // Check for updates after a short delay (skip in dev — app is not packaged) + if (process.env.NODE_ENV !== "development") { + setTimeout(() => { + autoUpdater.checkForUpdates().catch((err) => { + console.error("[AutoUpdater] Initial check failed:", err.message); + }); + }, 3000); + } + app.on("activate", function () { - if (BrowserWindow.getAllWindows().length === 0) createWindow(); + if (BrowserWindow.getAllWindows().length === 0) { + const newWin = createWindow(); + setupAutoUpdater(newWin); + } }); }); @@ -259,6 +276,63 @@ ipcMain.handle("switchbot-api-execute-scene", async (event, sceneId) => { } }); +// ── Auto-updater ──────────────────────────────────────────────────────── +autoUpdater.autoDownload = true; +autoUpdater.autoInstallOnAppQuit = true; + +function setupAutoUpdater(win: BrowserWindow) { + // Remove all previous listeners to avoid duplicates on window re-creation (macOS) + autoUpdater.removeAllListeners(); + + const send = (channel: string, ...args: any[]) => { + if (!win.isDestroyed()) { + win.webContents.send(channel, ...args); + } + }; + + autoUpdater.on("update-available", (info) => { + console.log("[AutoUpdater] Update available:", info.version); + send("update-available", { version: info.version }); + }); + + autoUpdater.on("update-not-available", () => { + console.log("[AutoUpdater] No update available."); + send("update-not-available"); + }); + + autoUpdater.on("download-progress", (progress) => { + console.log(`[AutoUpdater] Download progress: ${Math.round(progress.percent)}%`); + send("update-download-progress", { percent: progress.percent }); + }); + + autoUpdater.on("update-downloaded", (info) => { + console.log("[AutoUpdater] Update downloaded:", info.version); + send("update-downloaded", { version: info.version }); + }); + + autoUpdater.on("error", (err) => { + console.error("[AutoUpdater] Error:", err.message); + send("update-error", { message: err.message }); + }); +} + +ipcMain.handle("updater-check", async () => { + if (!app.isPackaged) { + return { success: false, error: "Update check is not available in development mode." }; + } + try { + const result = await autoUpdater.checkForUpdates(); + return { success: true, version: result?.updateInfo?.version }; + } catch (error: any) { + console.error("[AutoUpdater] Check failed:", error.message); + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("updater-install", () => { + autoUpdater.quitAndInstall(false, true); +}); + console.log("[Main] electron-store initialized. IPC handlers ready."); console.log("[Main] SwitchBot API IPC handlers ready."); console.log("[Main] Config file path:", store.path); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 8cf8522..e1ef69d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -37,11 +37,51 @@ const switchBotBridgeApi: SwitchBotBridgeAPI = { contextBridge.exposeInMainWorld("electronStore", electronStoreApi); contextBridge.exposeInMainWorld("switchBotBridge", switchBotBridgeApi); +// Auto-updater API +export interface AutoUpdaterAPI { + checkForUpdates: () => Promise<{ success: boolean; version?: string; error?: string }>; + installUpdate: () => Promise; + onUpdateAvailable: (callback: (info: { version: string }) => void) => () => void; + onUpdateDownloaded: (callback: (info: { version: string }) => void) => () => void; + onDownloadProgress: (callback: (info: { percent: number }) => void) => () => void; + onUpdateError: (callback: (info: { message: string }) => void) => () => void; + onUpdateNotAvailable: (callback: () => void) => () => void; +} + +const autoUpdaterApi: AutoUpdaterAPI = { + checkForUpdates: () => ipcRenderer.invoke("updater-check"), + installUpdate: () => ipcRenderer.invoke("updater-install"), + onUpdateAvailable: (callback) => { + const listener = (_event: any, info: { version: string }) => callback(info); + ipcRenderer.on("update-available", listener); + return () => { ipcRenderer.removeListener("update-available", listener); }; + }, + onUpdateDownloaded: (callback) => { + const listener = (_event: any, info: { version: string }) => callback(info); + ipcRenderer.on("update-downloaded", listener); + return () => { ipcRenderer.removeListener("update-downloaded", listener); }; + }, + onDownloadProgress: (callback) => { + const listener = (_event: any, info: { percent: number }) => callback(info); + ipcRenderer.on("update-download-progress", listener); + return () => { ipcRenderer.removeListener("update-download-progress", listener); }; + }, + onUpdateError: (callback) => { + const listener = (_event: any, info: { message: string }) => callback(info); + ipcRenderer.on("update-error", listener); + return () => { ipcRenderer.removeListener("update-error", listener); }; + }, + onUpdateNotAvailable: (callback) => { + const listener = () => callback(); + ipcRenderer.on("update-not-available", listener); + return () => { ipcRenderer.removeListener("update-not-available", listener); }; + }, +}; + +contextBridge.exposeInMainWorld("autoUpdater", autoUpdaterApi); + // Keep other exposed APIs if any (e.g., from previous preload setup) -contextBridge.exposeInMainWorld("electronAPI", { - // Example: send: (channel, data) => ipcRenderer.send(channel, data), - // receive: (channel, func) => ipcRenderer.on(channel, (event, ...args) => func(...args)) -}); +contextBridge.exposeInMainWorld("electronAPI", {}); console.log("Preload script: electronStore API exposed."); console.log("Preload script: switchBotBridge API exposed."); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fc57827..a79bcd0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from "react"; // Added useRef +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import { ThemeProvider, createTheme, responsiveFontSizes } from "@mui/material/styles"; import CssBaseline from "@mui/material/CssBaseline"; @@ -9,6 +9,9 @@ import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; import useMediaQuery from "@mui/material/useMediaQuery"; +import Snackbar from "@mui/material/Snackbar"; +import Alert from "@mui/material/Alert"; +import LinearProgress from "@mui/material/LinearProgress"; import { DeviceListScreen } from "./components/DeviceListScreen"; import { DeviceDetailScreen } from "./components/DeviceDetailScreen"; @@ -138,6 +141,45 @@ function App() { }, [dispatch, isTokenValid, pollingIntervalSetting, physicalDeviceCount]); // Re-run if these change + // ── Auto-updater state ───────────────────────────────────────────── + const [updateVersion, setUpdateVersion] = useState(null); + const [updateReady, setUpdateReady] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(null); + const [updateDismissed, setUpdateDismissed] = useState(false); + + useEffect(() => { + if (!window.autoUpdater) return; + + const unsubAvailable = window.autoUpdater.onUpdateAvailable((info) => { + setUpdateVersion(info.version); + setDownloadProgress(0); + setUpdateDismissed(false); + }); + const unsubProgress = window.autoUpdater.onDownloadProgress((info) => { + setDownloadProgress(info.percent); + }); + const unsubDownloaded = window.autoUpdater.onUpdateDownloaded((info) => { + setUpdateVersion(info.version); + setUpdateReady(true); + setDownloadProgress(null); + setUpdateDismissed(false); + }); + const unsubError = window.autoUpdater.onUpdateError(() => { + setDownloadProgress(null); + }); + + return () => { + unsubAvailable(); + unsubProgress(); + unsubDownloaded(); + unsubError(); + }; + }, []); + + const handleInstallUpdate = useCallback(() => { + window.autoUpdater?.installUpdate(); + }, []); + const navigateTo = (view: View) => { setCurrentView(view); }; const handleDeviceSelect = (deviceId: string) => { setSelectedDeviceId(deviceId); navigateTo("detail"); }; @@ -196,6 +238,41 @@ function App() { {renderView()} + + {/* Download progress bar */} + {downloadProgress !== null && ( + + + + {t("Update available").replace("{version}", updateVersion || "")} + + + + + )} + + {/* Update ready notification */} + + + + + + } + > + {t("Update ready").replace("{version}", updateVersion || "")} + + ); } diff --git a/src/renderer/src/components/SettingsScreen.tsx b/src/renderer/src/components/SettingsScreen.tsx index 81d628c..704133d 100644 --- a/src/renderer/src/components/SettingsScreen.tsx +++ b/src/renderer/src/components/SettingsScreen.tsx @@ -44,6 +44,26 @@ export const SettingsScreen: React.FC = () => { const [secretInput, setSecretInput] = useState(storedSecret || ""); const [isLoading, setIsLoading] = useState(false); const [pollingInput, setPollingInput] = useState(pollingInterval); + const [updateCheckStatus, setUpdateCheckStatus] = useState<"idle" | "checking" | "none" | "error">("idle"); + + // Listen for update-not-available / update-available to update the check button status + useEffect(() => { + if (!window.autoUpdater) return; + const unsubNotAvailable = window.autoUpdater.onUpdateNotAvailable(() => { + setUpdateCheckStatus("none"); + }); + const unsubAvailable = window.autoUpdater.onUpdateAvailable(() => { + setUpdateCheckStatus("idle"); + }); + const unsubError = window.autoUpdater.onUpdateError(() => { + setUpdateCheckStatus("error"); + }); + return () => { + unsubNotAvailable(); + unsubAvailable(); + unsubError(); + }; + }, []); useEffect(() => { // Load credentials from store when component mounts @@ -232,9 +252,34 @@ export const SettingsScreen: React.FC = () => { {t("App Info")} - + {t("App Version")}: {t("Footer Version")} + {window.autoUpdater && ( + + + {updateCheckStatus === "none" && ( + + {t("No updates available")} + + )} + {updateCheckStatus === "error" && ( + + {t("Update error")} + + )} + + )} diff --git a/src/renderer/src/global.d.ts b/src/renderer/src/global.d.ts index 7dc86f4..b0e420e 100644 --- a/src/renderer/src/global.d.ts +++ b/src/renderer/src/global.d.ts @@ -28,11 +28,21 @@ export interface SwitchBotBridgeAPI { executeScene: (sceneId: string) => Promise; } +export interface AutoUpdaterAPI { + checkForUpdates: () => Promise<{ success: boolean; version?: string; error?: string }>; + installUpdate: () => Promise; + onUpdateAvailable: (callback: (info: { version: string }) => void) => () => void; + onUpdateDownloaded: (callback: (info: { version: string }) => void) => () => void; + onDownloadProgress: (callback: (info: { percent: number }) => void) => () => void; + onUpdateError: (callback: (info: { message: string }) => void) => () => void; + onUpdateNotAvailable: (callback: () => void) => () => void; +} + declare global { interface Window { electronStore: ElectronStoreAPI; switchBotBridge: SwitchBotBridgeAPI; - // electronAPI: any; // If you exposed other things + autoUpdater: AutoUpdaterAPI; } } diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index 1cbc71f..858fb2c 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -150,6 +150,16 @@ const en: Dictionary = { "Sends directly to SwitchBot API. Keep parameters consistent with your device specification.": "Sends directly to SwitchBot API. Keep parameters consistent with your device specification.", + // Auto-update + "Update available": "A new version (v{version}) is available. Downloading...", + "Update ready": "Update v{version} is ready. Restart to apply.", + "Restart now": "Restart now", + "Later": "Later", + "Update error": "Update check failed.", + "Check for updates": "Check for updates", + "Checking for updates...": "Checking for updates...", + "No updates available": "You're on the latest version.", + // Scenes "Loading scenes...": "Loading scenes...", "No scenes found. Create a manual scene in the SwitchBot app.": @@ -305,6 +315,16 @@ const ja: Dictionary = { "Sends directly to SwitchBot API. Keep parameters consistent with your device specification.": "SwitchBot API に直接送信します。デバイス仕様に合わせてパラメータを設定してください。", + // Auto-update + "Update available": "新しいバージョン (v{version}) が見つかりました。ダウンロード中...", + "Update ready": "アップデート v{version} の準備ができました。再起動して適用します。", + "Restart now": "今すぐ再起動", + "Later": "あとで", + "Update error": "アップデートの確認に失敗しました。", + "Check for updates": "アップデートを確認", + "Checking for updates...": "アップデートを確認中...", + "No updates available": "最新バージョンです。", + // Scenes "Loading scenes...": "シーンを読み込み中...", "No scenes found. Create a manual scene in the SwitchBot app.": From 9d84f9e4b21034c1f8c29d74faa82a3bd964baab Mon Sep 17 00:00:00 2001 From: suu <46421931+0suu@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:32:21 +0900 Subject: [PATCH 2/2] Use app.isPackaged for update check consistency Replace process.env.NODE_ENV check with app.isPackaged to match the existing pattern in the updater-check IPC handler. Co-Authored-By: Claude Opus 4.6 --- src/main/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/main.ts b/src/main/main.ts index 5f56a89..27182d6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -97,7 +97,7 @@ app.whenReady().then(() => { setupAutoUpdater(win); // Check for updates after a short delay (skip in dev — app is not packaged) - if (process.env.NODE_ENV !== "development") { + if (app.isPackaged) { setTimeout(() => { autoUpdater.checkForUpdates().catch((err) => { console.error("[AutoUpdater] Initial check failed:", err.message);