mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
v26.10.1 (#1599)
* feat: AI translations at release, 7 new languages and regional fallback * fix: proper plural forms for count strings and full translations * fix: plural forms for selected, hidden and active counts * fix: synced plugin updates failing on desktop and server-only plugins shown as stopped * fix: restore rounded corners on app icons and favicon * feat: desktop app auto updates with automatic, ask first or never modes * feat: community and custom plugin registries * feat: plugin guide for AI tools in the SDK with a lint check * fix: keep the terminal WebSocket open past nginx's 60s default (#1588) * chore: update changelog * feat: show plugin download counts only and stop CI builds inflating them * feat: hide installed plugins in the browse tab by default * chore: pin bundled plugins to latest releases * chore: update translations * fix: focusing a saved password field and saving no longer erases it * fix: tell desktop users when their 2.8 data needs 2.9 first * fix: point windows update info at the x64 installer * fix: keep desktop client tunnel sockets alive behind proxies * chore: pin bundled plugins to latest releases * fix: cover client tunnel keepalive in the c2s runtime tests * fix: typing after opening a host no longer goes to the host search * fix: accept tab and = separators in SSH config import (#1598) * chore: update changelog * chore: drop plugin versions from core changelog * chore: update translations * chore: lint, format, and bump version to 26.10.1 --------- Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com> Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
174 files changed
+94334
-39995
No files matched your search
@@ -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,
|
||||
};
|
||||
@@ -35,7 +35,13 @@ function parseConflictingPort(stderr) {
|
||||
* stderr. Always returns a failure -- see the note above on why every exit
|
||||
* that reaches this point counts as one.
|
||||
*/
|
||||
// Part of PRE_29_DATABASE_ERROR in src/backend/database/db/upgrade-guard.ts.
|
||||
const OLD_DATABASE_MARKER = "This database is from Termix 2.8 or older";
|
||||
|
||||
function classifyBackendFailure(stderr = "") {
|
||||
if (stderr.includes(OLD_DATABASE_MARKER)) {
|
||||
return { reason: "old-database", port: null };
|
||||
}
|
||||
if (stderr.includes("EADDRINUSE")) {
|
||||
return { reason: "port-in-use", port: parseConflictingPort(stderr) };
|
||||
}
|
||||
|
||||
@@ -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"), {}) };
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -1924,6 +1984,16 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
// A proxy in front of the server may drop an idle WebSocket, which stopped
|
||||
// the tunnel. Pings count as traffic.
|
||||
function keepC2SSocketAlive(ws) {
|
||||
const timer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
}, 25_000);
|
||||
timer.unref?.();
|
||||
ws.once("close", () => clearInterval(timer));
|
||||
}
|
||||
|
||||
// C2S (client-to-server) tunnels relay through the server this desktop is
|
||||
// linked to, not the embedded backend.
|
||||
function getC2SRemoteBaseUrl() {
|
||||
@@ -2192,6 +2262,7 @@ async function openC2SRelay(
|
||||
getWebSocketOptions(relayUrl, { headers }),
|
||||
);
|
||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||
keepC2SSocketAlive(ws);
|
||||
const pendingChunks = [];
|
||||
let ready = false;
|
||||
let closed = false;
|
||||
@@ -2299,6 +2370,7 @@ async function testC2SRelay(
|
||||
getWebSocketOptions(relayUrl, { headers }),
|
||||
);
|
||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||
keepC2SSocketAlive(ws);
|
||||
|
||||
if (runtime) runtime.authSocket = ws;
|
||||
return new Promise((resolve) => {
|
||||
@@ -2609,6 +2681,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
|
||||
getWebSocketOptions(relayUrl, { headers }),
|
||||
);
|
||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||
keepC2SSocketAlive(ws);
|
||||
const sockets = new Map();
|
||||
let closed = false;
|
||||
|
||||
@@ -3804,6 +3877,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
createTray();
|
||||
createWindow();
|
||||
startAppUpdater();
|
||||
desktopSettings.removeOldSyncFiles();
|
||||
logToFile("=== Startup complete ===");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user