diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index a3e29608c..bd23fec09 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -134,7 +134,11 @@ jobs: - name: Build Windows (All Architectures) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run build && npx electron-builder --win --x64 --ia32 + run: npm run build && npx electron-builder --win --x64 --ia32 --publish never + + # The updater only installs the x64 NSIS build; 32-bit installs get a link. + - name: Fix Windows update files + run: node scripts/fix-update-info.cjs --drop ia32 release/latest.yml release/beta.yml release/alpha.yml - name: Upload Windows x64 NSIS Installer uses: actions/upload-artifact@v7 @@ -178,6 +182,17 @@ jobs: run: | Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip" + - name: Upload Windows update files + uses: actions/upload-artifact@v7 + if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none' + with: + name: termix_windows_update_meta + path: | + release/*.yml + release/termix_windows_x64_nsis.exe.blockmap + !release/builder-debug.yml + retention-days: 30 + - name: Upload Windows x64 Portable uses: actions/upload-artifact@v7 if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none' @@ -234,12 +249,12 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEBUG: electron-builder - run: npm run build && npx electron-builder --linux --x64 + run: npm run build && npx electron-builder --linux --x64 --publish never - name: Build Linux arm64 and armv7l env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx electron-builder --linux --arm64 --armv7l + run: npx electron-builder --linux --arm64 --armv7l --publish never - name: Rename Linux artifacts for consistency run: | @@ -255,6 +270,19 @@ jobs: cd .. + node scripts/fix-update-info.cjs \ + --rename termix_linux_amd64_deb.deb=termix_linux_x64_deb.deb \ + --rename termix_linux_x86_64_appimage.AppImage=termix_linux_x64_appimage.AppImage \ + release/*-linux*.yml + + - name: Upload Linux update files + uses: actions/upload-artifact@v7 + if: hashFiles('release/*-linux*.yml') != '' && inputs.artifact_destination != 'none' + with: + name: termix_linux_update_meta + path: release/*-linux*.yml + retention-days: 30 + - name: Upload Linux x64 AppImage uses: actions/upload-artifact@v7 if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none' @@ -494,7 +522,7 @@ jobs: run: | BUILD_VERSION="${{ github.run_number }}" - npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" + npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" --publish never - name: Clean up MAS keychain before DMG build if: steps.check_certs.outputs.has_certs == 'true' @@ -553,7 +581,24 @@ jobs: if [ "${{ steps.mas_build.outcome }}" != "success" ]; then npm run build fi - npx electron-builder --mac dmg --universal --x64 --arm64 --publish never + npx electron-builder --mac dmg zip --universal --x64 --arm64 --publish never + + # The updater picks the x64 or arm64 zip, so the universal one is left out. + - name: Fix macOS update files + run: node scripts/fix-update-info.cjs --drop universal release/*-mac.yml + + - name: Upload macOS update files + uses: actions/upload-artifact@v7 + if: hashFiles('release/*-mac.yml') != '' && inputs.artifact_destination != 'none' + with: + name: termix_macos_update_meta + path: | + release/*-mac.yml + release/termix_macos_x64_update.zip + release/termix_macos_x64_update.zip.blockmap + release/termix_macos_arm64_update.zip + release/termix_macos_arm64_update.zip.blockmap + retention-days: 30 - name: Verify macOS sharp packaging run: | @@ -771,144 +816,6 @@ jobs: path: choco-build/*.nupkg retention-days: 30 - submit-to-flatpak: - runs-on: blacksmith-8vcpu-ubuntu-2404 - if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') - needs: [] - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - ref: ${{ inputs.source_ref || github.ref }} - fetch-depth: 1 - - - name: Get version from package.json - id: package-version - run: | - VERSION=$(node -p "require('./package.json').version") - RELEASE_DATE=$(date +%Y-%m-%d) - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT - - - name: Download and prepare AppImage info from public release - id: appimage-info - run: | - VERSION="${{ steps.package-version.outputs.version }}" - mkdir -p release_assets - - APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage" - URL_X64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_X64_NAME" - PATH_X64="release_assets/$APPIMAGE_X64_NAME" - echo "Downloading x64 AppImage from $URL_X64" - curl -L -o "$PATH_X64" "$URL_X64" - chmod +x "$PATH_X64" - CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}') - - APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage" - URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_ARM64_NAME" - PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME" - echo "Downloading arm64 AppImage from $URL_ARM64" - curl -L -o "$PATH_ARM64" "$URL_ARM64" - chmod +x "$PATH_ARM64" - CHECKSUM_ARM64=$(sha256sum "$PATH_ARM64" | awk '{print $1}') - - echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT - echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT - echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT - echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y imagemagick - - - name: Prepare Flatpak submission files - run: | - VERSION="${{ steps.package-version.outputs.version }}" - CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}" - CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}" - RELEASE_DATE="${{ steps.package-version.outputs.release_date }}" - - mkdir -p flatpak-submission - - cp packaging/flatpak/com.karmaa.termix.yml flatpak-submission/ - cp packaging/flatpak/com.karmaa.termix.desktop flatpak-submission/ - cp packaging/flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/ - cp packaging/flatpak/flathub.json flatpak-submission/ - - cp public/icon.svg flatpak-submission/com.karmaa.termix.svg - convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png - convert public/icon.png -resize 128x128 flatpak-submission/icon-128.png - - sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.yml - sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" flatpak-submission/com.karmaa.termix.yml - sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" flatpak-submission/com.karmaa.termix.yml - - sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml - sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml - - - name: Upload Flatpak submission as artifact - uses: actions/upload-artifact@v7 - with: - name: flatpak-submission - path: flatpak-submission/* - retention-days: 30 - - - name: Check for Flathub token - id: check_flathub_token - run: | - if [ -n "${{ secrets.FLATHUB_TOKEN }}" ]; then - echo "has_token=true" >> $GITHUB_OUTPUT - fi - - - name: Open PR on Flathub repo - if: steps.check_flathub_token.outputs.has_token == 'true' - env: - GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }} - VERSION: ${{ steps.package-version.outputs.version }} - run: | - FLATHUB_REPO="flathub/com.karmaa.termix" - BRANCH="release-$VERSION" - - git config --global user.name "LukeGus" - git config --global user.email "bugattiguy527@gmail.com" - - git clone "https://x-access-token:${GH_TOKEN}@github.com/${FLATHUB_REPO}.git" flathub-repo - cd flathub-repo - - git checkout -b "$BRANCH" - - cp ../flatpak-submission/com.karmaa.termix.yml ./ - cp ../flatpak-submission/com.karmaa.termix.desktop ./ - cp ../flatpak-submission/com.karmaa.termix.metainfo.xml ./ - cp ../flatpak-submission/flathub.json ./ - cp ../flatpak-submission/com.karmaa.termix.svg ./ - cp ../flatpak-submission/icon-256.png ./ - cp ../flatpak-submission/icon-128.png ./ - - if git diff --quiet && git diff --cached --quiet; then - echo "No changes to submit for $VERSION; Flathub repo already up to date." - exit 0 - fi - - git add -A - git commit -m "Update to $VERSION" - git push origin "$BRANCH" - - EXISTING_PR=$(gh pr list --repo "$FLATHUB_REPO" --head "$BRANCH" --state open --json number -q '.[0].number' || true) - if [ -z "$EXISTING_PR" ]; then - gh pr create --repo "$FLATHUB_REPO" \ - --base master \ - --head "$BRANCH" \ - --title "Update to $VERSION" \ - --body "Automated release update to version $VERSION." - else - echo "PR #$EXISTING_PR already open for $BRANCH." - fi - submit-to-homebrew: runs-on: blacksmith-6vcpu-macos-latest if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos') @@ -1007,18 +914,15 @@ jobs: echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV fi + # Update files go up last: the desktop updater treats a release as ready + # once its update file is there, so every installer must already be up. - name: Upload artifacts to latest release run: | - cd artifacts - for dir in */; do - cd "$dir" - for file in *; - do - if [ -f "$file" ]; then - gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber - fi - done - cd .. + find artifacts -type f ! -name '*.yml' -print0 | while IFS= read -r -d '' file; do + gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber + done + find artifacts -type f -name '*.yml' -print0 | while IFS= read -r -d '' file; do + gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber done env: GH_TOKEN: ${{ github.token }} @@ -1188,7 +1092,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=4096 run: | BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}" - npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" + npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" --publish never - name: Verify macOS sharp packaging if: steps.check_certs.outputs.has_certs == 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f14d60f..d757b0f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Slovak, Croatian, Slovenian, Lithuanian, Latvian, Estonian and Malay +- The desktop app can update itself. Pick Automatic, Ask first or Never in Settings, Updates. Store and package manager installs keep updating the way they do now ### Changed diff --git a/Casks/termix.rb b/Casks/termix.rb index 8e7f8909c..738dcd8f8 100644 --- a/Casks/termix.rb +++ b/Casks/termix.rb @@ -12,6 +12,8 @@ cask "termix" do strategy :github_latest end + auto_updates true + app "Termix.app" zap trash: [ diff --git a/electron-builder.json b/electron-builder.json index 8d2cd7207..03c8467e3 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -1,7 +1,10 @@ { "appId": "com.karmaa.termix", "productName": "Termix", - "publish": null, + "publish": { + "provider": "generic", + "url": "https://github.com/Termix-SSH/Termix/releases/latest/download" + }, "directories": { "output": "release" }, @@ -114,8 +117,13 @@ { "target": "dmg", "arch": ["universal", "x64", "arm64"] + }, + { + "target": "zip", + "arch": ["x64", "arm64"] } ], + "artifactName": "termix_macos_${arch}_update.${ext}", "icon": "public/icon.icns", "category": "public.app-category.developer-tools", "hardenedRuntime": true, diff --git a/electron/auto-updater.cjs b/electron/auto-updater.cjs new file mode 100644 index 000000000..a2456e46e --- /dev/null +++ b/electron/auto-updater.cjs @@ -0,0 +1,323 @@ +// Desktop self updates. The release is picked from the GitHub releases list +// instead of electron-updater's GitHub provider, because the repo also holds +// SDK and 2.9.x releases, and a new release exists a while before its files +// finish uploading. The channel file is uploaded last, so a release counts +// only once it has one for this platform. + +const path = require("path"); + +const REPO = "Termix-SSH/Termix"; +const RELEASES_API = `https://api.github.com/repos/${REPO}/releases?per_page=30`; +const DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/`; +const MODES = ["automatic", "prompt", "never"]; +const DEFAULT_MODE = "prompt"; +const FIRST_CHECK_DELAY = 30 * 1000; +const CHECK_INTERVAL = 6 * 60 * 60 * 1000; +const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/; + +function parseVersion(value) { + const match = VERSION.exec(String(value || "").trim()); + if (!match) return null; + return { + parts: match.slice(1, 4).map(Number), + beta: match[4] == null ? null : Number(match[4]), + }; +} + +// A beta sorts before the release it leads up to. +function compareVersions(a, b) { + for (let i = 0; i < 3; i++) { + if (a.parts[i] !== b.parts[i]) return a.parts[i] - b.parts[i]; + } + if (a.beta === b.beta) return 0; + if (a.beta == null) return 1; + if (b.beta == null) return -1; + return a.beta - b.beta; +} + +// Matches the names electron-builder writes and electron-updater reads. +function channelFile(channel, platform, arch) { + if (platform === "win32") return `${channel}.yml`; + if (platform === "darwin") return `${channel}-mac.yml`; + return `${channel}-linux${arch === "x64" ? "" : `-${arch}`}.yml`; +} + +/** + * The newest release this install should move to, or null. Stable installs + * only see stable releases; a beta install also sees newer betas. + */ +function resolveRelease(releases, { currentVersion, platform, arch }) { + const current = parseVersion(currentVersion); + if (!current || !Array.isArray(releases)) return null; + const allowBeta = current.beta != null; + + const newest = releases + .filter((release) => release && !release.draft) + .map((release) => ({ release, version: parseVersion(release.tag_name) })) + .filter( + ({ release, version }) => + version && + release.tag_name.startsWith("v") && + (allowBeta || version.beta == null) && + compareVersions(version, current) > 0, + ) + .sort((a, b) => compareVersions(b.version, a.version))[0]; + if (!newest) return null; + + const { release, version } = newest; + const channel = version.beta == null ? "latest" : "beta"; + const file = channelFile(channel, platform, arch); + const assets = Array.isArray(release.assets) ? release.assets : []; + if (!assets.some((asset) => asset && asset.name === file)) return null; + + return { + tag: release.tag_name, + version: release.tag_name.slice(1), + channel, + feedUrl: `${DOWNLOAD_BASE}${release.tag_name}`, + releaseUrl: + release.html_url || + `https://github.com/${REPO}/releases/tag/${release.tag_name}`, + }; +} + +/** + * How this copy of the app was installed, which decides whether it can + * replace itself: + * - auto: NSIS, DMG, AppImage or deb, updated in place + * - manual: MSI, Chocolatey, Flatpak, portable builds and 32-bit Windows, told where to download + * - store: Mac App Store + * - disabled: dev builds + */ +function getInstallKind({ + platform, + arch, + isPackaged, + mas, + env, + execPath, + resourcesPath, + exists, + readFile, +}) { + if (!isPackaged) return "disabled"; + if (mas) return "store"; + + if (platform === "linux") { + if (env.FLATPAK_ID || exists("/.flatpak-info")) return "manual"; + if (env.APPIMAGE) return "auto"; + try { + const type = readFile(path.join(resourcesPath, "package-type")).trim(); + // The AUR package reuses the deb's files, so ask dpkg if it owns us. + if (type === "deb" && exists("/var/lib/dpkg/info/termix.list")) { + return "auto"; + } + } catch { + // Not a package install. + } + return "manual"; + } + + if (platform === "win32") { + if (arch !== "x64") return "manual"; + const uninstaller = path.join( + path.dirname(execPath), + "Uninstall Termix.exe", + ); + return exists(uninstaller) ? "auto" : "manual"; + } + + if (platform === "darwin") { + // Still running from the mounted DMG. + return execPath.startsWith("/Volumes/") ? "manual" : "auto"; + } + + return "manual"; +} + +function normalizeMode(mode) { + return MODES.includes(mode) ? mode : DEFAULT_MODE; +} + +function createUpdater({ + currentVersion, + platform, + arch, + kind, + getMode, + saveMode, + fetchReleases, + loadAutoUpdater, + send, + log, + beforeInstall, +}) { + const state = { + kind, + mode: normalizeMode(getMode()), + currentVersion, + status: "idle", + version: null, + releaseUrl: null, + progress: null, + error: null, + }; + const enabled = kind === "auto" || kind === "manual"; + let updater = null; + let timer = null; + let checkedOnce = false; + + function update(patch) { + Object.assign(state, patch); + send({ ...state }); + } + + function getUpdater() { + if (updater) return updater; + updater = loadAutoUpdater(); + updater.logger = { + info: (...args) => log("[updater]", ...args), + warn: (...args) => log("[updater]", ...args), + error: (...args) => log("[updater]", ...args), + debug: () => {}, + }; + updater.autoInstallOnAppQuit = true; + updater.on("update-available", (info) => + update({ + status: updater.autoDownload ? "downloading" : "available", + version: info.version, + progress: null, + error: null, + }), + ); + updater.on("update-not-available", () => + update({ status: "up-to-date", error: null }), + ); + updater.on("download-progress", (progress) => + update({ + status: "downloading", + progress: Math.round(progress.percent || 0), + }), + ); + updater.on("update-downloaded", (info) => + update({ + status: "downloaded", + version: info.version, + progress: 100, + error: null, + }), + ); + updater.on("error", (error) => { + log("[updater] error:", error?.message || String(error)); + update({ status: "error", error: error?.message || String(error) }); + }); + return updater; + } + + async function check() { + if (!enabled) return { ...state }; + if (["checking", "downloading", "downloaded"].includes(state.status)) { + return { ...state }; + } + checkedOnce = true; + update({ status: "checking", error: null }); + + let release; + try { + release = resolveRelease(await fetchReleases(), { + currentVersion, + platform, + arch, + }); + } catch (error) { + log("[updater] release lookup failed:", error?.message || String(error)); + update({ status: "error", error: error?.message || String(error) }); + return { ...state }; + } + + if (!release) { + update({ status: "up-to-date" }); + return { ...state }; + } + + update({ version: release.version, releaseUrl: release.releaseUrl }); + if (kind === "manual") { + update({ status: "available" }); + return { ...state }; + } + + try { + const auto = getUpdater(); + auto.setFeedURL({ provider: "generic", url: release.feedUrl }); + auto.channel = release.channel; + auto.allowDowngrade = false; + auto.autoDownload = state.mode === "automatic"; + await auto.checkForUpdates(); + } catch (error) { + log("[updater] check failed:", error?.message || String(error)); + update({ status: "error", error: error?.message || String(error) }); + } + return { ...state }; + } + + async function download() { + if (kind !== "auto" || state.status !== "available") return { ...state }; + update({ status: "downloading", progress: 0 }); + try { + await getUpdater().downloadUpdate(); + } catch (error) { + log("[updater] download failed:", error?.message || String(error)); + update({ status: "error", error: error?.message || String(error) }); + } + return { ...state }; + } + + function install() { + if (kind !== "auto" || state.status !== "downloaded") return false; + // The tray keeps windows alive on close, so mark the quit first. + beforeInstall?.(); + setImmediate(() => getUpdater().quitAndInstall()); + return true; + } + + function setMode(mode) { + if (!MODES.includes(mode)) return { ...state }; + saveMode(mode); + update({ mode }); + if (updater) updater.autoDownload = mode === "automatic"; + if (mode !== "never" && !checkedOnce) void check(); + return { ...state }; + } + + function start() { + if (!enabled || timer) return; + setTimeout(() => { + if (state.mode !== "never") void check(); + }, FIRST_CHECK_DELAY).unref?.(); + timer = setInterval(() => { + if (state.mode !== "never") void check(); + }, CHECK_INTERVAL); + timer.unref?.(); + } + + return { + getState: () => ({ ...state }), + check, + download, + install, + setMode, + start, + }; +} + +module.exports = { + DEFAULT_MODE, + RELEASES_API, + channelFile, + compareVersions, + createUpdater, + getInstallKind, + normalizeMode, + parseVersion, + resolveRelease, +}; diff --git a/electron/desktop-settings.cjs b/electron/desktop-settings.cjs index 4c9590793..144b40ed1 100644 --- a/electron/desktop-settings.cjs +++ b/electron/desktop-settings.cjs @@ -32,7 +32,7 @@ function writeJson(filePath, value) { } } -const DEFAULTS = { defaultConnectionOrigin: "local" }; +const DEFAULTS = { defaultConnectionOrigin: "local", updateMode: "prompt" }; function getDesktopSettings() { return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) }; diff --git a/electron/main.cjs b/electron/main.cjs index 05ebbdd1f..b7e8512bf 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -29,6 +29,7 @@ const pty = require("node-pty"); const WebSocket = require("ws"); const linkedServer = require("./linked-server.cjs"); const desktopSettings = require("./desktop-settings.cjs"); +const autoUpdates = require("./auto-updater.cjs"); const { launchNativeRdp } = require("./native-rdp.cjs"); const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs"); const { quitApp } = require("./app-quit.cjs"); @@ -1752,6 +1753,65 @@ ipcMain.handle("save-desktop-settings", (_event, settings) => { return desktopSettings.saveDesktopSettings(settings); }); +let appUpdater = null; + +function startAppUpdater() { + const kind = autoUpdates.getInstallKind({ + platform: process.platform, + arch: process.arch, + isPackaged: app.isPackaged, + mas: !!process.mas, + env: process.env, + execPath: process.execPath, + resourcesPath: process.resourcesPath, + exists: (target) => fs.existsSync(target), + readFile: (target) => fs.readFileSync(target, "utf8"), + }); + logToFile("Updater install kind:", kind); + appUpdater = autoUpdates.createUpdater({ + currentVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + kind, + getMode: () => desktopSettings.getDesktopSettings().updateMode, + saveMode: (mode) => + desktopSettings.saveDesktopSettings({ updateMode: mode }), + fetchReleases: async () => { + const { net } = require("electron"); + const response = await net.fetch(autoUpdates.RELEASES_API, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!response.ok) { + throw new Error(`GitHub releases returned ${response.status}`); + } + return response.json(); + }, + loadAutoUpdater: () => require("electron-updater").autoUpdater, + send: (state) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("desktop-update-state", state); + } + }, + log: logToFile, + beforeInstall: () => { + isQuitting = true; + }, + }); + appUpdater.start(); +} + +ipcMain.handle( + "desktop-update-get-state", + () => appUpdater?.getState() ?? null, +); +ipcMain.handle("desktop-update-check", () => appUpdater?.check() ?? null); +ipcMain.handle("desktop-update-download", () => appUpdater?.download() ?? null); +ipcMain.handle("desktop-update-install", () => appUpdater?.install() ?? false); +ipcMain.handle( + "desktop-update-set-mode", + (_event, mode) => appUpdater?.setMode(mode) ?? null, +); + // The server an older version of the app was pointed at, offered again when // linking so upgraded installs do not have to look it up. ipcMain.handle("get-previous-server-url", () => { @@ -3804,6 +3864,7 @@ app.whenReady().then(async () => { createTray(); createWindow(); + startAppUpdater(); desktopSettings.removeOldSyncFiles(); logToFile("=== Startup complete ==="); }); diff --git a/electron/preload.js b/electron/preload.js index d7b88ad3f..1586e8996 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -26,6 +26,23 @@ contextBridge.exposeInMainWorld("electronAPI", { getEmbeddedServerStatus: () => ipcRenderer.invoke("get-embedded-server-status"), + // App Store builds are updated by the store. + updates: process.mas + ? undefined + : { + getState: () => ipcRenderer.invoke("desktop-update-get-state"), + check: () => ipcRenderer.invoke("desktop-update-check"), + download: () => ipcRenderer.invoke("desktop-update-download"), + install: () => ipcRenderer.invoke("desktop-update-install"), + setMode: (mode) => ipcRenderer.invoke("desktop-update-set-mode", mode), + onState: (callback) => { + const listener = (_event, state) => callback(state); + ipcRenderer.on("desktop-update-state", listener); + return () => + ipcRenderer.removeListener("desktop-update-state", listener); + }, + }, + removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), isElectron: true, isDev: process.env.NODE_ENV === "development", diff --git a/env.catalog.json b/env.catalog.json index 4b7d0ed28..9f0934845 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -442,6 +442,18 @@ "internal": true, "description": "Read on Linux to pick the password store and window behavior." }, + { + "name": "APPIMAGE", + "group": "desktop", + "internal": true, + "description": "Set by AppImage. Tells the desktop app it can update itself." + }, + { + "name": "FLATPAK_ID", + "group": "desktop", + "internal": true, + "description": "Set by Flatpak. The desktop app links new versions instead of installing them." + }, { "name": "DESKTOP_SESSION", diff --git a/package-lock.json b/package-lock.json index 469a1fced..ef3a35c8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", + "electron-updater": "^6.8.9", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "motion": "^13.1.1", @@ -106,6 +107,7 @@ "husky": "^9.1.7", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", + "js-yaml": "^4.3.2", "jsdom": "^30.0.1", "lint-staged": "^17.2.0", "lucide-react": "^1.33.0", @@ -7589,7 +7591,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -9016,6 +9017,34 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "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/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/node_modules/@types/node": { "version": "24.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", @@ -9870,7 +9899,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -10803,7 +10831,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -10869,7 +10896,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/levn": { @@ -11218,6 +11244,12 @@ "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.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -11230,6 +11262,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "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.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -13020,7 +13059,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -13780,6 +13818,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/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -14081,7 +14125,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index 61913e70b..559a5dfdc 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", + "electron-updater": "^6.8.9", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "motion": "^13.1.1", @@ -141,6 +142,7 @@ "husky": "^9.1.7", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", + "js-yaml": "^4.3.2", "jsdom": "^30.0.1", "lint-staged": "^17.2.0", "lucide-react": "^1.33.0", diff --git a/packaging/flatpak/flathub.json b/packaging/flatpak/flathub.json deleted file mode 100644 index 43c4074e3..000000000 --- a/packaging/flatpak/flathub.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "only-arches": ["x86_64", "aarch64"], - "skip-icons-check": false, - "skip-appstream-check": false -} diff --git a/scripts/electron-auto-updater.test.ts b/scripts/electron-auto-updater.test.ts new file mode 100644 index 000000000..b80f49723 --- /dev/null +++ b/scripts/electron-auto-updater.test.ts @@ -0,0 +1,372 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const updates = require("../electron/auto-updater.cjs") as { + channelFile: (channel: string, platform: string, arch: string) => string; + getInstallKind: (input: Record) => string; + resolveRelease: ( + releases: unknown, + opts: { currentVersion: string; platform: string; arch: string }, + ) => { + tag: string; + version: string; + channel: string; + feedUrl: string; + releaseUrl: string; + } | null; + createUpdater: (deps: Record) => { + getState: () => Record; + check: () => Promise>; + download: () => Promise>; + install: () => boolean; + setMode: (mode: string) => Record; + }; +}; + +function release(tag: string, assets: string[], extra = {}) { + return { + tag_name: tag, + draft: false, + html_url: `https://github.com/Termix-SSH/Termix/releases/tag/${tag}`, + assets: assets.map((name) => ({ name })), + ...extra, + }; +} + +const ALL = [ + "latest.yml", + "latest-mac.yml", + "latest-linux.yml", + "latest-linux-arm64.yml", + "latest-linux-arm.yml", + "beta.yml", + "beta-mac.yml", + "beta-linux.yml", +]; + +const win = { platform: "win32", arch: "x64" }; + +describe("channelFile", () => { + it("matches the names electron-builder writes", () => { + expect(updates.channelFile("latest", "win32", "x64")).toBe("latest.yml"); + expect(updates.channelFile("latest", "darwin", "arm64")).toBe( + "latest-mac.yml", + ); + expect(updates.channelFile("latest", "linux", "x64")).toBe( + "latest-linux.yml", + ); + expect(updates.channelFile("beta", "linux", "arm64")).toBe( + "beta-linux-arm64.yml", + ); + expect(updates.channelFile("latest", "linux", "arm")).toBe( + "latest-linux-arm.yml", + ); + }); +}); + +describe("resolveRelease", () => { + it("picks the newest stable release and points the feed at its tag", () => { + const found = updates.resolveRelease( + [release("v26.10.2", ALL), release("v26.10.3", ALL)], + { currentVersion: "26.10.1", ...win }, + ); + expect(found).toMatchObject({ + tag: "v26.10.3", + version: "26.10.3", + channel: "latest", + feedUrl: + "https://github.com/Termix-SSH/Termix/releases/download/v26.10.3", + }); + }); + + it("ignores SDK, 2.9.x patch and draft releases", () => { + expect( + updates.resolveRelease( + [ + release("sdk-v1.0.9", ALL), + release("release-2.9.4-tag", ALL), + release("v26.11.0", ALL, { draft: true }), + ], + { currentVersion: "26.10.1", ...win }, + ), + ).toBeNull(); + }); + + it("returns null when nothing is newer", () => { + expect( + updates.resolveRelease([release("v26.10.1", ALL)], { + currentVersion: "26.10.1", + ...win, + }), + ).toBeNull(); + }); + + it("keeps stable installs off betas", () => { + expect( + updates.resolveRelease([release("v26.11.0-beta.1", ALL)], { + currentVersion: "26.10.1", + ...win, + }), + ).toBeNull(); + }); + + it("moves a beta install to the next beta, then to stable", () => { + expect( + updates.resolveRelease( + [release("v26.11.0-beta.1", ALL), release("v26.11.0-beta.2", ALL)], + { currentVersion: "26.11.0-beta.1", ...win }, + ), + ).toMatchObject({ tag: "v26.11.0-beta.2", channel: "beta" }); + expect( + updates.resolveRelease( + [release("v26.11.0-beta.2", ALL), release("v26.11.0", ALL)], + { currentVersion: "26.11.0-beta.2", ...win }, + ), + ).toMatchObject({ tag: "v26.11.0", channel: "latest" }); + }); + + it("waits while the newest release is still uploading", () => { + expect( + updates.resolveRelease( + [release("v26.10.2", ALL), release("v26.10.3", ["termix.exe"])], + { currentVersion: "26.10.1", ...win }, + ), + ).toBeNull(); + }); + + it("needs this platform's channel file", () => { + const releases = [release("v26.10.2", ["latest.yml", "latest-mac.yml"])]; + expect( + updates.resolveRelease(releases, { + currentVersion: "26.10.1", + platform: "linux", + arch: "x64", + }), + ).toBeNull(); + expect( + updates.resolveRelease(releases, { + currentVersion: "26.10.1", + platform: "darwin", + arch: "arm64", + }), + ).not.toBeNull(); + }); + + it("handles junk input", () => { + expect( + updates.resolveRelease(null, { currentVersion: "26.10.1", ...win }), + ).toBeNull(); + expect( + updates.resolveRelease([release("v26.10.2", ALL)], { + currentVersion: "dev", + ...win, + }), + ).toBeNull(); + }); +}); + +describe("getInstallKind", () => { + const base = { + platform: "win32", + arch: "x64", + isPackaged: true, + mas: false, + env: {}, + execPath: path.join("C:", "Program Files", "Termix", "Termix.exe"), + resourcesPath: "/opt/Termix/resources", + exists: () => false, + readFile: () => { + throw new Error("missing"); + }, + }; + + it("disables dev builds and leaves the App Store alone", () => { + expect(updates.getInstallKind({ ...base, isPackaged: false })).toBe( + "disabled", + ); + expect( + updates.getInstallKind({ ...base, platform: "darwin", mas: true }), + ).toBe("store"); + }); + + it("updates NSIS installs and only links MSI, portable and 32-bit", () => { + const uninstaller = path.join( + path.dirname(base.execPath), + "Uninstall Termix.exe", + ); + const nsis = (target: string) => target === uninstaller; + expect(updates.getInstallKind({ ...base, exists: nsis })).toBe("auto"); + expect(updates.getInstallKind(base)).toBe("manual"); + expect( + updates.getInstallKind({ ...base, arch: "ia32", exists: nsis }), + ).toBe("manual"); + }); + + it("updates DMG installs unless run from the mounted image", () => { + const mac = { ...base, platform: "darwin", arch: "arm64" }; + expect( + updates.getInstallKind({ + ...mac, + execPath: "/Applications/Termix.app/Contents/MacOS/Termix", + }), + ).toBe("auto"); + expect( + updates.getInstallKind({ + ...mac, + execPath: "/Volumes/Termix/Termix.app/Contents/MacOS/Termix", + }), + ).toBe("manual"); + }); + + it("tells Linux installs apart", () => { + const linux = { ...base, platform: "linux" }; + expect(updates.getInstallKind({ ...linux, env: { FLATPAK_ID: "x" } })).toBe( + "manual", + ); + expect( + updates.getInstallKind({ + ...linux, + exists: (target: string) => target === "/.flatpak-info", + }), + ).toBe("manual"); + expect( + updates.getInstallKind({ ...linux, env: { APPIMAGE: "/tmp/a" } }), + ).toBe("auto"); + const dpkgOwned = (target: string) => + target === "/var/lib/dpkg/info/termix.list"; + expect( + updates.getInstallKind({ + ...linux, + exists: dpkgOwned, + readFile: () => "deb\n", + }), + ).toBe("auto"); + // The AUR package ships the deb's files without dpkg. + expect(updates.getInstallKind({ ...linux, readFile: () => "deb\n" })).toBe( + "manual", + ); + expect(updates.getInstallKind(linux)).toBe("manual"); + }); +}); + +describe("createUpdater", () => { + afterEach(() => vi.useRealTimers()); + + function fakeAutoUpdater() { + const emitter = new EventEmitter() as EventEmitter & + Record; + emitter.setFeedURL = vi.fn(); + emitter.checkForUpdates = vi.fn(async () => { + emitter.emit("update-available", { version: "26.10.2" }); + }); + emitter.downloadUpdate = vi.fn(async () => { + emitter.emit("update-downloaded", { version: "26.10.2" }); + }); + emitter.quitAndInstall = vi.fn(); + return emitter; + } + + function setup(kind = "auto", mode = "prompt") { + const auto = fakeAutoUpdater(); + const saved: string[] = []; + const sent: Array> = []; + const beforeInstall = vi.fn(); + const updater = updates.createUpdater({ + currentVersion: "26.10.1", + ...win, + kind, + getMode: () => mode, + saveMode: (next: string) => saved.push(next), + fetchReleases: async () => [release("v26.10.2", ALL)], + loadAutoUpdater: () => auto, + send: (state: Record) => sent.push(state), + log: () => {}, + beforeInstall, + }); + return { auto, updater, saved, sent, beforeInstall }; + } + + it("asks before downloading in prompt mode", async () => { + const { auto, updater } = setup(); + const state = await updater.check(); + expect(auto.setFeedURL).toHaveBeenCalledWith({ + provider: "generic", + url: "https://github.com/Termix-SSH/Termix/releases/download/v26.10.2", + }); + expect(auto.autoDownload).toBe(false); + expect(auto.allowDowngrade).toBe(false); + expect(state).toMatchObject({ status: "available", version: "26.10.2" }); + + expect((await updater.download()).status).toBe("downloaded"); + expect(updater.install()).toBe(true); + }); + + it("downloads on its own in automatic mode", async () => { + const { auto, updater } = setup("auto", "automatic"); + await updater.check(); + expect(auto.autoDownload).toBe(true); + expect(updater.getState().status).toBe("downloading"); + }); + + it("marks the quit before installing so the tray lets go", async () => { + vi.useFakeTimers(); + const { auto, updater, beforeInstall } = setup(); + await updater.check(); + await updater.download(); + updater.install(); + expect(beforeInstall).toHaveBeenCalled(); + vi.runAllTimers(); + expect(auto.quitAndInstall).toHaveBeenCalled(); + }); + + it("only links the release for installs that can not update", async () => { + const { auto, updater } = setup("manual"); + const state = await updater.check(); + expect(auto.setFeedURL).not.toHaveBeenCalled(); + expect(state).toMatchObject({ + status: "available", + releaseUrl: "https://github.com/Termix-SSH/Termix/releases/tag/v26.10.2", + }); + expect(updater.install()).toBe(false); + }); + + it("does nothing for store and dev builds", async () => { + for (const kind of ["store", "disabled"]) { + const { auto, updater } = setup(kind); + expect((await updater.check()).status).toBe("idle"); + expect(auto.setFeedURL).not.toHaveBeenCalled(); + } + }); + + it("saves valid modes only and falls back to prompt", () => { + const { updater, saved } = setup("auto", "bogus"); + expect(updater.getState().mode).toBe("prompt"); + updater.setMode("never"); + updater.setMode("sometimes"); + expect(saved).toEqual(["never"]); + expect(updater.getState().mode).toBe("never"); + }); + + it("reports a failed lookup instead of throwing", async () => { + const updater = updates.createUpdater({ + currentVersion: "26.10.1", + ...win, + kind: "auto", + getMode: () => "prompt", + saveMode: () => {}, + fetchReleases: async () => { + throw new Error("offline"); + }, + loadAutoUpdater: () => fakeAutoUpdater(), + send: () => {}, + log: () => {}, + }); + expect(await updater.check()).toMatchObject({ + status: "error", + error: "offline", + }); + }); +}); diff --git a/scripts/fix-update-info.cjs b/scripts/fix-update-info.cjs new file mode 100644 index 000000000..ddbe1ab3e --- /dev/null +++ b/scripts/fix-update-info.cjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Fixes electron-builder's update files (latest.yml and friends) after the + * build, so the desktop updater gets the right download: + * + * --drop remove entries whose file name contains text + * (the ia32 installer, the universal mac zip) + * --rename follow an artifact the workflow renamed + * + * Usage: node scripts/fix-update-info.cjs [--drop x] [--rename a=b] files... + */ + +const fs = require("node:fs"); +const yaml = require("js-yaml"); + +function fixUpdateInfo(info, { drop = [], rename = {} } = {}) { + const rewrite = (name) => rename[name] ?? name; + const files = (info.files ?? []) + .filter((file) => !drop.some((text) => file.url.includes(text))) + .map((file) => ({ ...file, url: rewrite(file.url) })); + if (files.length === 0) { + throw new Error("no files left in update info"); + } + return { ...info, files, path: files[0].url, sha512: files[0].sha512 }; +} + +function parseArgs(argv) { + const options = { drop: [], rename: {}, files: [] }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--drop") options.drop.push(argv[++i]); + else if (arg === "--rename") { + const [from, to] = String(argv[++i]).split("="); + if (!from || !to) throw new Error(`bad --rename ${argv[i]}`); + options.rename[from] = to; + } else options.files.push(arg); + } + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + for (const file of options.files) { + if (!fs.existsSync(file)) continue; + const info = yaml.load(fs.readFileSync(file, "utf8")); + fs.writeFileSync(file, yaml.dump(fixUpdateInfo(info, options))); + console.log(`Fixed ${file}`); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} + +module.exports = { fixUpdateInfo, parseArgs }; diff --git a/scripts/fix-update-info.test.ts b/scripts/fix-update-info.test.ts new file mode 100644 index 000000000..30846b491 --- /dev/null +++ b/scripts/fix-update-info.test.ts @@ -0,0 +1,67 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { fixUpdateInfo, parseArgs } = require("./fix-update-info.cjs"); + +const info = { + version: "26.10.2", + files: [ + { url: "termix_windows_ia32_nsis.exe", sha512: "a", size: 1 }, + { url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 }, + ], + path: "termix_windows_ia32_nsis.exe", + sha512: "a", + releaseDate: "2026-10-11T00:00:00.000Z", +}; + +describe("fixUpdateInfo", () => { + it("drops entries and points path at what is left", () => { + const fixed = fixUpdateInfo(info, { drop: ["ia32"] }); + expect(fixed.files.map((f: { url: string }) => f.url)).toEqual([ + "termix_windows_x64_nsis.exe", + ]); + expect(fixed.path).toBe("termix_windows_x64_nsis.exe"); + expect(fixed.sha512).toBe("b"); + expect(fixed.version).toBe("26.10.2"); + }); + + it("follows renamed artifacts", () => { + const fixed = fixUpdateInfo( + { + ...info, + files: [{ url: "termix_linux_x86_64_appimage.AppImage", sha512: "c" }], + }, + { + rename: { + "termix_linux_x86_64_appimage.AppImage": + "termix_linux_x64_appimage.AppImage", + }, + }, + ); + expect(fixed.path).toBe("termix_linux_x64_appimage.AppImage"); + }); + + it("refuses to leave nothing to download", () => { + expect(() => fixUpdateInfo(info, { drop: ["nsis"] })).toThrow(); + }); +}); + +describe("parseArgs", () => { + it("reads drops, renames and files", () => { + expect( + parseArgs([ + "--drop", + "ia32", + "--rename", + "a=b", + "latest.yml", + "beta.yml", + ]), + ).toEqual({ + drop: ["ia32"], + rename: { a: "b" }, + files: ["latest.yml", "beta.yml"], + }); + }); +}); diff --git a/scripts/generate-settings-search-index.cjs b/scripts/generate-settings-search-index.cjs index 92a8b3b66..42df28a75 100644 --- a/scripts/generate-settings-search-index.cjs +++ b/scripts/generate-settings-search-index.cjs @@ -131,12 +131,18 @@ function adminSections() { "admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")), "admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")), "admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")), - updates: keysIn( - fs.readFileSync( - path.join(ROOT, "src", "ui", "settings", "UpdatesSettings.tsx"), - "utf8", + updates: [ + ...new Set( + ["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) => + keysIn( + fs.readFileSync( + path.join(ROOT, "src", "ui", "settings", file), + "utf8", + ), + ), + ), ), - ), + ], }; } diff --git a/src/types/onboarding.ts b/src/types/onboarding.ts index b21f5e71d..fcfdee4f2 100644 --- a/src/types/onboarding.ts +++ b/src/types/onboarding.ts @@ -11,6 +11,7 @@ export const CORE_ONBOARDING_STEPS = { plugins: 1, "desktop-sync": 1, + "desktop-updates": 1, preset: 1, appearance: 1, security: 1, diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index a81352fcc..aa7793bbb 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -42,6 +42,7 @@ import { type EditingWidth, } from "@/components/surface/surface-scope"; import { OnboardingHost } from "@/onboarding/OnboardingHost"; +import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts"; import { SplitView, type SplitViewActions } from "@/shell/split/SplitView"; import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay"; import { @@ -3184,6 +3185,7 @@ export function AppShell({ )} + {isElectron() && } ); } diff --git a/src/ui/lib/desktop-updates.ts b/src/ui/lib/desktop-updates.ts new file mode 100644 index 000000000..6f268627c --- /dev/null +++ b/src/ui/lib/desktop-updates.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import { isElectron } from "@/lib/electron"; + +export type DesktopUpdateMode = "automatic" | "prompt" | "never"; + +export type DesktopInstallKind = "auto" | "manual" | "store" | "disabled"; + +export type DesktopUpdateStatus = + | "idle" + | "checking" + | "available" + | "downloading" + | "downloaded" + | "up-to-date" + | "error"; + +export interface DesktopUpdateState { + kind: DesktopInstallKind; + mode: DesktopUpdateMode; + currentVersion: string; + status: DesktopUpdateStatus; + version: string | null; + releaseUrl: string | null; + progress: number | null; + error: string | null; +} + +interface DesktopUpdatesBridge { + getState: () => Promise; + check: () => Promise; + download: () => Promise; + install: () => Promise; + setMode: (mode: DesktopUpdateMode) => Promise; + onState: (callback: (state: DesktopUpdateState) => void) => () => void; +} + +type UpdatesWindow = Window & + typeof globalThis & { + electronAPI?: { updates?: DesktopUpdatesBridge }; + }; + +/** The main process updater, or null in the browser and on older builds. */ +export function desktopUpdates(): DesktopUpdatesBridge | null { + if (!isElectron()) return null; + return (window as UpdatesWindow).electronAPI?.updates ?? null; +} + +/** Whether this install has an updater the user can configure. */ +export function canConfigureUpdates( + state: DesktopUpdateState | null, +): state is DesktopUpdateState { + return !!state && state.kind !== "store" && state.kind !== "disabled"; +} + +/** Which toast, if any, this state calls for. */ +export function updateToastFor( + state: DesktopUpdateState | null, +): "prompt" | "manual" | "ready" | null { + if (!state || !state.version || state.mode === "never") return null; + if (state.status === "downloaded") return "ready"; + if (state.status !== "available") return null; + if (state.kind === "manual") return "manual"; + if (state.kind === "auto" && state.mode === "prompt") return "prompt"; + return null; +} + +export function useDesktopUpdate(): DesktopUpdateState | null { + const [state, setState] = useState(null); + + useEffect(() => { + const bridge = desktopUpdates(); + if (!bridge) return; + let active = true; + bridge + .getState() + .then((next) => active && next && setState(next)) + .catch(() => {}); + const off = bridge.onState((next) => active && setState(next)); + return () => { + active = false; + off(); + }; + }, []); + + return state; +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 4edbe7e09..53da633bf 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -233,6 +233,37 @@ "desktopLinked": "Linked to {{server}}", "pluginsDocsLink": "Read about each one" }, + "desktopUpdates": { + "title": "Desktop app", + "mode": "Updates", + "modeHint": "How this app gets new versions.", + "manualModeHint": "This copy can not update itself, so Termix only tells you when a new version is out.", + "modeAutomatic": "Automatic", + "modePrompt": "Ask first", + "modeNever": "Never", + "automaticBody": "Download new versions in the background and install them when you restart.", + "promptBody": "Tell me when a new version is out and let me choose when to download it.", + "neverBody": "Do not check for updates. You can still check by hand in Settings.", + "onboardingTitle": "App updates", + "onboardingBody": "The desktop app can now update itself. Pick how you want new versions to arrive. You can change this later in Settings, Updates.", + "onboardingManual": "This copy of the app was installed in a way that can not update itself, so Termix will tell you when a new version is out with a link to download it.", + "status": "Version {{version}}", + "statusIdle": "Not checked yet", + "statusChecking": "Checking for updates", + "statusAvailable": "Version {{version}} is available", + "statusDownloading": "Downloading {{version}} ({{progress}}%)", + "statusReady": "Version {{version}} is ready. Restart to install it.", + "statusUpToDate": "You have the newest version", + "statusError": "Could not check for updates. Try again later.", + "checkNow": "Check now", + "download": "Download", + "restart": "Restart now", + "later": "Later", + "available": "Termix {{version}} is available", + "manualHint": "Download it from the release page and install it over this one.", + "ready": "Termix {{version}} is ready to install", + "readyHint": "It installs the next time Termix restarts." + }, "nav": { "home": "Home", "terminal": "Terminal", diff --git a/src/ui/onboarding/onboarding-steps.ts b/src/ui/onboarding/onboarding-steps.ts index aabde0c20..626e9e1e1 100644 --- a/src/ui/onboarding/onboarding-steps.ts +++ b/src/ui/onboarding/onboarding-steps.ts @@ -1,10 +1,19 @@ -import { LayoutTemplate, Monitor, Palette, Puzzle, Shield } from "lucide-react"; +import { + LayoutTemplate, + Monitor, + MonitorDown, + Palette, + Puzzle, + Shield, +} from "lucide-react"; import { CORE_ONBOARDING_STEPS } from "@/types/onboarding"; import { PluginsStep } from "./steps/PluginsStep"; import { PresetStep } from "./steps/PresetStep"; import { AppearanceStep } from "./steps/AppearanceStep"; import { SecurityStep } from "./steps/SecurityStep"; import { DesktopSyncStep } from "./steps/DesktopSyncStep"; +import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep"; +import { desktopUpdates } from "@/lib/desktop-updates"; import type { OnboardingStepDef } from "./onboarding-registry"; /** @@ -38,6 +47,17 @@ export const CORE_STEPS: OnboardingStepDef[] = [ position: 20, isRelevant: (ctx) => ctx.isDesktop, }, + { + id: "desktop-updates", + version: CORE_ONBOARDING_STEPS["desktop-updates"], + titleKey: "desktopUpdates.onboardingTitle", + icon: MonitorDown, + Component: DesktopUpdatesStep, + audience: "all", + position: 25, + // App Store builds have no updater. + isRelevant: (ctx) => ctx.isDesktop && desktopUpdates() !== null, + }, { id: "preset", version: CORE_ONBOARDING_STEPS.preset, diff --git a/src/ui/onboarding/steps/DesktopUpdatesStep.tsx b/src/ui/onboarding/steps/DesktopUpdatesStep.tsx new file mode 100644 index 000000000..a6937488d --- /dev/null +++ b/src/ui/onboarding/steps/DesktopUpdatesStep.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BellRing, Download, PauseCircle } from "lucide-react"; +import { + desktopUpdates, + useDesktopUpdate, + type DesktopUpdateMode, +} from "@/lib/desktop-updates"; + +/** + * Desktop only: how the app updates itself. Can be changed later in + * Settings, Updates. + */ +export function DesktopUpdatesStep() { + const { t } = useTranslation(); + const state = useDesktopUpdate(); + const [picked, setPicked] = useState(null); + const mode = picked ?? state?.mode ?? "prompt"; + + const choose = (next: DesktopUpdateMode) => { + setPicked(next); + void desktopUpdates() + ?.setMode(next) + .catch(() => {}); + }; + + const options = [ + { + id: "automatic" as const, + icon: Download, + title: t("desktopUpdates.modeAutomatic"), + body: t("desktopUpdates.automaticBody"), + }, + { + id: "prompt" as const, + icon: BellRing, + title: t("desktopUpdates.modePrompt"), + body: t("desktopUpdates.promptBody"), + }, + { + id: "never" as const, + icon: PauseCircle, + title: t("desktopUpdates.modeNever"), + body: t("desktopUpdates.neverBody"), + }, + ]; + + return ( +
+

