diff --git a/.dockerignore b/.dockerignore
index ca837371f..353e19038 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,13 +5,22 @@ yarn-error.log*
dist
build
-.next
-.nuxt
+coverage
+release
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
+# Local data, secrets and editor state
+db
+old_db
+ssl
+uploads
+plugins
+.env
+.env.*
+.mcp.json
+.claude
+CLAUDE.md
+termix-plugin-signing.key
+*.tmxplug.sig
.vscode
.idea
@@ -29,63 +38,31 @@ Thumbs.db
.git
.gitignore
+.husky
README.md
CONTRIBUTING.md
+CHANGELOG.md
LICENSE
-
-uploads/
-
electron/
electron-builder.json
+Casks
+packaging
*.log
*.tmp
*.temp
-
logs
-*.log
-
pids
*.pid
*.seed
*.pid.lock
-coverage
-
.nyc_output
-
-jspm_packages/
-
.npm
-
.eslintcache
-
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-.node_repl_history
-
-*.tgz
-
-.yarn-integrity
-
.cache
.parcel-cache
-
-.next
-
-.nuxt
-
-.vuepress/dist
-
-.serverless
-
-.fusebox/
-
-.dynamodb/
-
-.tern-port
+*.tgz
+.node_repl_history
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 7698ddeb5..72a5d23fd 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
version:
- description: "Version to build (e.g., 1.8.0)"
+ description: "Version to build (e.g., 26.10.0)"
required: true
build_type:
description: "Build type"
@@ -22,11 +22,11 @@ on:
workflow_call:
inputs:
version:
- description: "Version to build (e.g., 1.8.0)"
+ description: "Version to build (e.g., 26.10.0)"
required: true
type: string
build_type:
- description: "Build type (Development or Production)"
+ description: "Build type (Development, Beta or Production)"
required: true
type: string
dry_run:
@@ -52,7 +52,14 @@ jobs:
fetch-depth: 1
- name: Resolve source revision
- run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
+ run: |
+ echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
+ echo "BUILD_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
+
+ # The image reads its version from package.json, so a beta has to say so.
+ - name: Stamp beta version
+ if: ${{ inputs.build_type == 'Beta' }}
+ run: node scripts/sync-version.cjs --version "${{ inputs.version }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -120,7 +127,7 @@ jobs:
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
- org.opencontainers.image.created=${{ github.run_id }}
+ org.opencontainers.image.created=${{ env.BUILD_CREATED }}
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
- name: Login to Docker Hub as termixssh
diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml
index 9dba3910e..a3e29608c 100644
--- a/.github/workflows/electron.yml
+++ b/.github/workflows/electron.yml
@@ -117,6 +117,11 @@ jobs:
- name: Install dependencies
run: npm ci
+ # A beta build has to know it is a beta, or it reports the stable version.
+ - name: Stamp beta version
+ if: inputs.version_override != ''
+ run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
+
- name: Get version
id: package-version
run: |
@@ -220,6 +225,11 @@ jobs:
npm install --force @rollup/rollup-linux-arm64-gnu
npm install --force @rollup/rollup-linux-arm-gnueabihf
+ # A beta build has to know it is a beta, or it reports the stable version.
+ - name: Stamp beta version
+ if: inputs.version_override != ''
+ run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
+
- name: Build Linux x64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -429,6 +439,11 @@ jobs:
npm install dmg-license
node scripts/install-macos-sharp.cjs
+ # A beta build has to know it is a beta, or it reports the stable version.
+ - name: Stamp beta version
+ if: inputs.version_override != ''
+ run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
+
- name: Check for Code Signing Certificates
id: check_certs
run: |
@@ -468,14 +483,15 @@ jobs:
security find-identity -v -p codesigning $KEYCHAIN_PATH
+ # Betas never go to the App Store, so they skip the MAS package.
- name: Build macOS App Store Package
- if: steps.check_certs.outputs.has_certs == 'true'
+ id: mas_build
+ if: steps.check_certs.outputs.has_certs == 'true' && inputs.version_override == ''
env:
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=4096
run: |
- CURRENT_VERSION=$(node -p "require('./package.json').version")
BUILD_VERSION="${{ github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
@@ -534,7 +550,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
+ if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
npm run build
fi
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
@@ -555,7 +571,7 @@ jobs:
echo "path=$MAS_PKG" >> "$GITHUB_OUTPUT"
- name: Upload macOS MAS PKG
- if: steps.check_certs.outputs.has_certs == 'true' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
+ if: steps.mas_build.outcome == 'success' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v7
with:
name: termix_macos_universal_mas
@@ -722,7 +738,7 @@ jobs:
choco pack termix-ssh.nuspec
if ($LASTEXITCODE -ne 0) {
- throw "Chocolatey push failed with exit code $LASTEXITCODE"
+ throw "Chocolatey pack failed with exit code $LASTEXITCODE"
}
- name: Check for Chocolatey API Key
@@ -952,8 +968,8 @@ jobs:
cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb
- sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
- sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
+ sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-submission/Casks/t/termix.rb
+ sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-submission/Casks/t/termix.rb
- name: Verify Cask syntax
run: |
diff --git a/README.md b/README.md
index 7f3386e77..4fcb7af5b 100644
--- a/README.md
+++ b/README.md
@@ -109,14 +109,14 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
| Windows x64/ia32 |
-Portable · MSI Installer · Chocolatey |
+Portable · EXE and MSI Installer · Chocolatey |
-| Linux x64/ia32 |
+Linux x64/arm64/armv7l |
Portable · AUR · AppImage · Deb · Flatpak |
-| macOS x64/ia32, v12.0+ |
+macOS Universal/x64/arm64, v12.0+ |
Apple App Store · DMG · Homebrew |
@@ -149,6 +149,10 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ GUACD_HOST: "guacd"
+ GUACD_TUNNEL_HOST: "termix"
+ GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
+ GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd
networks:
@@ -158,8 +162,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
- ports:
- - "4822:4822"
+ volumes:
+ - termix-data:/termix-data
networks:
- termix-net
diff --git a/components.json b/components.json
index 26b31d574..10e27bb21 100644
--- a/components.json
+++ b/components.json
@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
- "css": "src/index.css",
+ "css": "src/ui/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
@@ -15,7 +15,7 @@
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
- "ui": "@/components/ui",
+ "ui": "@/components",
"lib": "@/lib",
"hooks": "@/hooks"
},
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 157708a49..9159f321a 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,7 +1,7 @@
# Stage 1: Install dependencies
# The builders only produce JS, so they run on the build machine's own arch
# once instead of under QEMU per target. Rolldown hangs under arm64 emulation.
-FROM --platform=$BUILDPLATFORM node:26-slim AS deps
+FROM --platform=$BUILDPLATFORM node:24-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -37,7 +37,7 @@ COPY . .
RUN npm run build:backend
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
-FROM node:26-slim AS opkssh-downloader
+FROM node:24-slim AS opkssh-downloader
ARG TARGETARCH
ARG OPKSSH_VERSION=v0.16.0
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
@@ -58,7 +58,7 @@ RUN case "$TARGETARCH" in \
echo -n "$OPKSSH_VERSION" > version.txt
# Stage 5: Production dependencies only
-FROM node:26-slim AS production-deps
+FROM node:24-slim AS production-deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -81,7 +81,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force
# Stage 6: Final optimized image
-FROM node:26-slim
+FROM node:24-slim
WORKDIR /app
# The release the opkssh-downloader stage baked in, so the plugin trusts that
diff --git a/electron-builder.json b/electron-builder.json
index 59d437a4e..8d2cd7207 100644
--- a/electron-builder.json
+++ b/electron-builder.json
@@ -123,7 +123,7 @@
"entitlements": "packaging/build/entitlements.mac.plist",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution",
- "minimumSystemVersion": "10.15",
+ "minimumSystemVersion": "12.0",
"mergeASARs": false,
"singleArchFiles": "**/*.{node,bare,dylib}",
"x64ArchFiles": "**/{*.{node,bare,dylib},node-pty/prebuilds/*/spawn-helper}"
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 675a66a6b..1e4fad2b4 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -53,7 +53,7 @@ export default tseslint.config([
},
{
// MySQL has no RETURNING clause, and drizzle's mysql-core does not expose
- // the method at all — a bare .returning() is a TypeError there, not a bad
+ // the method at all. A bare .returning() is a TypeError there, not a bad
// query, and it only fails on the engine no test in this repo runs against.
//
// 175 call sites were migrated off it. This is what stops number 176.
@@ -85,14 +85,14 @@ export default tseslint.config([
{
// Postgres and SQLite spell it ON CONFLICT; MySQL spells it ON
// DUPLICATE KEY and names no columns, so drizzle's mysql-core has no
- // onConflictDoUpdate at all — another TypeError, not a bad query.
+ // onConflictDoUpdate at all, another TypeError, not a bad query.
selector: "CallExpression[callee.property.name='onConflictDoUpdate']",
message:
"MySQL has no ON CONFLICT. Use upsert() from ./returning.js.",
},
{
// better-sqlite3 puts these on a write result; node-postgres and
- // mysql2 do not, so reading them directly yields undefined — and
+ // mysql2 do not, so reading them directly yields undefined, and
// Number(undefined) is NaN, which reaches the database as the string
// "NaN" and fails an integer column. Three call sites did exactly
// this and only broke on Postgres.
diff --git a/scripts/generate-dialect-schema.cjs b/scripts/generate-dialect-schema.cjs
index 337360111..2e2c60295 100644
--- a/scripts/generate-dialect-schema.cjs
+++ b/scripts/generate-dialect-schema.cjs
@@ -7,8 +7,8 @@
* drizzle/mysql. Nothing imports them to run a query.
*
* That is not an oversight. The query builder needs two things from a table
- * object — the identifiers to interpolate, and the encoders that turn JS values
- * into driver values — and the sqlite definitions supply both correctly for
+ * object: the identifiers to interpolate, and the encoders that turn JS values
+ * into driver values. The sqlite definitions supply both correctly for
* every engine, which is why all 44 repositories import schema.ts directly:
*
* - text and integer encode as themselves everywhere
@@ -17,15 +17,15 @@
* which is true for JS `true` as well as for 1
* - real is a plain number on all three
*
- * What genuinely differs between the dialects is DDL — column types, key
- * lengths, autoincrement syntax — and DDL is exactly what these files exist to
+ * What genuinely differs between the dialects is DDL (column types, key
+ * lengths, autoincrement syntax), and DDL is exactly what these files exist to
* generate. See scripts/verify-dialects.mjs, which asserts the round-trips
* above against real servers rather than trusting this comment.
*
* The schema is declared once, in sqlite-core, and the other two dialects are
* derived. Hand-maintaining three copies of 52 tables would mean a renamed
* table has to land in three places consistently or a foreign key silently
- * points at the wrong one — and the schema is regular enough that the mapping
+ * points at the wrong one, and the schema is regular enough that the mapping
* is mechanical.
*
* What varies between dialects is small and closed:
@@ -198,7 +198,7 @@ function transform(source, dialect) {
);
// Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts
- // DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column — on a TEXT
+ // DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column. On a TEXT
// one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an
// expression default works on any type, and an expression is written
// parenthesised. MariaDB accepts the bare form, which is why this only
@@ -272,13 +272,13 @@ function transform(source, dialect) {
}
function header(dialect) {
- return `// GENERATED FILE — do not edit.
+ return `// GENERATED FILE, do not edit.
//
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`.
// Target dialect: ${dialect}.
//
-// DDL source for drizzle-kit. NOT imported to run queries — repositories use
+// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// schema.ts on every dialect. See the generator header for why that is correct.
`;
}
diff --git a/scripts/generate-dialect-schema.test.ts b/scripts/generate-dialect-schema.test.ts
index 39ae102e7..9cd455569 100644
--- a/scripts/generate-dialect-schema.test.ts
+++ b/scripts/generate-dialect-schema.test.ts
@@ -137,7 +137,7 @@ describe("mysql output", () => {
expect(out).toContain('double("score")');
});
- it("makes key columns varchar — MySQL cannot index unbounded TEXT", () => {
+ it("makes key columns varchar since MySQL cannot index unbounded TEXT", () => {
expect(out).toContain('varchar("user_id", { length: 255 })');
expect(out).toContain('text("name")');
});
diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs
index fac5e9f10..7343b34c7 100644
--- a/scripts/generate-icons.mjs
+++ b/scripts/generate-icons.mjs
@@ -46,7 +46,7 @@ await sharp(svgBuffer)
.toFile(join(publicDir, "full-icon.png"));
console.log(" ✓ full-icon.png");
-// favicon.ico — embed 16, 32, 48 px layers
+// favicon.ico: embed 16, 32, 48 px layers
console.log("Generating favicon.ico...");
const icoSizes = [16, 32, 48];
const icoBuffers = await Promise.all(
@@ -55,7 +55,7 @@ const icoBuffers = await Promise.all(
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
console.log(" ✓ favicon.ico");
-// icon.ico — embed 16, 32, 48, 64, 128, 256 px layers
+// icon.ico: embed 16, 32, 48, 64, 128, 256 px layers
console.log("Generating icon.ico...");
const winSizes = [16, 32, 48, 64, 128, 256];
const winBuffers = await Promise.all(
diff --git a/scripts/generate-release-body.cjs b/scripts/generate-release-body.cjs
index 36e571e03..92a26b03d 100644
--- a/scripts/generate-release-body.cjs
+++ b/scripts/generate-release-body.cjs
@@ -37,11 +37,11 @@ function buildTable(version, mobileVersion, mobileTag) {
return [
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
- `| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | — | — |`,
- `| **AArch64 (ARM64)** | — | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
- `| **ARMv7 (32-bit)** | — | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | — | — | — |`,
- `| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | — | — | — | — |`,
- `| **Universal** | [Chocolatey](https://docs.termix.site/install/connector/windows) | [Flatpak](https://docs.termix.site/install/connector/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/connector/macos) | — | — |`,
+ `| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
+ `| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
+ `| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
+ `| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
+ `| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
].join("\n");
}
diff --git a/scripts/verify-dialects.mjs b/scripts/verify-dialects.mjs
index 7251798ae..abac9aab0 100644
--- a/scripts/verify-dialects.mjs
+++ b/scripts/verify-dialects.mjs
@@ -2,8 +2,8 @@
* Runs the repository layer against a real Postgres or MySQL server.
*
* The unit tests only ever see SQLite, so the parts of this codebase that
- * differ per engine — the RETURNING replacements, the read-then-write
- * transactions, the value encoders — have no coverage there at all. This is
+ * differ per engine (the RETURNING replacements, the read-then-write
+ * transactions, the value encoders) have no coverage there at all. This is
* what covers them, and it needs a live server, which is why it is a script
* rather than a test.
*
@@ -12,7 +12,7 @@
* npm run verify:dialect -- mysql://user:pass@host:3306/db
*
* Applies the migrations first, through the same runRemoteMigrations() the
- * application uses at startup — so a broken migration fails here rather than in
+ * application uses at startup, so a broken migration fails here rather than in
* production. Writes real rows: point it at a scratch database.
*/
@@ -40,7 +40,7 @@ const { drizzle } = await import(
dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2"
);
-// No schema option on purpose — see connect.ts.
+// No schema option on purpose, see connect.ts.
const db = drizzle(url);
const context = { dialect, drizzle: db };
@@ -87,7 +87,7 @@ check("insert returns the stored row", created?.username, "before");
check("boolean true survives the round trip", created?.isAdmin, true);
// updateReturning must report the state AFTER the write. Reading first would
-// return the value the update replaced — silently, with no error.
+// return the value the update replaced, silently, with no error.
const updated = await users.update(userId, { username: "after" });
check("update returns the new value", updated?.username, "after");