feat: desktop app auto updates with automatic, ask first or never modes

This commit is contained in:
LukeGus committed 2026-10-11 12:35:43 -05:00
1 parent 8403449669
commit 1aa1bde5f7
33 files changed
+1728 -177

No files matched your search

+58 -154
View File
@@ -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'
+1
View File
@@ -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
+2
View File
@@ -12,6 +12,8 @@ cask "termix" do
strategy :github_latest
end
auto_updates true
app "Termix.app"
zap trash: [
+9 -1
View File
@@ -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,
+323
View File
@@ -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,
};
+1 -1
View File
@@ -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"), {}) };
+61
View File
@@ -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 ===");
});
+17
View File
@@ -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",
+12
View File
@@ -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",
+49 -6
View File
@@ -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"
+2
View File
@@ -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",
-5
View File
@@ -1,5 +0,0 @@
{
"only-arches": ["x86_64", "aarch64"],
"skip-icons-check": false,
"skip-appstream-check": false
}
+372
View File
@@ -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, unknown>) => 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<string, unknown>) => {
getState: () => Record<string, unknown>;
check: () => Promise<Record<string, unknown>>;
download: () => Promise<Record<string, unknown>>;
install: () => boolean;
setMode: (mode: string) => Record<string, unknown>;
};
};
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<string, unknown>;
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<Record<string, unknown>> = [];
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<string, unknown>) => 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",
});
});
});
+60
View File
@@ -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 <text> remove entries whose file name contains text
* (the ia32 installer, the universal mac zip)
* --rename <old=new> 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 };
+67
View File
@@ -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"],
});
});
});
+11 -5
View File
@@ -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",
),
),
),
),
),
],
};
}
+1
View File
@@ -11,6 +11,7 @@
export const CORE_ONBOARDING_STEPS = {
plugins: 1,
"desktop-sync": 1,
"desktop-updates": 1,
preset: 1,
appearance: 1,
security: 1,
+2
View File
@@ -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({
</Suspense>
)}
<OnboardingHost />
{isElectron() && <DesktopUpdateToasts />}
</ServerStatusProvider>
);
}
+86
View File
@@ -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<DesktopUpdateState | null>;
check: () => Promise<DesktopUpdateState | null>;
download: () => Promise<DesktopUpdateState | null>;
install: () => Promise<boolean>;
setMode: (mode: DesktopUpdateMode) => Promise<DesktopUpdateState | null>;
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<DesktopUpdateState | null>(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;
}
+31
View File
@@ -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",
+21 -1
View File
@@ -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,
@@ -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<DesktopUpdateMode | null>(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 (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
{state?.kind === "manual"
? t("desktopUpdates.onboardingManual")
: t("desktopUpdates.onboardingBody")}
</p>
<div className="flex flex-col gap-1.5">
{options.map(({ id, icon: Icon, title, body }) => (
<button
key={id}
type="button"
onClick={() => choose(id)}
className={`flex items-start gap-2.5 border p-2.5 text-left transition-colors ${
mode === id
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card hover:bg-muted/40"
}`}
>
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
<span className="flex flex-col gap-1">
<span className="text-xs font-medium">{title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{body}
</span>
</span>
</button>
))}
</div>
</div>
);
}
+162
View File
@@ -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<unknown> | 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 (
<SectionCard
title={t("desktopUpdates.title")}
icon={<MonitorDown className="size-3.5" />}
rowId="updates-desktop"
>
<SettingRow
label={t("desktopUpdates.mode")}
description={
state.kind === "manual"
? t("desktopUpdates.manualModeHint")
: t("desktopUpdates.modeHint")
}
>
<Select
disabled={busy}
value={state.mode}
onValueChange={(next) =>
void run(() => bridge?.setMode(next as DesktopUpdateMode))
}
>
<SelectTrigger className="h-8 w-40 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{modeLabel(mode)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t("desktopUpdates.status", { version: state.currentVersion })}
description={<StatusText state={state} />}
>
{state.status === "downloaded" ? (
<Button
size="sm"
className="h-8"
onClick={() => void bridge?.install()}
>
{t("desktopUpdates.restart")}
</Button>
) : state.status === "available" && state.kind === "auto" ? (
<Button
size="sm"
className="h-8"
disabled={busy}
onClick={() => void run(() => bridge?.download())}
>
{t("desktopUpdates.download")}
</Button>
) : state.status === "available" && state.releaseUrl ? (
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={state.releaseUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("desktopUpdates.download")}
<ExternalLink className="size-3" />
</a>
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={
busy ||
state.status === "checking" ||
state.status === "downloading"
}
onClick={() => void run(() => bridge?.check())}
>
{t("desktopUpdates.checkNow")}
</Button>
)}
</SettingRow>
</SectionCard>
);
}
+1 -1
View File
@@ -387,7 +387,7 @@ export function SettingsTab({
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && isAdmin && <UpdatesSettings />}
{page.id === "updates" && <UpdatesSettings isAdmin={isAdmin} />}
{pluginFeature && (
<>
{pluginFeature.user && (
+11 -1
View File
@@ -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<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
@@ -317,3 +318,12 @@ export function UpdatesSettings() {
</div>
);
}
export function UpdatesSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="flex flex-col gap-3">
<DesktopUpdatesCard />
{isAdmin && <InstanceUpdates />}
</div>
);
}
+6 -1
View File
@@ -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),
);
}
+18
View File
@@ -434,6 +434,24 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"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",
+87
View File
@@ -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<string | null>(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;
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { updateToastFor, type DesktopUpdateState } from "@/lib/desktop-updates";
function state(patch: Partial<DesktopUpdateState>): 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();
});
});
@@ -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",
@@ -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> = {}): 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(<DesktopUpdatesCard />);
});
return api;
}
afterEach(() => {
cleanup();
delete (window as TestWindow).electronAPI;
});
describe("DesktopUpdatesCard", () => {
it("renders nothing in the browser", () => {
const { container } = render(<DesktopUpdatesCard />);
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();
});
});
@@ -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);
+5 -1
View File
@@ -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(),
);
});
});