+ {state?.kind === "manual" + ? t("desktopUpdates.onboardingManual") + : t("desktopUpdates.onboardingBody")} +

+
+ {options.map(({ id, icon: Icon, title, body }) => ( + + ))} +
+
+ ); +} diff --git a/src/ui/settings/DesktopUpdatesCard.tsx b/src/ui/settings/DesktopUpdatesCard.tsx new file mode 100644 index 000000000..12b92218a --- /dev/null +++ b/src/ui/settings/DesktopUpdatesCard.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { ExternalLink, MonitorDown } from "lucide-react"; +import { Button } from "@/components/button"; +import { SectionCard, SettingRow } from "@/components/section-card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/select"; +import { + canConfigureUpdates, + desktopUpdates, + useDesktopUpdate, + type DesktopUpdateMode, + type DesktopUpdateState, +} from "@/lib/desktop-updates"; + +const MODES: DesktopUpdateMode[] = ["automatic", "prompt", "never"]; + +function StatusText({ state }: { state: DesktopUpdateState }) { + const { t } = useTranslation(); + const version = state.version; + switch (state.status) { + case "checking": + return <>{t("desktopUpdates.statusChecking")}; + case "available": + return <>{t("desktopUpdates.statusAvailable", { version })}; + case "downloading": + return ( + <> + {t("desktopUpdates.statusDownloading", { + version, + progress: state.progress ?? 0, + })} + + ); + case "downloaded": + return <>{t("desktopUpdates.statusReady", { version })}; + case "up-to-date": + return <>{t("desktopUpdates.statusUpToDate")}; + case "error": + return <>{t("desktopUpdates.statusError")}; + default: + return <>{t("desktopUpdates.statusIdle")}; + } +} + +/** Desktop only: how this install updates itself. */ +export function DesktopUpdatesCard() { + const { t } = useTranslation(); + const state = useDesktopUpdate(); + const [busy, setBusy] = useState(false); + + if (!canConfigureUpdates(state)) return null; + const bridge = desktopUpdates(); + + const run = async (action: () => Promise | undefined) => { + setBusy(true); + try { + await action(); + } catch { + toast.error(t("settings.updates.saveFailed")); + } finally { + setBusy(false); + } + }; + + const modeLabel = (mode: DesktopUpdateMode) => + mode === "automatic" + ? t("desktopUpdates.modeAutomatic") + : mode === "prompt" + ? t("desktopUpdates.modePrompt") + : t("desktopUpdates.modeNever"); + + return ( + } + rowId="updates-desktop" + > + + + + } + > + {state.status === "downloaded" ? ( + + ) : state.status === "available" && state.kind === "auto" ? ( + + ) : state.status === "available" && state.releaseUrl ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/ui/settings/SettingsTab.tsx b/src/ui/settings/SettingsTab.tsx index bb790075d..aa041bf30 100644 --- a/src/ui/settings/SettingsTab.tsx +++ b/src/ui/settings/SettingsTab.tsx @@ -387,7 +387,7 @@ export function SettingsTab({ )} {page.id === "keybindings" && } - {page.id === "updates" && isAdmin && } + {page.id === "updates" && } {pluginFeature && ( <> {pluginFeature.user && ( diff --git a/src/ui/settings/UpdatesSettings.tsx b/src/ui/settings/UpdatesSettings.tsx index 1c9c99c8a..f27b76990 100644 --- a/src/ui/settings/UpdatesSettings.tsx +++ b/src/ui/settings/UpdatesSettings.tsx @@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron"; import { isPrerelease } from "@/plugins/plugin-model"; import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins"; import { docsUrl } from "@/lib/docs"; +import { DesktopUpdatesCard } from "./DesktopUpdatesCard"; const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta"; @@ -61,7 +62,7 @@ function ReleaseRow({ ); } -export function UpdatesSettings() { +function InstanceUpdates() { const { t } = useTranslation(); const [channel, setChannel] = useState(null); const [releases, setReleases] = useState<{ @@ -317,3 +318,12 @@ export function UpdatesSettings() { ); } + +export function UpdatesSettings({ isAdmin }: { isAdmin: boolean }) { + return ( +
+ + {isAdmin && } +
+ ); +} diff --git a/src/ui/settings/settings-pages.ts b/src/ui/settings/settings-pages.ts index 2d94ee148..b6372a6a4 100644 --- a/src/ui/settings/settings-pages.ts +++ b/src/ui/settings/settings-pages.ts @@ -38,6 +38,8 @@ export interface SettingsPage { icon: LucideIcon; /** Only for admins. */ admin?: boolean; + /** Also shown to non-admins in the desktop app. */ + desktopToo?: boolean; /** Left out of the desktop app. */ browserOnly?: boolean; /** The profile section this page shows. */ @@ -135,6 +137,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [ labelKey: "settings.updates.title", icon: FlaskConical, admin: true, + desktopToo: true, }), core({ id: "admin-users", @@ -225,7 +228,9 @@ export function visibleSettingsPages( { isAdmin, isElectron }: { isAdmin: boolean; isElectron: boolean }, ): SettingsPage[] { return pages.filter( - (page) => (!page.admin || isAdmin) && (!page.browserOnly || !isElectron), + (page) => + (!page.admin || isAdmin || (page.desktopToo && isElectron)) && + (!page.browserOnly || !isElectron), ); } diff --git a/src/ui/settings/settings-search-index.ts b/src/ui/settings/settings-search-index.ts index 6f10a00e4..7d95a19fa 100644 --- a/src/ui/settings/settings-search-index.ts +++ b/src/ui/settings/settings-search-index.ts @@ -434,6 +434,24 @@ export const SETTINGS_SEARCH_INDEX: Record = { "newUi.sidebar.userProfile.updatePassword", ], "updates": [ + "desktopUpdates.checkNow", + "desktopUpdates.download", + "desktopUpdates.manualModeHint", + "desktopUpdates.mode", + "desktopUpdates.modeAutomatic", + "desktopUpdates.modeHint", + "desktopUpdates.modeNever", + "desktopUpdates.modePrompt", + "desktopUpdates.restart", + "desktopUpdates.status", + "desktopUpdates.statusAvailable", + "desktopUpdates.statusChecking", + "desktopUpdates.statusDownloading", + "desktopUpdates.statusError", + "desktopUpdates.statusIdle", + "desktopUpdates.statusReady", + "desktopUpdates.statusUpToDate", + "desktopUpdates.title", "settings.updates.allPluginsBeta", "settings.updates.allPluginsStable", "settings.updates.backToStable", diff --git a/src/ui/shell/DesktopUpdateToasts.tsx b/src/ui/shell/DesktopUpdateToasts.tsx new file mode 100644 index 000000000..e486337e0 --- /dev/null +++ b/src/ui/shell/DesktopUpdateToasts.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + desktopUpdates, + updateToastFor, + useDesktopUpdate, +} from "@/lib/desktop-updates"; + +const TOAST_ID = "desktop-update"; +const LATER_KEY = "termix-desktop-update-later"; + +function readLater(): string | null { + try { + return localStorage.getItem(LATER_KEY); + } catch { + return null; + } +} + +function saveLater(value: string) { + try { + localStorage.setItem(LATER_KEY, value); + } catch { + // Only means the toast comes back next launch. + } +} + +/** Desktop only: tells the user about a new version and installs it. */ +export function DesktopUpdateToasts() { + const { t } = useTranslation(); + const state = useDesktopUpdate(); + const shown = useRef(null); + + useEffect(() => { + const kind = updateToastFor(state); + if (!state || !kind) return; + const key = `${kind}:${state.version}`; + if (shown.current === key || readLater() === key) return; + shown.current = key; + + const version = state.version; + const later = { + label: t("desktopUpdates.later"), + onClick: () => saveLater(key), + }; + + if (kind === "ready") { + toast(t("desktopUpdates.ready", { version }), { + id: TOAST_ID, + duration: Infinity, + description: t("desktopUpdates.readyHint"), + action: { + label: t("desktopUpdates.restart"), + onClick: () => void desktopUpdates()?.install(), + }, + cancel: later, + }); + } else if (kind === "prompt") { + toast(t("desktopUpdates.available", { version }), { + id: TOAST_ID, + duration: Infinity, + action: { + label: t("desktopUpdates.download"), + onClick: () => void desktopUpdates()?.download(), + }, + cancel: later, + }); + } else { + const url = state.releaseUrl; + toast(t("desktopUpdates.available", { version }), { + id: TOAST_ID, + duration: Infinity, + description: t("desktopUpdates.manualHint"), + action: { + label: t("desktopUpdates.download"), + onClick: () => { + if (url) window.open(url, "_blank", "noopener,noreferrer"); + }, + }, + cancel: later, + }); + } + }, [state, t]); + + return null; +} diff --git a/src/ui/tests/lib/desktop-updates.test.ts b/src/ui/tests/lib/desktop-updates.test.ts new file mode 100644 index 000000000..cd9032c72 --- /dev/null +++ b/src/ui/tests/lib/desktop-updates.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { updateToastFor, type DesktopUpdateState } from "@/lib/desktop-updates"; + +function state(patch: Partial): DesktopUpdateState { + return { + kind: "auto", + mode: "prompt", + currentVersion: "26.10.1", + status: "available", + version: "26.10.2", + releaseUrl: null, + progress: null, + error: null, + ...patch, + }; +} + +describe("updateToastFor", () => { + it("asks before downloading in ask first mode", () => { + expect(updateToastFor(state({}))).toBe("prompt"); + }); + + it("stays quiet while automatic mode downloads", () => { + expect(updateToastFor(state({ mode: "automatic" }))).toBeNull(); + expect( + updateToastFor(state({ mode: "automatic", status: "downloading" })), + ).toBeNull(); + }); + + it("offers a restart once downloaded", () => { + expect(updateToastFor(state({ status: "downloaded" }))).toBe("ready"); + expect( + updateToastFor(state({ mode: "automatic", status: "downloaded" })), + ).toBe("ready"); + }); + + it("links the release for installs that can not update", () => { + expect(updateToastFor(state({ kind: "manual" }))).toBe("manual"); + }); + + it("never toasts when updates are off", () => { + expect(updateToastFor(state({ mode: "never" }))).toBeNull(); + expect( + updateToastFor(state({ mode: "never", status: "downloaded" })), + ).toBeNull(); + expect(updateToastFor(null)).toBeNull(); + }); +}); diff --git a/src/ui/tests/onboarding/onboarding-steps.test.ts b/src/ui/tests/onboarding/onboarding-steps.test.ts index b8784a354..1b084c437 100644 --- a/src/ui/tests/onboarding/onboarding-steps.test.ts +++ b/src/ui/tests/onboarding/onboarding-steps.test.ts @@ -93,6 +93,22 @@ describe("CORE_STEPS", () => { ]); }); + it("asks how the desktop app updates when it has an updater", () => { + const win = window as unknown as { electronAPI?: unknown }; + win.electronAPI = { isElectron: true, updates: {} }; + try { + expect(ids(ctx({ isDesktop: true }))).toEqual([ + "desktop-sync", + "desktop-updates", + "preset", + "appearance", + ]); + expect(ids(ctx())).not.toContain("desktop-updates"); + } finally { + delete win.electronAPI; + } + }); + it("offers account security only when there is something to set up", () => { expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([ "preset", diff --git a/src/ui/tests/settings/DesktopUpdatesCard.test.tsx b/src/ui/tests/settings/DesktopUpdatesCard.test.tsx new file mode 100644 index 000000000..d98c1565d --- /dev/null +++ b/src/ui/tests/settings/DesktopUpdatesCard.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { DesktopUpdatesCard } from "@/settings/DesktopUpdatesCard"; +import type { DesktopUpdateState } from "@/lib/desktop-updates"; + +type TestWindow = { electronAPI?: unknown }; + +function state(patch: Partial = {}): DesktopUpdateState { + return { + kind: "auto", + mode: "prompt", + currentVersion: "26.10.1", + status: "idle", + version: null, + releaseUrl: null, + progress: null, + error: null, + ...patch, + }; +} + +function bridge(initial: DesktopUpdateState) { + return { + getState: vi.fn(async () => initial), + check: vi.fn(async () => initial), + download: vi.fn(async () => initial), + install: vi.fn(async () => true), + setMode: vi.fn(async () => initial), + onState: vi.fn(() => () => {}), + }; +} + +async function renderWith(initial: DesktopUpdateState) { + const api = bridge(initial); + (window as TestWindow).electronAPI = { isElectron: true, updates: api }; + await act(async () => { + render(); + }); + return api; +} + +afterEach(() => { + cleanup(); + delete (window as TestWindow).electronAPI; +}); + +describe("DesktopUpdatesCard", () => { + it("renders nothing in the browser", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("checks on demand", async () => { + const api = await renderWith(state()); + fireEvent.click(screen.getByText("desktopUpdates.checkNow")); + expect(api.check).toHaveBeenCalled(); + }); + + it("offers the download in ask first mode", async () => { + const api = await renderWith( + state({ status: "available", version: "26.10.2" }), + ); + fireEvent.click(screen.getByText("desktopUpdates.download")); + expect(api.download).toHaveBeenCalled(); + }); + + it("restarts into a downloaded update", async () => { + const api = await renderWith( + state({ status: "downloaded", version: "26.10.2" }), + ); + fireEvent.click(screen.getByText("desktopUpdates.restart")); + expect(api.install).toHaveBeenCalled(); + }); + + it("links the release for installs that can not update", async () => { + await renderWith( + state({ + kind: "manual", + status: "available", + version: "26.10.2", + releaseUrl: "https://example.com/r", + }), + ); + expect( + screen.getByText("desktopUpdates.download").closest("a"), + ).toHaveProperty("href", "https://example.com/r"); + }); + + it("hides on dev builds", async () => { + await renderWith(state({ kind: "disabled" })); + expect(screen.queryByText("desktopUpdates.title")).toBeNull(); + }); +}); diff --git a/src/ui/tests/settings/settings-pages.test.ts b/src/ui/tests/settings/settings-pages.test.ts index e3724abc5..335624d84 100644 --- a/src/ui/tests/settings/settings-pages.test.ts +++ b/src/ui/tests/settings/settings-pages.test.ts @@ -33,6 +33,16 @@ describe("settings pages", () => { expect(admin).toContain("security"); }); + it("shows Updates to everyone on desktop but only admins in the browser", () => { + const ids = (isAdmin: boolean, isElectron: boolean) => + visibleSettingsPages(CORE_SETTINGS_PAGES, { isAdmin, isElectron }).map( + (page) => page.id, + ); + expect(ids(false, true)).toContain("updates"); + expect(ids(false, false)).not.toContain("updates"); + expect(ids(true, false)).toContain("updates"); + }); + it("finds a page by a label shown on it, not only its name", () => { const hits = searchSettingsPages(CORE_SETTINGS_PAGES, "theme", translate); expect(hits.some((hit) => hit.page.id === "appearance")).toBe(true); diff --git a/src/ui/tests/types/onboarding.test.ts b/src/ui/tests/types/onboarding.test.ts index 47320e83c..13c235fc6 100644 --- a/src/ui/tests/types/onboarding.test.ts +++ b/src/ui/tests/types/onboarding.test.ts @@ -38,8 +38,12 @@ describe("onboarding seen map", () => { }); it("legacy seen covers every core step at launch", () => { + // Steps added later stay out so upgraded users still see them. + const addedLater = ["desktop-updates"]; expect(Object.keys(LEGACY_SEEN).sort()).toEqual( - Object.keys(CORE_ONBOARDING_STEPS).sort(), + Object.keys(CORE_ONBOARDING_STEPS) + .filter((key) => !addedLater.includes(key)) + .sort(), ); }); });