diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..91dde20 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,86 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' # Trigger on version tags like v1.0.0 + +jobs: + build: + permissions: + contents: read + + strategy: + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install dependencies + run: npm install + + - name: Build for macOS + if: matrix.os == 'macos-latest' + run: npx electron-builder --mac + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build for Windows + if: matrix.os == 'windows-latest' + run: npx electron-builder --win + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build for Linux + if: matrix.os == 'ubuntu-latest' + run: npx electron-builder --linux + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.os }}-build + path: | + dist/*.dmg + dist/*.exe + dist/*.AppImage + dist/*.yml + + release: + needs: build + runs-on: ubuntu-latest + + permissions: + contents: write # Needed to create releases + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4.1.3 + with: + path: artifacts + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + artifacts/**/*.dmg + artifacts/**/*.exe + artifacts/**/*.AppImage + artifacts/**/*.yml + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 1f65473..5807a60 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A voice input method software built with Electron that allows you to dictate tex - **Real-time Audio Visualization**: Beautiful waveform animation while recording - **AI Transcription**: Uses Groq's Whisper API for accurate speech-to-text - **Auto-typing**: Automatically types transcribed text into active application (macOS) +- **Auto-update**: Automatically checks for updates and notifies when new versions are available - **Cross-platform**: Works on macOS, Windows, and Linux - **Background Operation**: Runs silently in the system tray - **Customizable Settings**: Configure API key, microphone, and languages @@ -50,6 +51,30 @@ npm run dev npm run build ``` +## Auto-Updates + +WhispLine includes automatic update functionality: + +- **Automatic Check**: On app startup, WhispLine automatically checks for new versions (in production builds only) +- **Manual Check**: Right-click the system tray icon and select "Check for Updates" +- **Update Process**: When an update is available, you'll be prompted to download it. Once downloaded, you can choose to install immediately or install on next app restart +- **GitHub Releases**: Updates are distributed via GitHub Releases. When building for release, use `npm run build` which will create distributable files compatible with the auto-updater + +**Note**: Auto-update is disabled in development mode (`npm run dev`). + +### Creating a Release + +To create a new release with auto-update support: + +1. Update the version in `package.json` (or use `npm version patch/minor/major`) +2. Create and push a git tag: + ```bash + git tag v1.0.75 + git push origin v1.0.75 + ``` +3. The GitHub Actions workflow will automatically build and publish the release +4. Users will be notified of the update on their next app launch + ## Console Character Encoding (Windows) On Windows, the console may display non-English characters as garbled text due to PowerShell/CMD output encoding settings. diff --git a/package-lock.json b/package-lock.json index c423507..5ce8b3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,12 @@ "": { "name": "whisp-line", "version": "1.0.74", - "license": "MIT", + "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "auto-launch": "^5.0.6", + "electron-log": "^5.4.3", "electron-store": "^10.1.0", + "electron-updater": "^6.7.3", "groq-sdk": "^0.30.0", "koffi": "^2.13.0", "openai": "^4.57.0", @@ -1295,7 +1297,6 @@ "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, "license": "Python-2.0" }, "node_modules/assert-plus": { @@ -2097,7 +2098,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2512,6 +2512,15 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-log": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz", + "integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/electron-publish": { "version": "26.0.11", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.0.11.tgz", @@ -2583,6 +2592,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/electron-updater": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.7.3.tgz", + "integrity": "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==", + "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.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "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", @@ -3162,7 +3247,6 @@ "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, "license": "ISC" }, "node_modules/groq-sdk": { @@ -3552,7 +3636,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3640,7 +3723,6 @@ "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, "license": "MIT" }, "node_modules/lodash": { @@ -3650,6 +3732,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/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -4725,7 +4820,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true, "license": "ISC" }, "node_modules/semver": { @@ -5193,6 +5287,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/tmp": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", diff --git a/package.json b/package.json index e818b1e..3527782 100644 --- a/package.json +++ b/package.json @@ -28,15 +28,24 @@ }, "dependencies": { "auto-launch": "^5.0.6", + "electron-log": "^5.4.3", "electron-store": "^10.1.0", + "electron-updater": "^6.7.3", "groq-sdk": "^0.30.0", + "koffi": "^2.13.0", "openai": "^4.57.0", - "uiohook-napi": "^1.5.4", - "koffi": "^2.13.0" + "uiohook-napi": "^1.5.4" }, "build": { "appId": "com.tao.whispline", "productName": "WhispLine", + "publish": [ + { + "provider": "github", + "owner": "hellotaotao", + "repo": "WhispLine" + } + ], "directories": { "output": "dist" }, diff --git a/src/auto-updater-service.js b/src/auto-updater-service.js new file mode 100644 index 0000000..db3b7ad --- /dev/null +++ b/src/auto-updater-service.js @@ -0,0 +1,150 @@ +const { autoUpdater } = require("electron-updater"); +const { dialog } = require("electron"); +const log = require("electron-log"); + +class AutoUpdaterService { + constructor() { + // Configure logging + log.transports.file.level = "info"; + autoUpdater.logger = log; + + // Configure auto-updater + autoUpdater.autoDownload = false; // Don't auto-download, ask user first + autoUpdater.autoInstallOnAppQuit = true; // Auto-install on quit after download + + this.updateAvailable = false; + this.updateDownloaded = false; + this.lastLoggedProgress = 0; // Track last logged progress percentage + + this.setupEventHandlers(); + } + + setupEventHandlers() { + autoUpdater.on("checking-for-update", () => { + log.info("Checking for updates..."); + }); + + autoUpdater.on("update-available", (info) => { + log.info("Update available:", info.version); + this.updateAvailable = true; + this.showUpdateAvailableDialog(info); + }); + + autoUpdater.on("update-not-available", (info) => { + log.info("Update not available:", info.version); + this.updateAvailable = false; + }); + + autoUpdater.on("error", (err) => { + log.error("Error in auto-updater:", err); + this.updateAvailable = false; + this.updateDownloaded = false; + }); + + autoUpdater.on("download-progress", (progressObj) => { + // Only log progress at 10% intervals to avoid excessive logging + const percent = Math.floor(progressObj.percent); + if (percent >= this.lastLoggedProgress + 10 || (percent === 100 && percent > this.lastLoggedProgress)) { + const logMessage = `Download speed: ${progressObj.bytesPerSecond} - Downloaded ${percent}% (${progressObj.transferred}/${progressObj.total})`; + log.info(logMessage); + this.lastLoggedProgress = percent; + } + }); + + autoUpdater.on("update-downloaded", (info) => { + log.info("Update downloaded:", info.version); + this.updateDownloaded = true; + this.lastLoggedProgress = 0; // Reset for next download + this.showUpdateDownloadedDialog(info); + }); + } + + showUpdateAvailableDialog(info) { + const detailLines = [ + "A new version of WhispLine is available. Would you like to download it now?", + "", + `Current version: ${autoUpdater.currentVersion}`, + `New version: ${info.version}` + ]; + + const dialogOpts = { + type: "info", + buttons: ["Download", "Later"], + title: "Update Available", + message: `Version ${info.version} is available`, + detail: detailLines.join("\n"), + }; + + dialog.showMessageBox(dialogOpts).then((returnValue) => { + if (returnValue.response === 0) { + // User clicked "Download" + autoUpdater.downloadUpdate(); + this.showDownloadingDialog(); + } + }); + } + + showDownloadingDialog() { + dialog.showMessageBox({ + type: "info", + buttons: ["OK"], + title: "Downloading Update", + message: "Update is being downloaded", + detail: "The update is being downloaded in the background. You'll be notified when it's ready to install.", + }); + } + + showUpdateDownloadedDialog(info) { + const dialogOpts = { + type: "info", + buttons: ["Restart Now", "Later"], + title: "Update Ready", + message: `Version ${info.version} has been downloaded`, + detail: "The update has been downloaded and is ready to install. Would you like to restart now?", + }; + + dialog.showMessageBox(dialogOpts).then((returnValue) => { + if (returnValue.response === 0) { + // User clicked "Restart Now" + autoUpdater.quitAndInstall(); + } + }); + } + + checkForUpdates() { + if (!this.updateDownloaded) { + autoUpdater.checkForUpdates().catch((err) => { + log.error("Failed to check for updates:", err); + dialog.showMessageBox({ + type: "error", + buttons: ["OK"], + title: "Update Check Failed", + message: "Failed to check for updates", + detail: err.message || "An error occurred while checking for updates. Please try again later.", + }); + }); + } else { + // Update already downloaded, show install dialog + dialog.showMessageBox({ + type: "info", + buttons: ["Restart Now", "Later"], + title: "Update Ready", + message: "An update has been downloaded", + detail: "Would you like to restart now to install the update?", + }).then((returnValue) => { + if (returnValue.response === 0) { + autoUpdater.quitAndInstall(); + } + }); + } + } + + checkForUpdatesQuietly() { + // Check for updates without showing dialogs for "no updates available" + autoUpdater.checkForUpdates().catch((err) => { + log.error("Failed to check for updates (silent):", err); + }); + } +} + +module.exports = AutoUpdaterService; diff --git a/src/main.js b/src/main.js index e4d10d3..0846df0 100644 --- a/src/main.js +++ b/src/main.js @@ -18,6 +18,7 @@ const { uIOhook, UiohookKey } = require("uiohook-napi"); const DatabaseManager = require("./database-manager"); const PermissionManager = require("./permission-manager"); const TranscriptionService = require("./services/transcription-service"); +const AutoUpdaterService = require("./auto-updater-service"); // Import platform-specific text inserters let windowsTextInserter = null; @@ -59,6 +60,15 @@ const db = new DatabaseManager(); const permissionManager = new PermissionManager(); const isDevelopment = process.env.NODE_ENV === 'development' || process.argv.includes('--dev'); +// Initialize auto-updater (only in production) +let autoUpdaterService = null; +if (!isDevelopment) { + autoUpdaterService = new AutoUpdaterService(); +} + +// Constants for auto-update +const UPDATE_CHECK_DELAY_MS = 5000; // Wait 5 seconds after startup before checking for updates + // Transcription service cache to avoid recreating clients let transcriptionServiceCache = new Map(); @@ -419,6 +429,24 @@ function createTray() { { type: "separator", }, + { + label: "Check for Updates", + click: () => { + if (autoUpdaterService) { + autoUpdaterService.checkForUpdates(); + } else { + dialog.showMessageBox({ + type: "info", + buttons: ["OK"], + title: "Development Mode", + message: "Auto-update is not available in development mode", + }); + } + }, + }, + { + type: "separator", + }, { label: "Quit", click: async () => { @@ -816,6 +844,14 @@ app.whenReady().then(async () => { permissionManager.requestInitialMicrophonePermission(); } + // Check for updates on startup (quietly, in production only) + if (autoUpdaterService) { + // Wait a bit before checking to let the app fully initialize + setTimeout(() => { + autoUpdaterService.checkForUpdatesQuietly(); + }, UPDATE_CHECK_DELAY_MS); + } + app.on("activate", () => { // On macOS, show or recreate main window when dock icon is clicked if (mainWindow) {