Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6377798d45 | ||
|
|
af87d09273 | ||
|
|
da3f0c7836 | ||
|
|
737e7cd7f7 | ||
|
|
a699feb4e2 | ||
|
|
b22b3679e4 | ||
|
|
895488d400 | ||
|
|
802b3003d5 | ||
|
|
3b3fd7f135 | ||
|
|
901a235de4 | ||
|
|
3caf6cca62 | ||
|
|
e284c95891 | ||
|
|
b0f2213b15 | ||
|
|
ee0d1a5d8e | ||
|
|
25816084aa | ||
|
|
87cc80b291 | ||
|
|
89222bed85 | ||
|
|
ad46f42ab1 | ||
|
|
34a9c77114 | ||
|
|
2c785fe35b | ||
|
|
1cdbfc674c | ||
|
|
9d93fb0893 | ||
|
|
556f0406aa | ||
|
|
0589465e4e | ||
|
|
ec3cc9cf18 | ||
|
|
4c235bb23f | ||
|
|
b414b85746 | ||
|
|
d049588f7f | ||
|
|
86161d66e2 | ||
|
|
aff29a9581 | ||
|
|
66e8ce6df7 | ||
|
|
3517b2f0ed | ||
|
|
220f5c8676 | ||
|
|
4dfad3efb3 | ||
|
|
e152bacc7c | ||
|
|
8d9c83b0f6 | ||
|
|
2fe7530cd4 | ||
|
|
4389bf2013 | ||
|
|
50f80b975d | ||
|
|
1ccb30f0a7 | ||
|
|
d44a504c04 | ||
|
|
62ac931468 | ||
|
|
6873bc92bb | ||
|
|
02f0c0c259 | ||
|
|
db317283ac | ||
|
|
3ebe8d0fac | ||
|
|
a074c10205 | ||
|
|
55b11598f5 | ||
|
|
ebbbbc2696 |
No files matched your search
|
After Width: | Height: | Size: 71 KiB |
@@ -115,7 +115,18 @@ jobs:
|
||||
Write-Host "Spectre libs present: $($found.FullName)"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: |
|
||||
npm ci
|
||||
node scripts/install-windows-sharp.cjs
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
@@ -134,7 +145,12 @@ 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 --arm64 --publish never
|
||||
|
||||
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
|
||||
# The combined multi-arch installer is never uploaded.
|
||||
- name: Fix Windows update files
|
||||
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
|
||||
|
||||
- name: Upload Windows x64 NSIS Installer
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -152,6 +168,14 @@ jobs:
|
||||
path: release/termix_windows_ia32_nsis.exe
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows arm64 NSIS Installer
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_arm64_nsis
|
||||
path: release/termix_windows_arm64_nsis.exe
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows x64 MSI Installer
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
|
||||
@@ -168,6 +192,14 @@ jobs:
|
||||
path: release/termix_windows_ia32_msi.msi
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows arm64 MSI Installer
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_arm64_msi
|
||||
path: release/termix_windows_arm64_msi.msi
|
||||
retention-days: 30
|
||||
|
||||
- name: Create Windows x64 Portable zip
|
||||
if: hashFiles('release/win-unpacked/*') != ''
|
||||
run: |
|
||||
@@ -178,6 +210,22 @@ jobs:
|
||||
run: |
|
||||
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
|
||||
|
||||
- name: Create Windows arm64 Portable zip
|
||||
if: hashFiles('release/win-arm64-unpacked/*') != ''
|
||||
run: |
|
||||
Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_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'
|
||||
@@ -194,6 +242,14 @@ jobs:
|
||||
path: termix_windows_ia32_portable.zip
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows arm64 Portable
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_arm64_portable
|
||||
path: termix_windows_arm64_portable.zip
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
||||
@@ -225,6 +281,15 @@ jobs:
|
||||
npm install --force @rollup/rollup-linux-arm64-gnu
|
||||
npm install --force @rollup/rollup-linux-arm-gnueabihf
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
@@ -234,12 +299,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 +320,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'
|
||||
@@ -439,6 +517,15 @@ jobs:
|
||||
npm install dmg-license
|
||||
node scripts/install-macos-sharp.cjs
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
@@ -494,7 +581,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 +640,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 +875,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 +973,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 }}
|
||||
@@ -1050,6 +1013,15 @@ jobs:
|
||||
npm install dmg-license
|
||||
node scripts/install-macos-sharp.cjs
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
run: |
|
||||
@@ -1188,7 +1160,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'
|
||||
@@ -1212,6 +1184,49 @@ jobs:
|
||||
--locales "en-US"
|
||||
echo "metadata_path=$META_DIR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Apple won't create a new version while the last one is still in
|
||||
# review. Pull it out so deliver can rename it to this version.
|
||||
- name: Cancel pending App Store review
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
LANE_DIR="$RUNNER_TEMP/asc_cancel/fastlane"
|
||||
mkdir -p "$LANE_DIR"
|
||||
|
||||
cat > "$LANE_DIR/Fastfile" <<'EOF'
|
||||
default_platform(:mac)
|
||||
|
||||
lane :cancel_pending_review do
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.from_json_file("/tmp/asc_keys/api_key.json")
|
||||
app = Spaceship::ConnectAPI::App.find("com.karmaa.termix")
|
||||
UI.user_error!("App not found") unless app
|
||||
|
||||
app.get_app_store_versions(filter: { platform: "MAC_OS" }, limit: 5).each do |v|
|
||||
UI.message("Version #{v.version_string}: #{v.app_version_state || v.app_store_state}")
|
||||
end
|
||||
|
||||
active = %w[WAITING_FOR_REVIEW IN_REVIEW UNRESOLVED_ISSUES]
|
||||
canceled = false
|
||||
app.get_review_submissions(filter: { platform: "MAC_OS" }).each do |sub|
|
||||
UI.message("Review submission #{sub.id}: #{sub.state}")
|
||||
next unless active.include?(sub.state)
|
||||
sub.cancel_submission
|
||||
canceled = true
|
||||
UI.important("Canceled review submission #{sub.id}")
|
||||
end
|
||||
next unless canceled
|
||||
|
||||
30.times do
|
||||
busy = app.get_review_submissions(filter: { platform: "MAC_OS" }).any? do |sub|
|
||||
(active + %w[CANCELING]).include?(sub.state)
|
||||
end
|
||||
break unless busy
|
||||
sleep(10)
|
||||
end
|
||||
end
|
||||
EOF
|
||||
|
||||
cd "$RUNNER_TEMP/asc_cancel" && fastlane cancel_pending_review
|
||||
|
||||
- name: Upload and submit to Mac App Store
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
|
||||
@@ -24,6 +24,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:sdk
|
||||
|
||||
|
||||
@@ -161,15 +161,62 @@ jobs:
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
|
||||
|
||||
verify-beta:
|
||||
# Translates new or changed strings with the Termix LLM server and commits
|
||||
# them to the dev branch, so the beta or stable build includes them. If the
|
||||
# server is down it warns and the release goes on with what is there.
|
||||
translate:
|
||||
needs: [prep]
|
||||
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout dev branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prep.outputs.sha || needs.prep.outputs.dev_branch }}
|
||||
fetch-depth: 1
|
||||
token: ${{ secrets.TERMIX_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Translate
|
||||
uses: Termix-SSH/Termix-Registry/translate@main
|
||||
with:
|
||||
source: src/ui/locales/en.json
|
||||
out: src/ui/locales/translated
|
||||
languages: src/ui/locales/languages.json
|
||||
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
||||
|
||||
- name: Commit translations
|
||||
id: commit
|
||||
run: |
|
||||
git add src/ui/locales
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes."
|
||||
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
|
||||
echo "DRY RUN: would commit these translation changes:"
|
||||
git --no-pager diff --cached --stat
|
||||
else
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git commit -m "chore: update translations"
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
verify-beta:
|
||||
needs: [prep, translate]
|
||||
if: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.skip != 'true' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout beta commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prep.outputs.sha }}
|
||||
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -181,6 +228,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:sdk
|
||||
|
||||
@@ -197,7 +253,7 @@ jobs:
|
||||
run: npm run test
|
||||
|
||||
normalize:
|
||||
needs: [prep]
|
||||
needs: [prep, translate]
|
||||
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
@@ -284,8 +340,8 @@ jobs:
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
|
||||
merge-to-main:
|
||||
needs: [prep, normalize, sync-main]
|
||||
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }}
|
||||
needs: [prep, translate, normalize, sync-main]
|
||||
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.translate.result == 'success' || needs.translate.result == 'skipped') && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
main_sha: ${{ steps.merge.outputs.main_sha }}
|
||||
@@ -353,14 +409,14 @@ jobs:
|
||||
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
needs: [prep, verify-beta, merge-to-main]
|
||||
needs: [prep, translate, verify-beta, merge-to-main]
|
||||
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.skip != 'true' && (needs.merge-to-main.result == 'success' || needs.verify-beta.result == 'success') }}
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${{ needs.prep.outputs.version }}
|
||||
build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }}
|
||||
dry_run: ${{ needs.prep.outputs.mode == 'Dry run' }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||
secrets: inherit
|
||||
|
||||
create-release:
|
||||
@@ -418,7 +474,7 @@ jobs:
|
||||
fi
|
||||
|
||||
create-beta-release:
|
||||
needs: [prep, docker]
|
||||
needs: [prep, translate, docker]
|
||||
if: ${{ always() && needs.prep.outputs.channel == 'Beta' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
@@ -427,7 +483,7 @@ jobs:
|
||||
- name: Checkout beta commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prep.outputs.sha }}
|
||||
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -440,7 +496,7 @@ jobs:
|
||||
node scripts/generate-beta-body.cjs \
|
||||
--version "${{ needs.prep.outputs.version }}" \
|
||||
--branch "${{ needs.prep.outputs.dev_branch }}" \
|
||||
--sha "${{ needs.prep.outputs.sha }}" \
|
||||
--sha "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}" \
|
||||
--prev "${{ needs.prep.outputs.prev_ref }}" > BETA_BODY.md
|
||||
|
||||
- name: Create beta release
|
||||
@@ -456,11 +512,19 @@ jobs:
|
||||
else
|
||||
gh release create "$TAG" --repo ${{ github.repository }} \
|
||||
--title "$TITLE" --notes-file BETA_BODY.md \
|
||||
--prerelease --target "${{ needs.prep.outputs.sha }}"
|
||||
--prerelease --target "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}"
|
||||
fi
|
||||
|
||||
electron-release:
|
||||
needs: [prep, merge-to-main, docker, create-release, create-beta-release]
|
||||
needs:
|
||||
[
|
||||
prep,
|
||||
translate,
|
||||
merge-to-main,
|
||||
docker,
|
||||
create-release,
|
||||
create-beta-release,
|
||||
]
|
||||
if: ${{ always() && needs.prep.outputs.skip != 'true' && needs.docker.result == 'success' && needs.create-release.result != 'failure' && needs.create-beta-release.result != 'failure' }}
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
@@ -468,12 +532,12 @@ jobs:
|
||||
artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }}
|
||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||
version_override: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.version || '' }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||
secrets: inherit
|
||||
|
||||
electron-submit:
|
||||
needs: [prep, merge-to-main, electron-release]
|
||||
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Skip submit' }}
|
||||
if: ${{ always() && needs.electron-release.result == 'success' && needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Skip submit' }}
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
build_type: all
|
||||
@@ -483,7 +547,7 @@ jobs:
|
||||
|
||||
cask-commit-back:
|
||||
needs: [prep, electron-release]
|
||||
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' }}
|
||||
if: ${{ always() && needs.electron-release.result == 'success' && needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -541,8 +605,48 @@ jobs:
|
||||
|
||||
# The docs site reads the env catalog, API spec and download list from the
|
||||
# newest stable release.
|
||||
docs-refresh:
|
||||
# Docs written for this release live on dev-X.Y.Z in the Docs repo. Squash
|
||||
# merge it into main once the release is out. No branch means nothing to do.
|
||||
docs-merge:
|
||||
needs: [prep, electron-release]
|
||||
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Merge the docs dev branch into main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||
REPO: Termix-SSH/Docs
|
||||
VERSION: ${{ needs.prep.outputs.version }}
|
||||
run: |
|
||||
BRANCH="dev-$VERSION"
|
||||
if ! gh api "repos/$REPO/branches/$BRANCH" > /dev/null 2>&1; then
|
||||
echo "$REPO has no $BRANCH branch, nothing to merge."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
AHEAD=$(gh api "repos/$REPO/compare/main...$BRANCH" -q .ahead_by)
|
||||
if [ "$AHEAD" = "0" ]; then
|
||||
echo "$BRANCH has nothing main doesn't, deleting it."
|
||||
gh api -X DELETE "repos/$REPO/git/refs/heads/$BRANCH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(gh pr list --repo "$REPO" --head "$BRANCH" --base main --state open --json number -q '.[0].number')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
PR_NUMBER=$(gh pr create --repo "$REPO" --base main --head "$BRANCH" \
|
||||
--title "v$VERSION" --body "Docs for Termix v$VERSION." | grep -oE '[0-9]+$')
|
||||
fi
|
||||
|
||||
gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --admin --delete-branch
|
||||
|
||||
STATE=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state -q .state)
|
||||
if [ "$STATE" != "MERGED" ]; then
|
||||
echo "Docs PR #$PR_NUMBER did not merge (state: $STATE)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docs-refresh:
|
||||
needs: [prep, electron-release, docs-merge]
|
||||
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Translate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
retranslate:
|
||||
description: "Translate every string again, not only new or changed ones"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: translate-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
if: ${{ startsWith(github.ref, 'refs/heads/dev-') }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
token: ${{ secrets.TERMIX_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Translate
|
||||
uses: Termix-SSH/Termix-Registry/translate@main
|
||||
with:
|
||||
source: src/ui/locales/en.json
|
||||
out: src/ui/locales/translated
|
||||
languages: src/ui/locales/languages.json
|
||||
retranslate: ${{ inputs.retranslate }}
|
||||
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
||||
|
||||
- name: Commit translations
|
||||
run: |
|
||||
git add src/ui/locales
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git commit -m "chore: update translations"
|
||||
git push origin HEAD:"${GITHUB_REF_NAME}"
|
||||
@@ -11,4 +11,3 @@ db
|
||||
*.min.css
|
||||
openapi.json
|
||||
drizzle/
|
||||
src/ui/settings/settings-search-index.ts
|
||||
@@ -1,5 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## 26.10.3
|
||||
|
||||
### Added
|
||||
|
||||
- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone
|
||||
- Plugins can add layouts and themes
|
||||
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
|
||||
- Hovering a theme in Settings previews it
|
||||
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
|
||||
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
|
||||
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
|
||||
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
|
||||
- Browse shows plugins a page at a time
|
||||
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
|
||||
- Hosts can be managed from a config file through a plugin. Managed hosts are read only for everyone, but pins and order still work
|
||||
- A plugin can block adding hosts by hand, so every host comes from one place
|
||||
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
|
||||
- A user who must have a second factor but has none can sign in only to set one up
|
||||
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
|
||||
- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app
|
||||
- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch
|
||||
- The linked server list holds one link per account, with room for more connection options per server
|
||||
- A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix
|
||||
- Plugins can create a folder with a color and icon and remove it when it is empty
|
||||
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
|
||||
- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine
|
||||
- Pasting a security key file now explains how to use it instead of failing to decode it
|
||||
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
|
||||
- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window
|
||||
- Plugins can show a web page in a native view in the desktop app, for sites that do not load in an iframe
|
||||
|
||||
### Changed
|
||||
|
||||
- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working
|
||||
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
|
||||
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
|
||||
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
|
||||
- A theme that no longer exists shows as System instead of an unstyled page
|
||||
- Installs and updates from a registry check the Termix version a plugin needs, not only its plugin API
|
||||
- Host online and offline dots now come from the Host Status plugin. The check interval setting moved to its settings and keeps its value. Without the plugin hosts show no dot
|
||||
- Audit log forwarding and the CSV and NDJSON export now come from the Audit Tools plugin. The collector URL and token move to its settings on upgrade. The audit log viewer, retention and the audit log itself stay in Termix
|
||||
- Client tunnels on your own computer and opening a host in Windows Remote Desktop moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build
|
||||
- The local terminal, the local side of the file manager with its transfers, and opening a file in an external editor moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build
|
||||
- The recent activity list and the first run steps for the preset and for theme, accent, font and language now come from bundled plugins. Existing entries and choices are kept, and people who went through those steps are not asked again
|
||||
- Branding (app name, tagline and logo) is now saved by the bundled layout plugin. An existing setup keeps its branding, and the sign in screen still reads it from Termix
|
||||
- Without the plugin that stores recent activity, the list stays empty and nothing is recorded
|
||||
- Importing hosts from a JSON or SSH config file and exporting hosts now come from the Import and Export plugin. Exports are now written to the audit log. Without the plugin there is no import or export
|
||||
- Generating and checking SSH keys in the credential editor and deploying a key to a host now come from the Credential Tools plugin. Deploys are now written to the audit log. Without the plugin those buttons are hidden
|
||||
|
||||
### Fixed
|
||||
|
||||
- Linking the desktop app to a server no longer stalls on the server dashboard after sign in
|
||||
- Exporting your data no longer fails when a plugin table has a text key, like saved passkeys
|
||||
- The Windows on ARM desktop app starts again instead of failing with a local server error
|
||||
|
||||
### Removed
|
||||
|
||||
- The desktop app no longer has its own client tunnel and native Remote Desktop code
|
||||
|
||||
## 26.10.2
|
||||
|
||||
### Added
|
||||
|
||||
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
|
||||
- Sort the dashboard's Host Status card by name, IP address or status
|
||||
- Windows on ARM builds of the desktop app (installer, MSI and portable)
|
||||
- Plugins get an event when a host is added
|
||||
- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser
|
||||
- Plugins can pin a panel to the right edge of the window that pushes the page over
|
||||
|
||||
### Changed
|
||||
|
||||
- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts
|
||||
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
|
||||
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
|
||||
|
||||
### Fixed
|
||||
|
||||
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
|
||||
- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3
|
||||
- The local terminal failed to start in the universal macOS build
|
||||
- Hosts shared with you never showed up on a linked desktop
|
||||
- A linked desktop could never turn on an unsigned plugin the server had enabled
|
||||
- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed
|
||||
- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list
|
||||
- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy
|
||||
- Usernames are now unique
|
||||
- Importing user data keeps foreign keys on
|
||||
- Trusted proxy login checks the address nginx saw, not nginx itself
|
||||
- The desktop login frame only trusts the server's own origin
|
||||
- External login URLs and callback ports are checked on the desktop app
|
||||
- A changed jump host key is no longer accepted without asking
|
||||
- Synced plugins signed only by a key the server sent are no longer turned on by themselves
|
||||
- Two connects to the same host at once could overwrite each other's connection
|
||||
- Indonesian uses the same technical terms everywhere
|
||||
- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back
|
||||
- nginx answered 404 instead of 502 while the backend was down
|
||||
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
|
||||
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
|
||||
- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language
|
||||
- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required
|
||||
- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own
|
||||
- A linked desktop could never finish syncing with a server on Postgres
|
||||
- Routine plugin activity filled the audit log and pushed out real security entries
|
||||
- Opening a full-screen view link while signed out showed a spinner forever. It now shows the login and opens the link after
|
||||
- A linked desktop behind a proxy that needs extra headers, or with an allowed self-signed certificate, could not open remote terminals or tunnels
|
||||
|
||||
## 26.10.1
|
||||
|
||||
### 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
|
||||
- Community plugins. Turn on the community registry in Plugins, Registries to browse and install plugins made by other people, each version reviewed before it is listed
|
||||
- Custom plugin registries. Add your own registry by its index URL; its signing key is pinned when you add it
|
||||
|
||||
### Changed
|
||||
|
||||
- Every language is now fully translated, in Termix and in every plugin, and stays up to date with each release
|
||||
- Languages with more than two plural forms, like Russian, Polish and Arabic, now use the right one
|
||||
- The Plugins tab shows how many times each plugin was downloaded, in a Downloads column
|
||||
- Plugins, Browse hides plugins you already have. Turn on Show installed to see them
|
||||
|
||||
### Fixed
|
||||
|
||||
- Regional browser languages like `es-MX`, `pt` or `zh-HK` showed English instead of the closest translation
|
||||
- Counts like "1 keys" or "Moved 1 host(s)" now use the singular
|
||||
- Plugin updates on the desktop app, synced or installed from the Plugins tab, failed to start with "Cannot find package"
|
||||
- The desktop app showed plugins that only run on the server as not running and out of date
|
||||
- Keep the terminal WebSocket open past nginx's 60s default
|
||||
- Plugin download counts now show in the Plugins tab without waiting for the background check
|
||||
- Clicking into a saved password, key passphrase or sudo password field and saving without typing erased it, which stopped sudo autofill
|
||||
- The desktop app now says when its data is from 2.8 or older and needs 2.9 first, instead of only saying the local server could not start
|
||||
- Client tunnels on the desktop app stopped with "Endpoint SSH connection closed" when a reverse proxy dropped their idle connection
|
||||
- Typing right after opening a host from the sidebar went to the host search instead of the new session
|
||||
- SSH config import failed on files that put a tab or `=` between a keyword and its value
|
||||
- App icons and the favicon have rounded corners again
|
||||
|
||||
## 26.10.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,7 +25,7 @@ Termix is a small core plus plugins. This repo is the core: hosts, credentials,
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Termix-Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
|
||||
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
|
||||
|
||||
## Useful commands
|
||||
|
||||
@@ -38,21 +38,21 @@ npm run format # format the code with Prettier
|
||||
|
||||
## Making a change
|
||||
|
||||
1. **Fork the repository**: Click "Fork" at the top right of the [repository page](https://github.com/Termix-SSH/Termix).
|
||||
2. **Create a branch**:
|
||||
1. Fork the [repo](https://github.com/Termix-SSH/Termix).
|
||||
2. Create a branch:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes** and add or update tests for them.
|
||||
4. **Commit your changes**:
|
||||
3. Make your changes and add or update tests for them.
|
||||
4. Commit your changes:
|
||||
```sh
|
||||
git commit -m "feat: add my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
5. Push to your fork:
|
||||
```sh
|
||||
git push origin feature/my-new-feature
|
||||
```
|
||||
6. **Open a pull request** against the current `dev-*` branch with a clear description.
|
||||
6. Open a pull request against the current `dev-*` branch with a clear description.
|
||||
|
||||
## Guidelines
|
||||
|
||||
@@ -72,4 +72,4 @@ Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Tem
|
||||
|
||||
## Support
|
||||
|
||||
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Please be as detailed as possible, preferably in English. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Add as much detail as you can, in English if possible. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "2.9.2"
|
||||
sha256 "808d876b37ab802c511e964c232619b1ba2949ec39ccd2b1f97e264544739172"
|
||||
version "26.10.2"
|
||||
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
@@ -12,6 +12,8 @@ cask "termix" do
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
auto_updates true
|
||||
|
||||
app "Termix.app"
|
||||
|
||||
zap trash: [
|
||||
|
||||
@@ -18,36 +18,38 @@
|
||||
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<a href="https://www.youtube.com/watch?v=vhtkP-UqV5U"><img src="./.github/readme/overview.jpg" width="720" alt="Watch the Termix overview on YouTube" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## Overview
|
||||
|
||||
Termix is a free, open source, self-hosted platform for managing your servers, built on plugins. The core is small. It keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, turn off or remove.
|
||||
Termix is a free, open source, self-hosted server management platform built on plugins. The core keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, off or remove.
|
||||
|
||||
It runs on web, desktop and mobile, and it is a self-hosted alternative to Termius that stays free forever.
|
||||
It runs on web, desktop and mobile. Think of it as a free, self-hosted Termius.
|
||||
|
||||
<br />
|
||||
|
||||
## Try the Demo
|
||||
|
||||
Want to see it before you install it? Try the demo at [demo.termix.site](https://demo.termix.site/). Any username and password works.
|
||||
Try it at [demo.termix.site](https://demo.termix.site/). Any username and password works.
|
||||
|
||||
<br />
|
||||
|
||||
## Core
|
||||
|
||||
What every Termix install has, with or without plugins:
|
||||
What every install has, with or without plugins:
|
||||
|
||||
- **Hosts:** Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
|
||||
- **Credentials:** Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
|
||||
- **Sharing and Roles:** Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
|
||||
- **Accounts:** Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
|
||||
- **Security:** Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
|
||||
- **Desktop Sync:** The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
|
||||
- **Interface:** Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
|
||||
- **Databases:** SQLite by default, with PostgreSQL and MySQL supported too
|
||||
- Hosts: Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
|
||||
- Credentials: Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
|
||||
- Sharing and Roles: Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
|
||||
- Accounts: Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
|
||||
- Security: Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
|
||||
- Desktop Sync: The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
|
||||
- Interface: Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
|
||||
- Databases: SQLite by default, with PostgreSQL and MySQL supported too
|
||||
|
||||
<br />
|
||||
|
||||
@@ -55,44 +57,53 @@ What every Termix install has, with or without plugins:
|
||||
|
||||
Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo.
|
||||
|
||||
| Plugin | What it does |
|
||||
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
|
||||
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
|
||||
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
||||
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
||||
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
|
||||
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
||||
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
||||
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
||||
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
||||
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
||||
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
|
||||
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
|
||||
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
|
||||
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
|
||||
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
|
||||
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
||||
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
||||
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
||||
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
||||
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
|
||||
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
|
||||
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
|
||||
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
|
||||
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
|
||||
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
||||
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
|
||||
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
|
||||
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
||||
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
||||
| Plugin | What it does |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
|
||||
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
|
||||
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
||||
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
|
||||
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
||||
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
|
||||
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
|
||||
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
|
||||
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
|
||||
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
||||
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
||||
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
||||
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
||||
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
||||
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
|
||||
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
|
||||
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
|
||||
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
||||
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
|
||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
|
||||
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
|
||||
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
|
||||
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
|
||||
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
|
||||
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
||||
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
||||
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
||||
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
||||
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
|
||||
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
|
||||
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
|
||||
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
|
||||
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
|
||||
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
|
||||
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
||||
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
|
||||
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
|
||||
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
||||
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
||||
|
||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Its community registry is open for submissions now, and Termix will be able to install community plugins in a later update. See [submitting a plugin](https://docs.termix.site/develop/community-registry).
|
||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
|
||||
|
||||
<br />
|
||||
|
||||
@@ -105,27 +116,27 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Web</b></td>
|
||||
<td>Any modern browser (Chrome, Safari, Firefox) · PWA support</td>
|
||||
<td>Any modern browser (Chrome, Safari, Firefox) | PWA support</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
||||
<td>Portable · EXE and MSI Installer · Chocolatey</td>
|
||||
<td>Portable | EXE and MSI Installer | Chocolatey</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
||||
<td>Portable · AUR · AppImage · Deb · Flatpak</td>
|
||||
<td>Portable | AUR | AppImage | Deb | Flatpak</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
||||
<td>Apple App Store · DMG · Homebrew</td>
|
||||
<td>Apple App Store | DMG | Homebrew</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
||||
<td>Apple App Store · IPA</td>
|
||||
<td>Apple App Store | IPA</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Android</b> <sub>v7.0+</sub></td>
|
||||
<td>Google Play Store · APK</td>
|
||||
<td>Google Play Store | APK</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -133,9 +144,9 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
|
||||
## Installation
|
||||
|
||||
Visit the [Termix Docs](https://docs.termix.site/install) for full install steps on every platform.
|
||||
See the [docs](https://docs.termix.site/install) for install steps on every platform.
|
||||
|
||||
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
|
||||
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -149,44 +160,23 @@ 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:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- termix-data:/termix-data
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### Cloud Hosting
|
||||
|
||||
You can run Termix on a VPS instead of inside your own network. If Termix runs on the network it manages, an outage takes Termix down with it, right when you need it to fix things. Running it somewhere else keeps it reachable, gives you a static IP and lets you get in from anywhere without a VPN or port forward.
|
||||
If Termix runs on the network it manages, an outage takes it down right when you need it. A VPS keeps it reachable, gives you a static IP and works from anywhere without a VPN or port forward.
|
||||
|
||||
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix, and the docs have a step by step guide for their VPS platform.
|
||||
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix and the docs have a setup guide for their VPS.
|
||||
|
||||
<br />
|
||||
|
||||
## Donate
|
||||
|
||||
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains and development time.
|
||||
Termix has no paid plans. Donations cover servers, domains and development time.
|
||||
|
||||
[Donate](https://donate.termix.site/)
|
||||
|
||||
@@ -194,7 +184,7 @@ Termix is free and open source with no subscriptions or paid plans. If you find
|
||||
|
||||
## Sponsors
|
||||
|
||||
Interested in a paid placement to support development? Email [mail@termix.site](mailto:mail@termix.site).
|
||||
For a paid sponsor spot, email [mail@termix.site](mailto:mail@termix.site).
|
||||
|
||||
<!-- SPONSORS:START -->
|
||||
|
||||
@@ -202,14 +192,6 @@ Interested in a paid placement to support development? Email [mail@termix.site](
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://termix.site/img/sponsors/digitalocean.svg" height="40" alt="DigitalOcean" />
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://termix.site/img/sponsors/crowdin.svg" height="40" alt="Crowdin" />
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://termix.site/img/sponsors/blacksmith.svg" height="40" alt="Blacksmith" />
|
||||
</a>
|
||||
@@ -250,7 +232,7 @@ Bugs and ideas for Termix itself (login, hosts, credentials, sharing, sync, the
|
||||
|
||||
Every plugin has its own repo, so a problem with the terminal, file manager, Docker, remote desktop or any other plugin goes in that plugin's repo (see the table above, or use Report an issue on the plugin's page in the app, which fills in your versions for you). Not sure where it goes? Open it here and it will be moved.
|
||||
|
||||
You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
|
||||
You need a GitHub account. Add as much detail as you can, in English if possible.
|
||||
|
||||
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server. Update videos are on [YouTube](https://www.youtube.com/@TermixSSH/videos).
|
||||
|
||||
@@ -258,4 +240,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See [LICENSE](LICENSE) for more information.
|
||||
Apache License 2.0. See [LICENSE](LICENSE).
|
||||
@@ -26,7 +26,7 @@ COPY . .
|
||||
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
||||
|
||||
RUN npm cache clean --force && \
|
||||
NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||
TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||
|
||||
# Stage 3: Build backend
|
||||
FROM deps AS backend-builder
|
||||
@@ -34,7 +34,9 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build:backend
|
||||
# Bundled plugin downloads show in the plugin store, so keep them between builds.
|
||||
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
|
||||
npm run build:backend
|
||||
|
||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||
FROM node:24-slim AS opkssh-downloader
|
||||
@@ -72,12 +74,15 @@ COPY vendor ./vendor
|
||||
|
||||
COPY scripts/patch-ssh2-agent.cjs ./scripts/
|
||||
|
||||
# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses
|
||||
# OpenSSL instead (chacha20-poly1305 is then left out).
|
||||
RUN npm ci --omit=dev --ignore-scripts && \
|
||||
node scripts/patch-ssh2-agent.cjs && \
|
||||
rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
||||
npm rebuild bcryptjs ssh2 && \
|
||||
npm rebuild bcryptjs && \
|
||||
rm -rf node_modules/ssh2/lib/protocol/crypto/build && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final optimized image
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
{
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug",
|
||||
"sha256": "34aa172b2f6bc6a773ebdff2ec0f590d411ce258fd43176bdddb4c30d1fe30da"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
|
||||
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
|
||||
"sha256": "ad291a2c6f6f01b72ebd275e8d202b78f55bc13a1e1a40f419d04e71b2fbd06b"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
|
||||
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
|
||||
"sha256": "3fe008f9d2a32e2b7111399054a2264ea5cf5d71264cc31e85cdab3e909cc62f",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
|
||||
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -25,14 +25,28 @@
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
|
||||
"sha256": "76d4cff6e78d40ec67e899f0c7b2aca43d35a47b9a15071d0a8f5a8d939af9a9"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
||||
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
||||
},
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
|
||||
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "classic-themes",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
|
||||
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
|
||||
"sha256": "641e564ba915a72efed4236272d63f6fff43904309833870f3554d3e9bd28dfa",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
|
||||
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -41,8 +55,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
|
||||
"sha256": "1cafccfe86a29e2f318026ca7bc20629df94331c81209607976dafd7feac0ca4",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
|
||||
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -51,20 +65,20 @@
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
|
||||
"sha256": "1ebeccd6b2a8e9510ab56658374d0b87f652149101d14956752a696ca920f611"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
|
||||
"sha256": "65cd21802f6a71f3b3c582bc0007bfc91160abc161b2390bce715fd2ed987f99"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
|
||||
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
|
||||
"sha256": "1afce5489038e5e30b55210e8e43ba93e0446fe087e2952af75a402dbf809ea9",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
|
||||
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -73,32 +87,32 @@
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
|
||||
"sha256": "1df08a931457043ba500689a73b4b5ab2bfb43630f377557e0fb13280b996334"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
|
||||
"sha256": "dc3ad943c1753b352b4d80c09836a293a8767293061ddf2e641575bae97124e9"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
|
||||
"sha256": "91cc1575aa1ff84632e0b6a4da68df3a7df4b8e18431b9c0378d314b886ed55d"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
|
||||
"sha256": "09891a3ab67a5b6589d8ab9e0fc88d8c63ff9d4e52ed8e7cbd0801a5e64e593f"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
|
||||
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
|
||||
"sha256": "7b24691953550458ee1609372443eeb5580d20091b02c95011c0427268350efc",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
|
||||
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -107,14 +121,14 @@
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
|
||||
"sha256": "04e9cfca70ffafc41546ddf0f1f6411743a2e0e9835b26ad5c0b91ac11028485"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
|
||||
"sha256": "2470acbd6c4c343c3d89dada972051f1ee7aace88af1e6719b92f19a4a6e46ea",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
||||
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
@@ -123,20 +137,20 @@
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
|
||||
"sha256": "6b630ca0e08d79be7cb49366c898586ee3416a518f8e200157a6191c2c7c9ab7"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
|
||||
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
|
||||
"sha256": "0c0d3f2d63184d4a4b2a90b11a1ee247ddc937abba76af74eb39e27048b412f9"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
|
||||
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
|
||||
},
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
|
||||
"sha256": "47872d08cdf4a15b50c8f90ca125276a92579147a27cb6b5639a84a01160abe5",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
|
||||
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -145,8 +159,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
|
||||
"sha256": "5b5a357bbf9a503c6b68725245712a99fa8ea0eec0baff64319be28950acac79",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
|
||||
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -155,8 +169,8 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
|
||||
"sha256": "d9f69f7f52b778d1b144c58e5a65006780498d6fa171b5aeb18e15b7b033ca6b",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
|
||||
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
@@ -166,20 +180,20 @@
|
||||
{
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
|
||||
"sha256": "05209afbaeb104ff77fecb5cf26a7823b9ed66031eac7557d3216fc3b861f7a6"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
|
||||
"sha256": "e9840bddef696b24354b9f04f2c97e164c3879f3fd1b337347de728db522874e"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
|
||||
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
|
||||
"sha256": "b41210d638b93470891ab238e3d1180a0e0d0f0c697b1c2079156416dec05966",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
||||
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -188,20 +202,20 @@
|
||||
{
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
|
||||
"sha256": "facd9f1c6036202e7c6b1020408ce49c7ba4bc687a8f8b93ccf60e97f473f897"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
|
||||
"sha256": "d453e0c5f1bf54160615d5df4e12eac0d97fea3c78c0aad5ebe8552e1f2de42c"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
|
||||
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
|
||||
"sha256": "a2d389048d16e31c089cf211fe2fb37fcfb33369497b58ee445e8626e9ec494d",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
||||
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -210,8 +224,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
|
||||
"sha256": "dc6c2942490c07ec1f9a4065850288cbb00c42dae64397937d2551d689314939",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
|
||||
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -220,32 +234,32 @@
|
||||
{
|
||||
"id": "vault",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
|
||||
"sha256": "bdad88ed576ed30b464c9cbddab4d9bb60696581dc1b815b76e1b4a08500f870"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
|
||||
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
|
||||
},
|
||||
{
|
||||
"id": "wake-on-lan",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
|
||||
"sha256": "3e7d4c8a9a20cde95d8594d4ae8e1fa0469d0751be9f00249f6431b10ada9ea9"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
|
||||
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
|
||||
"sha256": "18ed1c8238ecf57b5776ad93c6533d7040b988ae765012c5da591b85faa02d2e"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
|
||||
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
|
||||
},
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
|
||||
"sha256": "7f69e608eccd8cc6fa2436bffb3e2c3c8aae6cebebf7341913f15e7f9be58e42"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
|
||||
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
|
||||
"sha256": "dafcd7bfffc39b60a14333164dfd8ca70986da1390285359669d0293cd050121",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
|
||||
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -254,8 +268,8 @@
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
|
||||
"sha256": "1ee3a07bd8d61b46b6fbe118d2a424f8027575282287622b467e83a4356e6412",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
|
||||
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
|
||||
@@ -12,24 +12,9 @@ services:
|
||||
environment:
|
||||
PORT: "8080"
|
||||
NODE_ENV: development
|
||||
GUACD_HOST: "guacd-dev"
|
||||
GUACD_TUNNEL_HOST: "termix-dev"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||
# REDIS_URL: "redis://redis:6379"
|
||||
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
||||
depends_on:
|
||||
- guacd-dev
|
||||
networks:
|
||||
- termix-dev-net
|
||||
|
||||
guacd-dev:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd-dev
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- termix-dev-data:/termix-data
|
||||
networks:
|
||||
- termix-dev-net
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@ services:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
GUACD_HOST: "guacd"
|
||||
GUACD_TUNNEL_HOST: "termix"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
# Where guacd stores files for RDP drive redirection (one folder per
|
||||
# user is created underneath). Must be writable by guacd's user.
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||
# Multi-instance deployments should point every backend at the same
|
||||
# Redis. Without it, collaboration rooms keep their single-instance
|
||||
# in-memory fallback.
|
||||
@@ -28,21 +22,14 @@ services:
|
||||
# Trusted reverse-proxy authentication is disabled by default. When
|
||||
# enabled, do not expose this container directly to untrusted clients.
|
||||
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
||||
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12"
|
||||
# List the address your authenticating proxy connects from, as this
|
||||
# container sees it (for a proxy container on termix-net, its IP there).
|
||||
# Keep it narrow: published ports can reach the container from the
|
||||
# Docker gateway, so do not trust whole private ranges.
|
||||
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10"
|
||||
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
|
||||
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
|
||||
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- termix-data:/termix-data
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
|
||||
@@ -200,6 +200,9 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||
# X-Forwarded-For; trusted proxy login checks this address.
|
||||
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
@@ -425,6 +428,16 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
# A terminal is quiet most of the time - waiting on a prompt, reading
|
||||
# output, running something long. Every console plugin dials this route
|
||||
# (terminal, serial, remote desktop), and nginx's default
|
||||
# proxy_read_timeout of 60s closed the socket about a minute into any
|
||||
# pause, which reaches the user as the SSH server disconnecting. The
|
||||
# plugin WebSocket routes below already ask for 86400s; this is the same
|
||||
# class of socket, so it asks for the same.
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /host/ {
|
||||
@@ -463,9 +476,12 @@ http {
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
# Only nginx's own errors land here (the backend is down or too slow).
|
||||
# There is no 50x page, so answer directly instead of a 404.
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
internal;
|
||||
default_type application/json;
|
||||
return 502 '{"error":"The Termix server is not responding"}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,9 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||
# X-Forwarded-For; trusted proxy login checks this address.
|
||||
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
@@ -394,6 +397,16 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
# A terminal is quiet most of the time - waiting on a prompt, reading
|
||||
# output, running something long. Every console plugin dials this route
|
||||
# (terminal, serial, remote desktop), and nginx's default
|
||||
# proxy_read_timeout of 60s closed the socket about a minute into any
|
||||
# pause, which reaches the user as the SSH server disconnecting. The
|
||||
# plugin WebSocket routes below already ask for 86400s; this is the same
|
||||
# class of socket, so it asks for the same.
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /host/ {
|
||||
@@ -432,9 +445,12 @@ http {
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
# Only nginx's own errors land here (the backend is down or too slow).
|
||||
# There is no 50x page, so answer directly instead of a 404.
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
internal;
|
||||
default_type application/json;
|
||||
return 502 '{"error":"The Termix server is not responding"}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%')
|
||||
)
|
||||
)
|
||||
UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available
|
||||
SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence)
|
||||
WHERE ranked.occurrence > 1;--> statement-breakpoint
|
||||
ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `sessions` ADD `restriction` text;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `idle_timeout` int;
|
||||
@@ -498,6 +498,41 @@
|
||||
"when": 1791550497926,
|
||||
"tag": "0070_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "5",
|
||||
"when": 1791755801066,
|
||||
"tag": "0071_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "5",
|
||||
"when": 1791935283211,
|
||||
"tag": "0072_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "5",
|
||||
"when": 1792357129912,
|
||||
"tag": "0073_user_look_preferences",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 74,
|
||||
"version": "5",
|
||||
"when": 1792374489419,
|
||||
"tag": "0074_mature_puff_adder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "5",
|
||||
"when": 1792430772041,
|
||||
"tag": "0075_session_policy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "plugin_install_counts" DROP COLUMN "source";
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "sessions" ADD COLUMN "restriction" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idle_timeout" integer;
|
||||
@@ -498,6 +498,41 @@
|
||||
"when": 1791550496661,
|
||||
"tag": "0070_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "7",
|
||||
"when": 1791755800419,
|
||||
"tag": "0071_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "7",
|
||||
"when": 1791935282340,
|
||||
"tag": "0072_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1792357128746,
|
||||
"tag": "0073_user_look_preferences",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 74,
|
||||
"version": "7",
|
||||
"when": 1792374488837,
|
||||
"tag": "0074_bouncy_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "7",
|
||||
"when": 1792430770990,
|
||||
"tag": "0075_session_policy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `sessions` ADD `restriction` text(32);--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `idle_timeout` integer;
|
||||
@@ -463,6 +463,41 @@
|
||||
"when": 1791550495357,
|
||||
"tag": "0065_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 66,
|
||||
"version": "6",
|
||||
"when": 1791755799726,
|
||||
"tag": "0066_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "6",
|
||||
"when": 1791935281343,
|
||||
"tag": "0067_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "6",
|
||||
"when": 1792357127581,
|
||||
"tag": "0068_user_look_preferences",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "6",
|
||||
"when": 1792374488196,
|
||||
"tag": "0069_normal_ozymandias",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "6",
|
||||
"when": 1792430769899,
|
||||
"tag": "0070_session_policy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
@@ -43,11 +46,11 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "ia32"]
|
||||
"arch": ["x64", "ia32", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "msi",
|
||||
"arch": ["x64", "ia32"]
|
||||
"arch": ["x64", "ia32", "arm64"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.ico",
|
||||
@@ -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,
|
||||
@@ -148,6 +156,6 @@
|
||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||
}
|
||||
},
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"generateUpdatesFilesForAllChannels": false,
|
||||
"msiProjectCreated": "packaging/build/msi-shortcuts.cjs"
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Desktop self updates. The release is picked from the GitHub releases list
|
||||
// instead of electron-updater's GitHub provider, because the repo also holds
|
||||
// SDK and 2.9.x releases, and a new release exists a while before its files
|
||||
// finish uploading. The channel file is uploaded last, so a release counts
|
||||
// only once it has one for this platform.
|
||||
|
||||
const path = require("path");
|
||||
|
||||
const REPO = "Termix-SSH/Termix";
|
||||
const RELEASES_API = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
|
||||
const DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/`;
|
||||
const MODES = ["automatic", "prompt", "never"];
|
||||
const DEFAULT_MODE = "prompt";
|
||||
const FIRST_CHECK_DELAY = 30 * 1000;
|
||||
const CHECK_INTERVAL = 6 * 60 * 60 * 1000;
|
||||
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
||||
|
||||
function parseVersion(value) {
|
||||
const match = VERSION.exec(String(value || "").trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
parts: match.slice(1, 4).map(Number),
|
||||
beta: match[4] == null ? null : Number(match[4]),
|
||||
};
|
||||
}
|
||||
|
||||
// A beta sorts before the release it leads up to.
|
||||
function compareVersions(a, b) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a.parts[i] !== b.parts[i]) return a.parts[i] - b.parts[i];
|
||||
}
|
||||
if (a.beta === b.beta) return 0;
|
||||
if (a.beta == null) return 1;
|
||||
if (b.beta == null) return -1;
|
||||
return a.beta - b.beta;
|
||||
}
|
||||
|
||||
// Matches the names electron-builder writes and electron-updater reads.
|
||||
function channelFile(channel, platform, arch) {
|
||||
if (platform === "win32") return `${channel}.yml`;
|
||||
if (platform === "darwin") return `${channel}-mac.yml`;
|
||||
return `${channel}-linux${arch === "x64" ? "" : `-${arch}`}.yml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest release this install should move to, or null. Stable installs
|
||||
* only see stable releases; a beta install also sees newer betas.
|
||||
*/
|
||||
function resolveRelease(releases, { currentVersion, platform, arch }) {
|
||||
const current = parseVersion(currentVersion);
|
||||
if (!current || !Array.isArray(releases)) return null;
|
||||
const allowBeta = current.beta != null;
|
||||
|
||||
const newest = releases
|
||||
.filter((release) => release && !release.draft)
|
||||
.map((release) => ({ release, version: parseVersion(release.tag_name) }))
|
||||
.filter(
|
||||
({ release, version }) =>
|
||||
version &&
|
||||
release.tag_name.startsWith("v") &&
|
||||
(allowBeta || version.beta == null) &&
|
||||
compareVersions(version, current) > 0,
|
||||
)
|
||||
.sort((a, b) => compareVersions(b.version, a.version))[0];
|
||||
if (!newest) return null;
|
||||
|
||||
const { release, version } = newest;
|
||||
const channel = version.beta == null ? "latest" : "beta";
|
||||
const file = channelFile(channel, platform, arch);
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
if (!assets.some((asset) => asset && asset.name === file)) return null;
|
||||
|
||||
return {
|
||||
tag: release.tag_name,
|
||||
version: release.tag_name.slice(1),
|
||||
channel,
|
||||
feedUrl: `${DOWNLOAD_BASE}${release.tag_name}`,
|
||||
releaseUrl:
|
||||
release.html_url ||
|
||||
`https://github.com/${REPO}/releases/tag/${release.tag_name}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How this copy of the app was installed, which decides whether it can
|
||||
* replace itself:
|
||||
* - auto: NSIS, DMG, AppImage or deb, updated in place
|
||||
* - manual: MSI, Chocolatey, Flatpak, portable builds and 32-bit Windows, told where to download
|
||||
* - store: Mac App Store
|
||||
* - disabled: dev builds
|
||||
*/
|
||||
function getInstallKind({
|
||||
platform,
|
||||
arch,
|
||||
isPackaged,
|
||||
mas,
|
||||
env,
|
||||
execPath,
|
||||
resourcesPath,
|
||||
exists,
|
||||
readFile,
|
||||
}) {
|
||||
if (!isPackaged) return "disabled";
|
||||
if (mas) return "store";
|
||||
|
||||
if (platform === "linux") {
|
||||
if (env.FLATPAK_ID || exists("/.flatpak-info")) return "manual";
|
||||
if (env.APPIMAGE) return "auto";
|
||||
try {
|
||||
const type = readFile(path.join(resourcesPath, "package-type")).trim();
|
||||
// The AUR package reuses the deb's files, so ask dpkg if it owns us.
|
||||
if (type === "deb" && exists("/var/lib/dpkg/info/termix.list")) {
|
||||
return "auto";
|
||||
}
|
||||
} catch {
|
||||
// Not a package install.
|
||||
}
|
||||
return "manual";
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
if (arch !== "x64") return "manual";
|
||||
const uninstaller = path.join(
|
||||
path.dirname(execPath),
|
||||
"Uninstall Termix.exe",
|
||||
);
|
||||
return exists(uninstaller) ? "auto" : "manual";
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
// Still running from the mounted DMG.
|
||||
return execPath.startsWith("/Volumes/") ? "manual" : "auto";
|
||||
}
|
||||
|
||||
return "manual";
|
||||
}
|
||||
|
||||
function normalizeMode(mode) {
|
||||
return MODES.includes(mode) ? mode : DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function createUpdater({
|
||||
currentVersion,
|
||||
platform,
|
||||
arch,
|
||||
kind,
|
||||
getMode,
|
||||
saveMode,
|
||||
fetchReleases,
|
||||
loadAutoUpdater,
|
||||
send,
|
||||
log,
|
||||
beforeInstall,
|
||||
}) {
|
||||
const state = {
|
||||
kind,
|
||||
mode: normalizeMode(getMode()),
|
||||
currentVersion,
|
||||
status: "idle",
|
||||
version: null,
|
||||
releaseUrl: null,
|
||||
progress: null,
|
||||
error: null,
|
||||
};
|
||||
const enabled = kind === "auto" || kind === "manual";
|
||||
let updater = null;
|
||||
let timer = null;
|
||||
let checkedOnce = false;
|
||||
|
||||
function update(patch) {
|
||||
Object.assign(state, patch);
|
||||
send({ ...state });
|
||||
}
|
||||
|
||||
function getUpdater() {
|
||||
if (updater) return updater;
|
||||
updater = loadAutoUpdater();
|
||||
updater.logger = {
|
||||
info: (...args) => log("[updater]", ...args),
|
||||
warn: (...args) => log("[updater]", ...args),
|
||||
error: (...args) => log("[updater]", ...args),
|
||||
debug: () => {},
|
||||
};
|
||||
updater.autoInstallOnAppQuit = true;
|
||||
updater.on("update-available", (info) =>
|
||||
update({
|
||||
status: updater.autoDownload ? "downloading" : "available",
|
||||
version: info.version,
|
||||
progress: null,
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
updater.on("update-not-available", () =>
|
||||
update({ status: "up-to-date", error: null }),
|
||||
);
|
||||
updater.on("download-progress", (progress) =>
|
||||
update({
|
||||
status: "downloading",
|
||||
progress: Math.round(progress.percent || 0),
|
||||
}),
|
||||
);
|
||||
updater.on("update-downloaded", (info) =>
|
||||
update({
|
||||
status: "downloaded",
|
||||
version: info.version,
|
||||
progress: 100,
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
updater.on("error", (error) => {
|
||||
log("[updater] error:", error?.message || String(error));
|
||||
update({ status: "error", error: error?.message || String(error) });
|
||||
});
|
||||
return updater;
|
||||
}
|
||||
|
||||
async function check() {
|
||||
if (!enabled) return { ...state };
|
||||
if (["checking", "downloading", "downloaded"].includes(state.status)) {
|
||||
return { ...state };
|
||||
}
|
||||
checkedOnce = true;
|
||||
update({ status: "checking", error: null });
|
||||
|
||||
let release;
|
||||
try {
|
||||
release = resolveRelease(await fetchReleases(), {
|
||||
currentVersion,
|
||||
platform,
|
||||
arch,
|
||||
});
|
||||
} catch (error) {
|
||||
log("[updater] release lookup failed:", error?.message || String(error));
|
||||
update({ status: "error", error: error?.message || String(error) });
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
if (!release) {
|
||||
update({ status: "up-to-date" });
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
update({ version: release.version, releaseUrl: release.releaseUrl });
|
||||
if (kind === "manual") {
|
||||
update({ status: "available" });
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
try {
|
||||
const auto = getUpdater();
|
||||
auto.setFeedURL({ provider: "generic", url: release.feedUrl });
|
||||
auto.channel = release.channel;
|
||||
auto.allowDowngrade = false;
|
||||
auto.autoDownload = state.mode === "automatic";
|
||||
await auto.checkForUpdates();
|
||||
} catch (error) {
|
||||
log("[updater] check failed:", error?.message || String(error));
|
||||
update({ status: "error", error: error?.message || String(error) });
|
||||
}
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (kind !== "auto" || state.status !== "available") return { ...state };
|
||||
update({ status: "downloading", progress: 0 });
|
||||
try {
|
||||
await getUpdater().downloadUpdate();
|
||||
} catch (error) {
|
||||
log("[updater] download failed:", error?.message || String(error));
|
||||
update({ status: "error", error: error?.message || String(error) });
|
||||
}
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function install() {
|
||||
if (kind !== "auto" || state.status !== "downloaded") return false;
|
||||
// The tray keeps windows alive on close, so mark the quit first.
|
||||
beforeInstall?.();
|
||||
setImmediate(() => getUpdater().quitAndInstall());
|
||||
return true;
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
if (!MODES.includes(mode)) return { ...state };
|
||||
saveMode(mode);
|
||||
update({ mode });
|
||||
if (updater) updater.autoDownload = mode === "automatic";
|
||||
if (mode !== "never" && !checkedOnce) void check();
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!enabled || timer) return;
|
||||
setTimeout(() => {
|
||||
if (state.mode !== "never") void check();
|
||||
}, FIRST_CHECK_DELAY).unref?.();
|
||||
timer = setInterval(() => {
|
||||
if (state.mode !== "never") void check();
|
||||
}, CHECK_INTERVAL);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => ({ ...state }),
|
||||
check,
|
||||
download,
|
||||
install,
|
||||
setMode,
|
||||
start,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MODE,
|
||||
RELEASES_API,
|
||||
channelFile,
|
||||
compareVersions,
|
||||
createUpdater,
|
||||
getInstallKind,
|
||||
normalizeMode,
|
||||
parseVersion,
|
||||
resolveRelease,
|
||||
};
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
function createC2SAuthBridge(ipcMain, getWindow) {
|
||||
const pending = new Map();
|
||||
ipcMain.handle("answer-c2s-auth", (event, id, answer) => {
|
||||
const item = pending.get(id);
|
||||
if (!item || item.sender !== event.sender) return false;
|
||||
if (answer !== null && (typeof answer !== "string" || answer.length > 8192))
|
||||
return false;
|
||||
item.finish(answer);
|
||||
return true;
|
||||
});
|
||||
return function attach(ws, tunnelName) {
|
||||
const active = new Set();
|
||||
const close = () => {
|
||||
for (const id of active) pending.get(id)?.finish(null);
|
||||
ws.off("message", onMessage);
|
||||
ws.off("close", close);
|
||||
ws.off("error", close);
|
||||
};
|
||||
const onMessage = (raw, binary) => {
|
||||
if (binary) return;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type !== "auth-prompt") return;
|
||||
const window = getWindow();
|
||||
if (
|
||||
!window ||
|
||||
window.isDestroyed() ||
|
||||
active.size ||
|
||||
typeof message.requestId !== "string" ||
|
||||
!["totp", "input", "browser"].includes(message.request?.kind) // plugin-id-ok: SSH prompt kind, not the plugin
|
||||
) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
const sender = window.webContents;
|
||||
const id = randomUUID();
|
||||
const finish = (answer) => {
|
||||
if (!pending.delete(id)) return;
|
||||
active.delete(id);
|
||||
clearTimeout(timer);
|
||||
sender.off("destroyed", cancelled);
|
||||
sender.off("did-start-loading", cancelled);
|
||||
if (!sender.isDestroyed())
|
||||
sender.send("c2s-auth-prompt", { id, closed: true });
|
||||
if (ws.readyState === 1)
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth-response",
|
||||
requestId: message.requestId,
|
||||
answer,
|
||||
}),
|
||||
);
|
||||
if (answer === null) ws.close();
|
||||
};
|
||||
const cancelled = () => finish(null);
|
||||
const timer = setTimeout(cancelled, 60_000);
|
||||
active.add(id);
|
||||
pending.set(id, { sender, finish });
|
||||
sender.once("destroyed", cancelled);
|
||||
sender.once("did-start-loading", cancelled);
|
||||
sender.send("c2s-auth-prompt", {
|
||||
id,
|
||||
tunnelName,
|
||||
request: message.request,
|
||||
});
|
||||
};
|
||||
ws.on("message", onMessage);
|
||||
ws.once("close", close);
|
||||
ws.once("error", close);
|
||||
};
|
||||
}
|
||||
module.exports = { createC2SAuthBridge };
|
||||
@@ -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"), {}) };
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
const { isolatedUrl } = require("./isolated-window.cjs");
|
||||
|
||||
function cleanBounds(bounds) {
|
||||
const value = (n) => Math.max(0, Math.round(Number(n) || 0));
|
||||
return {
|
||||
x: value(bounds?.x),
|
||||
y: value(bounds?.y),
|
||||
width: value(bounds?.width),
|
||||
height: value(bounds?.height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Native web views drawn inside the main window, for pages that do not work
|
||||
* in a sandboxed iframe. Each one is a WebContentsView in its own
|
||||
* non-persistent session, with the same limits as an isolated window: no
|
||||
* permissions, no downloads, http(s) and ws(s) only. The renderer owns the
|
||||
* placement and sends the bounds of the element the view should cover.
|
||||
*/
|
||||
function createEmbeddedViews({ WebContentsView, session, getMainWindow }) {
|
||||
const views = new Map();
|
||||
|
||||
function destroy(id) {
|
||||
const entry = views.get(id);
|
||||
if (!entry) return;
|
||||
views.delete(id);
|
||||
const main = getMainWindow();
|
||||
if (main && !main.isDestroyed()) {
|
||||
try {
|
||||
main.contentView.removeChildView(entry.view);
|
||||
} catch {
|
||||
/* already detached */
|
||||
}
|
||||
}
|
||||
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
|
||||
void Promise.allSettled([
|
||||
entry.session.clearStorageData(),
|
||||
entry.session.clearCache(),
|
||||
entry.session.closeAllConnections(),
|
||||
]);
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
for (const id of [...views.keys()]) destroy(id);
|
||||
}
|
||||
|
||||
function open(options = {}) {
|
||||
const main = getMainWindow();
|
||||
if (!main) throw new Error("No window to attach a view to");
|
||||
const id = String(options.id || "");
|
||||
if (!id) throw new Error("A view needs an id");
|
||||
const url = isolatedUrl(options.url);
|
||||
const existing = views.get(id);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.url === url.href &&
|
||||
existing.ignoreCert === !!options.ignoreCert
|
||||
) {
|
||||
setBounds(id, options.bounds);
|
||||
setVisible(id, options.visible !== false);
|
||||
return { success: true };
|
||||
}
|
||||
destroy(id);
|
||||
}
|
||||
|
||||
const isolated = session.fromPartition(
|
||||
`embedded-view-${id}-${Date.now()}`,
|
||||
{
|
||||
cache: false,
|
||||
},
|
||||
);
|
||||
isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false));
|
||||
isolated.setPermissionCheckHandler(() => false);
|
||||
isolated.on("will-download", (event) => event.preventDefault());
|
||||
isolated.webRequest.onBeforeRequest((details, callback) => {
|
||||
const protocol = new URL(details.url).protocol;
|
||||
callback({
|
||||
cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol),
|
||||
});
|
||||
});
|
||||
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
session: isolated,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
},
|
||||
});
|
||||
const canNavigate = (target) => {
|
||||
try {
|
||||
return target === "about:blank" || !!isolatedUrl(target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for (const name of ["will-navigate", "will-redirect"]) {
|
||||
view.webContents.on(name, (event, target) => {
|
||||
if (!canNavigate(target)) event.preventDefault();
|
||||
});
|
||||
}
|
||||
view.webContents.setWindowOpenHandler(({ url: target }) =>
|
||||
canNavigate(target)
|
||||
? {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: { session: isolated, sandbox: true },
|
||||
},
|
||||
}
|
||||
: { action: "deny" },
|
||||
);
|
||||
|
||||
views.set(id, {
|
||||
view,
|
||||
session: isolated,
|
||||
url: url.href,
|
||||
origin: url.origin,
|
||||
ignoreCert: !!options.ignoreCert,
|
||||
});
|
||||
main.contentView.addChildView(view);
|
||||
setBounds(id, options.bounds);
|
||||
setVisible(id, options.visible !== false);
|
||||
view.webContents.loadURL(url.href).catch(() => {});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function setBounds(id, bounds) {
|
||||
const entry = views.get(id);
|
||||
if (entry) entry.view.setBounds(cleanBounds(bounds));
|
||||
}
|
||||
|
||||
function setVisible(id, visible) {
|
||||
const entry = views.get(id);
|
||||
if (entry) entry.view.setVisible(!!visible);
|
||||
}
|
||||
|
||||
function handleCertificateError(event, contents, url, callback) {
|
||||
for (const entry of views.values()) {
|
||||
if (entry.view.webContents !== contents) continue;
|
||||
event.preventDefault();
|
||||
let allowed = false;
|
||||
try {
|
||||
allowed = entry.ignoreCert && new URL(url).origin === entry.origin;
|
||||
} catch {
|
||||
/* malformed URL is refused */
|
||||
}
|
||||
callback(allowed);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
setBounds,
|
||||
setVisible,
|
||||
destroy,
|
||||
destroyAll,
|
||||
handleCertificateError,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createEmbeddedViews, cleanBounds };
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The login URL a server hands the desktop app goes to shell.openExternal,
|
||||
* so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...)
|
||||
* would launch whatever OS handler is registered for it.
|
||||
*/
|
||||
function isExternalLoginUrl(value) {
|
||||
try {
|
||||
const { protocol } = new URL(value);
|
||||
return protocol === "https:" || protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-privileged port for the loopback callback listener. */
|
||||
function isCallbackPort(value) {
|
||||
return Number.isInteger(value) && value >= 1024 && value <= 65535;
|
||||
}
|
||||
|
||||
module.exports = { isExternalLoginUrl, isCallbackPort };
|
||||
@@ -6,9 +6,16 @@
|
||||
|
||||
let linked = null;
|
||||
|
||||
const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" };
|
||||
|
||||
// A WebSocket to the linked server (wss://host/...) counts as the link's
|
||||
// https://host origin, so its upgrade carries the proxy headers too.
|
||||
function getOrigin(url) {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
const parsed = new URL(url);
|
||||
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
|
||||
if (httpProtocol) parsed.protocol = httpProtocol;
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -27,6 +34,8 @@ function setLinkedServer(config) {
|
||||
basicAuth:
|
||||
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
|
||||
allowInvalidCertificate: !!config.allowInvalidCertificate,
|
||||
clientCert:
|
||||
config.clientCert && config.clientCert.pfx ? config.clientCert : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +47,16 @@ function isLinkedOrigin(url) {
|
||||
return !!linked && getOrigin(url) === linked.origin;
|
||||
}
|
||||
|
||||
// The PFX of the link's client certificate, for TLS this process makes itself
|
||||
// to the linked origin. Null for any other origin.
|
||||
function clientCertOptionsFor(url) {
|
||||
if (!isLinkedOrigin(url) || !linked.clientCert) return {};
|
||||
return {
|
||||
pfx: Buffer.from(linked.clientCert.pfx, "base64"),
|
||||
passphrase: linked.clientCert.passphrase || "",
|
||||
};
|
||||
}
|
||||
|
||||
function basicAuthValue(basicAuth) {
|
||||
return `Basic ${Buffer.from(
|
||||
`${basicAuth.username}:${basicAuth.password || ""}`,
|
||||
@@ -92,6 +111,7 @@ module.exports = {
|
||||
setLinkedServer,
|
||||
getLinkedServer,
|
||||
isLinkedOrigin,
|
||||
clientCertOptionsFor,
|
||||
applyLinkHeaders,
|
||||
linkedRequestHeaders,
|
||||
answerLogin,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function isUsableShell(file, exists) {
|
||||
return !!file && (!path.isAbsolute(file) || exists(file));
|
||||
}
|
||||
|
||||
function resolveLocalShell(
|
||||
platform,
|
||||
requestedShell,
|
||||
env = process.env,
|
||||
exists = fs.existsSync,
|
||||
) {
|
||||
if (platform === "win32") {
|
||||
if (requestedShell === "wsl") {
|
||||
return { file: "wsl.exe", args: [] };
|
||||
}
|
||||
return {
|
||||
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
|
||||
args: ["-NoLogo"],
|
||||
};
|
||||
}
|
||||
|
||||
// A SHELL left pointing at an uninstalled shell makes the spawn fail.
|
||||
const fallbacks =
|
||||
platform === "darwin"
|
||||
? ["/bin/zsh", "/bin/bash", "/bin/sh"]
|
||||
: ["/bin/bash", "/bin/sh"];
|
||||
const file =
|
||||
[env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find((candidate) =>
|
||||
isUsableShell(candidate, exists),
|
||||
) || fallbacks[0];
|
||||
return { file, args: ["-l"] };
|
||||
}
|
||||
|
||||
/**
|
||||
* node-pty starts every shell through its spawn-helper binary, and a packaged
|
||||
* copy that lost its execute bit fails with "posix_spawnp failed". Returns
|
||||
* how many helpers were fixed.
|
||||
*/
|
||||
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
||||
const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
|
||||
const candidates = [
|
||||
path.join(dir, "build", "Release", "spawn-helper"),
|
||||
path.join(
|
||||
dir,
|
||||
"prebuilds",
|
||||
`${process.platform}-${process.arch}`,
|
||||
"spawn-helper",
|
||||
),
|
||||
];
|
||||
let fixed = 0;
|
||||
for (const helper of candidates) {
|
||||
try {
|
||||
const mode = fsImpl.statSync(helper).mode;
|
||||
if ((mode & 0o111) === 0) {
|
||||
fsImpl.chmodSync(helper, mode | 0o755);
|
||||
fixed += 1;
|
||||
}
|
||||
} catch {
|
||||
// Missing or read-only; nothing to fix here.
|
||||
}
|
||||
}
|
||||
return fixed;
|
||||
}
|
||||
|
||||
module.exports = { resolveLocalShell, repairSpawnHelpers };
|
||||
@@ -1,85 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function singleLine(value, maxLength = 255) {
|
||||
return String(value ?? "")
|
||||
.replace(/[\r\n\0]/g, "")
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function validateNativeRdpOptions(options) {
|
||||
const host = singleLine(options?.host);
|
||||
const port = Number(options?.port ?? 3389);
|
||||
if (!host || host.length > 253 || /[\\/]/.test(host)) {
|
||||
throw new Error("Invalid RDP host");
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Invalid RDP port");
|
||||
}
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
username: singleLine(options?.username),
|
||||
domain: singleLine(options?.domain),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRdpFile(options) {
|
||||
const { host, port, username, domain } = validateNativeRdpOptions(options);
|
||||
const address = host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
const qualifiedUsername = username
|
||||
? domain
|
||||
? `${domain}\\${username}`
|
||||
: username
|
||||
: "";
|
||||
return [
|
||||
`full address:s:${address}`,
|
||||
"prompt for credentials:i:1",
|
||||
"administrative session:i:0",
|
||||
...(qualifiedUsername ? [`username:s:${qualifiedUsername}`] : []),
|
||||
"",
|
||||
].join("\r\n");
|
||||
}
|
||||
|
||||
async function launchNativeRdp(options, platform = process.platform) {
|
||||
if (platform !== "win32") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Windows Remote Desktop is only available on Windows",
|
||||
};
|
||||
}
|
||||
|
||||
const rdpContent = buildRdpFile(options);
|
||||
const tempDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "termix-rdp-"),
|
||||
);
|
||||
const rdpPath = path.join(tempDir, "connection.rdp");
|
||||
await fs.promises.writeFile(rdpPath, rdpContent, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("mstsc.exe", [rdpPath], {
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const cleanup = () =>
|
||||
fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
child.once("error", (error) => {
|
||||
cleanup();
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
child.once("spawn", () => {
|
||||
child.unref();
|
||||
setTimeout(cleanup, 30_000).unref();
|
||||
resolve({ success: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { buildRdpFile, launchNativeRdp, validateNativeRdpOptions };
|
||||
@@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
|
||||
// The embedded backend's port, picked by the main process at startup.
|
||||
const backendPortArg = process.argv.find((arg) =>
|
||||
arg.startsWith("--termix-backend-port="),
|
||||
);
|
||||
const backendPort = backendPortArg
|
||||
? Number(backendPortArg.split("=")[1])
|
||||
: undefined;
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
backendPort,
|
||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||
// Kept so the interface size can reset a zoom an earlier build applied.
|
||||
setZoomFactor: (factor) => {
|
||||
@@ -26,42 +35,29 @@ 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",
|
||||
|
||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
||||
answerC2SAuth: (id, answer) =>
|
||||
ipcRenderer.invoke("answer-c2s-auth", id, answer),
|
||||
onC2SAuthPrompt: (callback) => {
|
||||
const listener = (_event, prompt) => callback(prompt);
|
||||
ipcRenderer.on("c2s-auth-prompt", listener);
|
||||
return () => ipcRenderer.removeListener("c2s-auth-prompt", listener);
|
||||
},
|
||||
getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"),
|
||||
saveC2STunnelConfig: (config) =>
|
||||
ipcRenderer.invoke("save-c2s-tunnel-config", config),
|
||||
checkLocalPortAvailable: (host, port) =>
|
||||
ipcRenderer.invoke("check-local-port-available", host, port),
|
||||
getC2STunnelPresetDefaultName: () =>
|
||||
ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"),
|
||||
startC2STunnel: (tunnel, index, authToken) =>
|
||||
ipcRenderer.invoke("start-c2s-tunnel", tunnel, index, authToken),
|
||||
testC2STunnel: (tunnel, index, authToken) =>
|
||||
ipcRenderer.invoke("test-c2s-tunnel", tunnel, index, authToken),
|
||||
stopC2STunnel: (tunnelName) =>
|
||||
ipcRenderer.invoke("stop-c2s-tunnel", tunnelName),
|
||||
getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"),
|
||||
onC2STunnelStatuses: (callback) => {
|
||||
const listener = (_event, statuses) => callback(statuses);
|
||||
ipcRenderer.on("c2s-tunnel-statuses", listener);
|
||||
return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener);
|
||||
},
|
||||
startC2SAutoStartTunnels: () =>
|
||||
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
|
||||
setC2SRelayPath: (relayPath) =>
|
||||
ipcRenderer.invoke("set-c2s-relay-path", relayPath),
|
||||
|
||||
onCloseActiveTab: (callback) => {
|
||||
const listener = () => callback();
|
||||
@@ -70,46 +66,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
},
|
||||
|
||||
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
||||
getSessionCookie: (name, targetUrl) =>
|
||||
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
||||
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
|
||||
ipcRenderer.invoke(
|
||||
"wait-session-cookie",
|
||||
name,
|
||||
targetUrl,
|
||||
previousValue,
|
||||
timeoutMs,
|
||||
),
|
||||
|
||||
externalBrowserLogin: (authUrl, callbackPort) =>
|
||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||
|
||||
openExternalEditor: (fileData) =>
|
||||
ipcRenderer.invoke("open-external-editor", fileData),
|
||||
closeExternalEditor: (editId) =>
|
||||
ipcRenderer.invoke("close-external-editor", editId),
|
||||
onExternalEditorSaved: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("external-editor-saved", listener);
|
||||
return () => ipcRenderer.removeListener("external-editor-saved", listener);
|
||||
},
|
||||
|
||||
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||
getLocalHomeDirectory: () => ipcRenderer.invoke("get-local-home-directory"),
|
||||
listLocalDirectory: (dirPath) =>
|
||||
ipcRenderer.invoke("list-local-directory", dirPath),
|
||||
statLocalPaths: (paths) => ipcRenderer.invoke("stat-local-paths", paths),
|
||||
collectLocalFiles: (paths) =>
|
||||
ipcRenderer.invoke("collect-local-files", paths),
|
||||
createLocalFolder: (parentPath, folderName) =>
|
||||
ipcRenderer.invoke("create-local-folder", parentPath, folderName),
|
||||
renameLocalPath: (entryPath, newName) =>
|
||||
ipcRenderer.invoke("rename-local-path", entryPath, newName),
|
||||
trashLocalPath: (entryPath) =>
|
||||
ipcRenderer.invoke("trash-local-path", entryPath),
|
||||
chmodLocalPath: (entryPath, permissions) =>
|
||||
ipcRenderer.invoke("chmod-local-path", entryPath, permissions),
|
||||
createTempFile: (fileData) =>
|
||||
ipcRenderer.invoke("create-temp-file", fileData),
|
||||
createTempFolder: (folderData) =>
|
||||
@@ -118,61 +80,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
||||
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
||||
|
||||
startLocalTerminal: (dimensions) =>
|
||||
ipcRenderer.invoke("local-terminal-start", dimensions),
|
||||
writeLocalTerminal: (sessionId, data) =>
|
||||
ipcRenderer.invoke("local-terminal-write", sessionId, data),
|
||||
readyLocalTerminal: (sessionId) =>
|
||||
ipcRenderer.invoke("local-terminal-ready", sessionId),
|
||||
resizeLocalTerminal: (sessionId, cols, rows) =>
|
||||
ipcRenderer.invoke("local-terminal-resize", sessionId, cols, rows),
|
||||
closeLocalTerminal: (sessionId) =>
|
||||
ipcRenderer.invoke("local-terminal-close", sessionId),
|
||||
onLocalTerminalData: (sessionId, callback) => {
|
||||
const channel = `local-terminal:data:${sessionId}`;
|
||||
const listener = (_event, data) => callback(data);
|
||||
ipcRenderer.on(channel, listener);
|
||||
return () => ipcRenderer.removeListener(channel, listener);
|
||||
},
|
||||
onLocalTerminalExit: (sessionId, callback) => {
|
||||
const channel = `local-terminal:exit:${sessionId}`;
|
||||
const listener = (_event, exitCode) => callback(exitCode);
|
||||
ipcRenderer.on(channel, listener);
|
||||
return () => ipcRenderer.removeListener(channel, listener);
|
||||
},
|
||||
|
||||
// Dual-pane file manager: local disk browsing and streamed transfers.
|
||||
localFs: {
|
||||
home: () => ipcRenderer.invoke("local-fs:home"),
|
||||
list: (dirPath) => ipcRenderer.invoke("local-fs:list", dirPath),
|
||||
mkdir: (parentPath, name) =>
|
||||
ipcRenderer.invoke("local-fs:mkdir", parentPath, name),
|
||||
createFile: (parentPath, name) =>
|
||||
ipcRenderer.invoke("local-fs:create-file", parentPath, name),
|
||||
rename: (oldPath, newName) =>
|
||||
ipcRenderer.invoke("local-fs:rename", oldPath, newName),
|
||||
trash: (paths) => ipcRenderer.invoke("local-fs:trash", paths),
|
||||
ensureDir: (dirPath, rootPath) =>
|
||||
ipcRenderer.invoke("local-fs:ensure-dir", dirPath, rootPath),
|
||||
exists: (paths) => ipcRenderer.invoke("local-fs:exists", paths),
|
||||
walk: (paths) => ipcRenderer.invoke("local-fs:walk", paths),
|
||||
reveal: (targetPath) => ipcRenderer.invoke("local-fs:reveal", targetPath),
|
||||
open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath),
|
||||
},
|
||||
localTransfer: {
|
||||
setApiPath: (apiPath) =>
|
||||
ipcRenderer.invoke("local-transfer:set-api", apiPath),
|
||||
upload: (options) => ipcRenderer.invoke("local-transfer:upload", options),
|
||||
download: (options) =>
|
||||
ipcRenderer.invoke("local-transfer:download", options),
|
||||
cancel: (transferId) =>
|
||||
ipcRenderer.invoke("local-transfer:cancel", transferId),
|
||||
onProgress: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("local-transfer:progress", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("local-transfer:progress", listener);
|
||||
},
|
||||
// Native web views placed over an element of the page (WebContentsView).
|
||||
embeddedView: {
|
||||
open: (options) => ipcRenderer.invoke("embedded-view-open", options),
|
||||
setBounds: (id, bounds) =>
|
||||
ipcRenderer.invoke("embedded-view-bounds", id, bounds),
|
||||
setVisible: (id, visible) =>
|
||||
ipcRenderer.invoke("embedded-view-visible", id, visible),
|
||||
close: (id) => ipcRenderer.invoke("embedded-view-close", id),
|
||||
},
|
||||
|
||||
invoke: invokeAllowed,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
|
||||
function renameLocalPath(source, target) {
|
||||
if (source === target) return;
|
||||
try {
|
||||
fs.lstatSync(target);
|
||||
const error = new Error("A file or folder with that name already exists");
|
||||
error.code = "EEXIST";
|
||||
throw error;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (fs.lstatSync(source).isDirectory()) {
|
||||
fs.renameSync(source, target);
|
||||
return;
|
||||
}
|
||||
// link is exclusive: a destination created concurrently is never replaced.
|
||||
fs.linkSync(source, target);
|
||||
fs.unlinkSync(source);
|
||||
}
|
||||
|
||||
module.exports = { renameLocalPath };
|
||||
@@ -71,7 +71,6 @@
|
||||
"group": "server",
|
||||
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ENABLE_SSL",
|
||||
"group": "https",
|
||||
@@ -108,7 +107,6 @@
|
||||
"internal": true,
|
||||
"description": "Set by the image when nginx serves HTTPS in front of the backend."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "DATABASE_DIALECT",
|
||||
"group": "database",
|
||||
@@ -161,7 +159,6 @@
|
||||
"internal": true,
|
||||
"description": "Where database migrations are read from. Set by the image."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "JWT_SECRET",
|
||||
"group": "secrets",
|
||||
@@ -204,7 +201,6 @@
|
||||
"secret": true,
|
||||
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ALLOW_PASSWORD_LOGIN",
|
||||
"group": "auth",
|
||||
@@ -283,7 +279,6 @@
|
||||
"group": "auth",
|
||||
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "TRUSTED_PROXIES",
|
||||
"group": "proxy",
|
||||
@@ -315,7 +310,6 @@
|
||||
"group": "proxy",
|
||||
"description": "Comma separated hosts that skip the proxy."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "LOG_LEVEL",
|
||||
"group": "logging",
|
||||
@@ -338,33 +332,26 @@
|
||||
"default": "10000",
|
||||
"description": "Most audit entries kept. The oldest go first."
|
||||
},
|
||||
{
|
||||
"name": "AUDIT_LOG_FORWARD_URL",
|
||||
"group": "logging",
|
||||
"description": "Also POST every audit entry as JSON to this URL. Overrides the admin setting."
|
||||
},
|
||||
{
|
||||
"name": "AUDIT_LOG_FORWARD_TOKEN",
|
||||
"group": "logging",
|
||||
"secret": true,
|
||||
"description": "Bearer token sent with forwarded audit entries."
|
||||
},
|
||||
{
|
||||
"name": "SESSION_RECORDING_RETENTION_DAYS",
|
||||
"group": "logging",
|
||||
"deprecated": "the session-recording plugin setting",
|
||||
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "TERMIX_PLUGIN_REGISTRY_URL",
|
||||
"group": "plugins",
|
||||
"description": "Use another plugin index instead of the official registry."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_COMMUNITY_REGISTRY_URL",
|
||||
"group": "plugins",
|
||||
"description": "Use another plugin index instead of the community registry. Its plugins still have to be signed with the community key."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_PLUGIN_STATS_URL",
|
||||
"group": "plugins",
|
||||
"description": "Where install counts are read from. Defaults to stats.json next to the registry index."
|
||||
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||
@@ -372,6 +359,12 @@
|
||||
"default": "false",
|
||||
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_SAFE_LAYOUT",
|
||||
"group": "plugins",
|
||||
"default": "false",
|
||||
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||
"group": "plugins",
|
||||
@@ -401,7 +394,6 @@
|
||||
"internal": true,
|
||||
"description": "Set by npm run dev."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ENABLE_INSECURE_MODE",
|
||||
"group": "desktop",
|
||||
@@ -430,6 +422,18 @@
|
||||
"internal": true,
|
||||
"description": "Set by the desktop app when it runs its own backend."
|
||||
},
|
||||
{
|
||||
"name": "ELECTRON_USER_DATA",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Set by the desktop app to its user data folder."
|
||||
},
|
||||
{
|
||||
"name": "EMBEDDED_BACKEND_PORT",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_DATA_DIR",
|
||||
"group": "desktop",
|
||||
@@ -442,14 +446,24 @@
|
||||
"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",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Read on Linux to pick the password store."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "VITE_BASE_PATH",
|
||||
"group": "internal",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.0",
|
||||
"version": "26.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.0",
|
||||
"version": "26.10.3",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -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"
|
||||
@@ -15015,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.13",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.0",
|
||||
"version": "26.10.3",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -15,10 +15,10 @@
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
|
||||
"prepare": "husky || true",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "npm run build:sdk && tsc -b --force",
|
||||
"test": "vitest run",
|
||||
@@ -30,7 +30,7 @@
|
||||
"dev:electron": "node scripts/dev.mjs electron",
|
||||
"dev:docker": "node scripts/dev.mjs docker",
|
||||
"dev:frontend": "vite",
|
||||
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
|
||||
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.18
|
||||
|
||||
Plugin API 1.10.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `clientCert` on the sync link, link patch and remote target, and the `client_cert` remote error kind, so a sync plugin can link to a server that needs mutual TLS
|
||||
|
||||
## 1.0.17
|
||||
|
||||
Plugin API 1.9.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running
|
||||
- `NativeView` in `@termix-ssh/plugin-sdk/ui`, which shows a web page in a native view in the desktop app, for sites that do not work in an iframe
|
||||
|
||||
## 1.0.16
|
||||
|
||||
Plugin API 1.8.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
|
||||
- `ctx.ssh.transports.provide` and the `ssh:transport` capability, so a plugin can carry SSH connections over its own stream
|
||||
- `ctx.hosts.folders.ensure` and `removeEmpty`
|
||||
|
||||
### Removed
|
||||
|
||||
- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin
|
||||
|
||||
## 1.0.15
|
||||
|
||||
Plugin API 1.7.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.auth.policy.provide` and the `auth:policy` capability, so a plugin can set an idle timeout, cap session length, turn off remember me and require a second factor
|
||||
- `ctx.auth.listSecondFactors` lists the second factors from every running plugin
|
||||
- `AuthEnrollmentSections` takes `factorsOnly`
|
||||
- `getHttpsSettings` and `updateHttpsSettings` in `@termix-ssh/plugin-sdk/ui`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Plugin frontend tests against a locally linked SDK failed with a second copy of React
|
||||
|
||||
## 1.0.14
|
||||
|
||||
Plugin API 1.6.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.hosts.locks.provide` and the `hosts:lock` capability, so a plugin can manage hosts that stay read only for everyone else and block adding hosts by hand
|
||||
- `ctx.hosts.setShares` to replace every grant on a host in one call
|
||||
- `ctx.credentials.list` returns the acting user's credentials by name, without secrets
|
||||
- `ctx.hosts.create` and `update` take `password`, `key`, `keyPassword`, `keyType`, `sudoPassword`, `protocolAuth` and `pluginEnable`
|
||||
|
||||
## 1.0.13
|
||||
|
||||
Plugin API 1.5.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `runtime: "desktop"` in the manifest for a plugin that only loads on the desktop app
|
||||
- `desktop:local` and `ctx.desktop.local`: file system, local shell and machine info on the desktop app's own backend
|
||||
- `ctx.audit.subscribe`, `ctx.audit.list` and `ctx.audit.exportBatches` under `audit:read`
|
||||
- `ctx.hosts.importMany`, `importSshConfig`, `exportMany` and `registerImportNormalizer`, with the `hosts:export` capability
|
||||
- `ctx.credentials.publicKey` and `resolveKeyRefs`, `ctx.ssh.exec`, and `@termix-ssh/plugin-sdk/ssh-keys`
|
||||
- `ctx.hosts.status.provide` and `ctx.hosts.activity.provide` so a plugin can own host status and recent activity, with `ctx.network.tcpProbe`, `ctx.ssh.probe`, `ctx.hosts.probeTargets` and `ctx.hosts.sessionActive`
|
||||
- `registerHostStatusIndicator` and `registerHostEditorToolbarItem`, and `order` for host badges
|
||||
- Onboarding steps can sit in a `start` section, take a `position` and `replaces` a core step
|
||||
- A plugin can provide the app name, tagline and logo through the `<id>.brandingProvider` registry key
|
||||
|
||||
## 1.0.12
|
||||
|
||||
Plugin API 1.4.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
|
||||
- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
|
||||
- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries. The shell's home tab is left out until some plugin registers a `dashboard` tab type
|
||||
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
|
||||
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
|
||||
- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
|
||||
- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
|
||||
- A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
|
||||
- `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
|
||||
|
||||
### Fixed
|
||||
|
||||
- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
|
||||
- Plugin tests and type checks work with the SDK linked from a local Termix checkout
|
||||
|
||||
## 1.0.11
|
||||
|
||||
Plugin API 1.3.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
|
||||
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
|
||||
|
||||
## 1.0.10
|
||||
|
||||
### Changed
|
||||
|
||||
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
|
||||
|
||||
## 1.0.9
|
||||
|
||||
### Added
|
||||
|
||||
- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/<name>.js`
|
||||
- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests
|
||||
|
||||
### Fixed
|
||||
|
||||
- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled
|
||||
|
||||
## 1.0.8
|
||||
|
||||
### Added
|
||||
|
||||
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
|
||||
|
||||
## 1.0.7
|
||||
|
||||
### Fixed
|
||||
|
||||
- A migration file, or a part of one, holding only comments made core fail to start the plugin
|
||||
|
||||
## 1.0.6
|
||||
|
||||
### Added
|
||||
|
||||
- `PLUGIN-GUIDE.md`, a guide to building, testing and releasing a plugin, written so AI coding tools can read it too
|
||||
|
||||
## 1.0.5
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,982 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.18 | Plugin API 1.10.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
Run `npm update @termix-ssh/plugin-sdk` to get the newest one.
|
||||
|
||||
If this guide and the SDK's TypeScript types disagree, the types are right. They live next to this file
|
||||
in `dist/*.d.ts`. The long form of everything here is at https://docs.termix.site/develop, and the raw
|
||||
Markdown of each page is at
|
||||
`https://raw.githubusercontent.com/Termix-SSH/Docs/main/develop/<page>.md` (for example
|
||||
`develop/backend.md`), which is easier for tools to read than the site.
|
||||
|
||||
## What a plugin is
|
||||
|
||||
Termix is self-hosted, plugin-based server management. Core keeps hosts, credentials, users, sharing and
|
||||
the shell. Everything else, even the SSH terminal, is a plugin built with this SDK.
|
||||
|
||||
A plugin is:
|
||||
|
||||
- `manifest.json`: what it is, what it adds and what it may do. Termix reads it without running any code.
|
||||
- A backend, `src/backend/index.ts`, run inside the Termix server process. It exports `activate(ctx)`.
|
||||
- A frontend, `src/frontend/index.tsx`, run in the browser in the same page as Termix. It exports
|
||||
`activate(app)`.
|
||||
|
||||
There is no sandbox. Capabilities make a plugin honest and easy to audit, but code runs with the server's
|
||||
rights. Write it as if an admin will read every line, because reviewers do.
|
||||
|
||||
## Hard rules
|
||||
|
||||
Break one of these and the plugin fails to load, fails validation or gets rejected in review.
|
||||
|
||||
1. Import only `@termix-ssh/plugin-sdk/*`, your own files and npm packages. Never import Termix's own code.
|
||||
2. Create everything inside `activate`. No servers, timers, sockets or connections at the top of a module.
|
||||
Disable then enable runs `activate` again on the same module, and it must work without a restart.
|
||||
3. Clean up what you make yourself with `ctx.disposables.add(fn)` (backend) or `app.onDispose(fn)`
|
||||
(frontend). Things made through `ctx` and `app` are cleaned up for you.
|
||||
4. Never call `process.exit` or touch signals.
|
||||
5. Never open your own SSH connection. Use `ctx.ssh` so jump hosts, proxies, host keys, every auth type and
|
||||
sharing work the way the user set them up.
|
||||
6. Outbound HTTP goes through `ctx.fetch` (needs `network:outbound`), not a raw `fetch` or axios.
|
||||
7. Ask for the fewest capabilities you can. A call that needs one you didn't list throws
|
||||
`PluginCapabilityError`.
|
||||
8. Never take a user id from a request body or a service caller. Use `ctx.currentActor()`.
|
||||
9. Check permissions on the backend. Frontend checks only hide UI.
|
||||
10. Call `await ctx.db.persist()` after every database write. On SQLite a write without it can be lost on
|
||||
restart.
|
||||
11. Never use `.returning()` in a query. MySQL doesn't have it. Read a new row back by a key you chose.
|
||||
12. Never edit a migration that has shipped. Termix checks a checksum and refuses to start the plugin. Add a
|
||||
new one.
|
||||
13. Never index a `text()` or `json()` column. Use `varchar(n)` for anything you index.
|
||||
14. Never hard code UI text. Every string goes in `locales/en.json` and is read with `t()`.
|
||||
15. The plugin `id` never changes once released. It names your tables, routes and permissions.
|
||||
16. The version in `manifest.json`, `package.json` and the newest `CHANGELOG.md` section must match.
|
||||
17. Put an `@openapi` JSDoc block above every route.
|
||||
18. No obfuscated or minified source, and no code downloaded and run at runtime.
|
||||
19. No tracking, telemetry or network calls the user didn't ask for. If you need one, make it off by
|
||||
default and explain it in your docs.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
manifest.json what the plugin is, adds and may do
|
||||
package.json npm scripts and dependencies
|
||||
CHANGELOG.md release notes, one section per version
|
||||
README.md the repo front page
|
||||
AGENTS.md notes for AI tools working on this plugin
|
||||
docs/index.md the plugin's docs page (more pages next to it)
|
||||
src/backend/index.ts activate(ctx), runs in the Termix server
|
||||
src/backend/tables.ts table definitions, exports `tables`
|
||||
src/backend/routes.ts HTTP routes
|
||||
src/frontend/index.tsx activate(app), runs in the browser
|
||||
locales/en.json every string the plugin shows
|
||||
locales/translated/ other languages, like de_DE.json (optional)
|
||||
migrations/ sqlite/, postgres/, mysql/ and snapshot.json, made by npm run migrations
|
||||
tests/backend/ backend tests (Node)
|
||||
tests/frontend/ frontend tests (jsdom)
|
||||
vitest.config.ts uses the SDK preset
|
||||
.github/workflows/ ci.yml and release.yml, calling shared workflows in Termix-Registry
|
||||
dist/ build output, never commit it
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # termix-plugin build: dist/backend.js, dist/frontend.js, CSS, openapi.json
|
||||
npm test # termix-plugin test: Vitest
|
||||
npm run typecheck # tsc
|
||||
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
|
||||
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
|
||||
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
|
||||
npm run format # Prettier
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
## Done checklist
|
||||
|
||||
Before you call a change finished:
|
||||
|
||||
- [ ] `npm test` passes
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm run validate` passes with no warnings you can fix
|
||||
- [ ] `npm run build` passes
|
||||
- [ ] `npm run format:check` passes
|
||||
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
|
||||
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
|
||||
- [ ] New routes have an `@openapi` block and a test, including without the permission
|
||||
- [ ] New env vars are listed in `env` in the manifest
|
||||
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
|
||||
- [ ] User facing changes are in `CHANGELOG.md` under the version they will ship in
|
||||
|
||||
## Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json",
|
||||
"id": "hello",
|
||||
"name": "Hello",
|
||||
"version": "1.0.0",
|
||||
"description": "Says hello and keeps a few notes.",
|
||||
"author": { "name": "You", "url": "https://example.com" },
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/you/termix-plugin-hello",
|
||||
"docs": "https://github.com/you/termix-plugin-hello/tree/main/docs",
|
||||
"category": "Productivity",
|
||||
"icon": "Hand",
|
||||
"features": ["A tab from the sidebar", "Notes per user"],
|
||||
"engine": { "termix": ">=26.10.0", "api": "^1.2" },
|
||||
"capabilities": ["db:own", "network:serve", "ui:surface"],
|
||||
"env": [],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "hello",
|
||||
"titleKey": "tab.title",
|
||||
"icon": "Hand",
|
||||
"openFrom": ["rail"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
```
|
||||
|
||||
Unknown fields are refused at every level, so a typo fails validation. The schema at
|
||||
`node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json` has every field.
|
||||
|
||||
| Field | Notes |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Lowercase, starts with a letter, 2 to 40 characters, letters, numbers and dashes. Can't be an official plugin's id. |
|
||||
| `version` | Semver. Betas are `X.Y.Z-beta.N`. |
|
||||
| `category` | One of: Terminal, Files & Transfer, Infrastructure, Monitoring, Networking, Access & Security, Productivity. |
|
||||
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
|
||||
| `features` | Up to 20 short lines shown on the plugin's page. |
|
||||
| `docs` | An https link to your docs. |
|
||||
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. Older Termix builds don't list a release they can't run. |
|
||||
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
|
||||
| `capabilities` | What your code may do. See the reference at the end. |
|
||||
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
|
||||
| `provides`, `requires` | Services you offer and use. |
|
||||
| `env` | Every environment variable your backend reads. `validate` warns about unlisted `process.env.X`. Prefer settings. |
|
||||
| `desktop` | `mirror` (default), `local` or `server`: how a linked desktop app treats the plugin. |
|
||||
| `nativeDependencies` | npm packages with `.node` bindings. Avoid them: community plugins can't count on them. |
|
||||
|
||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
||||
|
||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||
|
||||
## Backend
|
||||
|
||||
```ts
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { notes } from "./tables.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const notesTable = await ctx.db.define(notes);
|
||||
registerRoutes(ctx.http.router<Router>(), ctx, notesTable);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything registered through ctx is disposed by core.
|
||||
}
|
||||
```
|
||||
|
||||
Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own files use `.js`.
|
||||
|
||||
### What ctx has
|
||||
|
||||
| Member | Capability | What it does |
|
||||
| ---------------------- | -------------------------------- | --------------------------------------------------------------- |
|
||||
| `pluginId`, `manifest` | none | Your id and manifest |
|
||||
| `log` | none | Log lines tagged with your plugin |
|
||||
| `db` | `db:own` | Your own tables |
|
||||
| `kv` | `kv:own` | Small key value state |
|
||||
| `files.dataDir()` | `files:own` | A folder of your own |
|
||||
| `settings` | none | Your settings |
|
||||
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
|
||||
| `http`, `ws` | `network:serve` | Routes and sockets |
|
||||
| `rbac` | none | Check the actor's permissions |
|
||||
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
|
||||
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
|
||||
| `credentials` | varies | The actor's saved keys, and protocol logins |
|
||||
| `services` | per service | Call other plugins and offer your own |
|
||||
| `events` | `events:core` for core topics | Emit and listen for events |
|
||||
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
|
||||
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
|
||||
| `process` | `process:spawn` | Run programs on the server |
|
||||
| `schedule` | none | Timers that stop on disable and never overlap |
|
||||
| `audit.record` | none | Write your own audit line |
|
||||
| `sync` | none | Sync your tables to a linked desktop |
|
||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
||||
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
|
||||
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
|
||||
| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream |
|
||||
| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty |
|
||||
| `users.getLocale` | `users:read` | A user's UI language |
|
||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
||||
| `currentActor()` | none | The user the current call runs for |
|
||||
| `disposables.add(fn)` | none | Extra cleanup on disable |
|
||||
|
||||
### The actor
|
||||
|
||||
In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()` returns it. Background
|
||||
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
|
||||
that user's permissions.
|
||||
|
||||
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
|
||||
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
|
||||
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
|
||||
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
|
||||
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
|
||||
UI language, like `zh_CN`, or null, for writing to them in it.
|
||||
|
||||
### Routes
|
||||
|
||||
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
|
||||
your own port or an nginx change. Before your handler runs, Termix checks sign in, sets the actor, answers
|
||||
503 if the plugin is off, parses JSON up to 2 MB and catches errors.
|
||||
|
||||
```ts
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/hello/notes:
|
||||
* post:
|
||||
* summary: Add a note for the signed-in user
|
||||
* tags:
|
||||
* - Hello
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* text:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Note added
|
||||
* 400:
|
||||
* description: Missing or empty text
|
||||
*/
|
||||
router.post("/notes", async (req, res) => {
|
||||
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
||||
if (!text) return res.status(400).json({ error: "text is required" });
|
||||
const db = await ctx.db.client<BetterSQLite3Database>();
|
||||
await db
|
||||
.insert(notesTable)
|
||||
.values({ userId: ctx.currentActor() as string, text });
|
||||
await ctx.db.persist();
|
||||
res.status(201).json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
||||
`dist/openapi.json` and the docs site renders them.
|
||||
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
||||
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
|
||||
`publicRateLimit` raises that for a proxied page that loads its own assets.
|
||||
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
|
||||
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
||||
`conn.isDataUnlocked()` per message.
|
||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||
`hosts.edit` checks a core permission.
|
||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
||||
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
||||
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
||||
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
|
||||
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
|
||||
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
|
||||
`ctx.credentials.list()` finds the acting user's credentials by name.
|
||||
|
||||
### Errors
|
||||
|
||||
Errors from routes, sockets, listeners and timers count against an error budget. Five in a minute and
|
||||
Termix turns the plugin off (state `failed`). A 4xx you send on purpose doesn't count. Validate input and
|
||||
send 400s instead of throwing.
|
||||
|
||||
### SSH
|
||||
|
||||
```ts
|
||||
const { client, dispose } = await ctx.ssh.connect(hostId, {
|
||||
purpose: "hello",
|
||||
profile: "background",
|
||||
});
|
||||
try {
|
||||
// use the ssh2 client
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
```
|
||||
|
||||
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
|
||||
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
||||
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
||||
|
||||
### The desktop app
|
||||
|
||||
Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never
|
||||
loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare
|
||||
`desktop:local` and check `ctx.desktop.local.available()` at runtime.
|
||||
|
||||
`ctx.desktop.local` works on the desktop app's own backend only:
|
||||
|
||||
- `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null
|
||||
- `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`,
|
||||
`openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and
|
||||
an existing file is never replaced by accident
|
||||
- `pty.spawn()`: a local shell, stopped when the plugin stops
|
||||
- `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer
|
||||
|
||||
Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers
|
||||
are called after a line is accepted and never delay the action that wrote it.
|
||||
|
||||
### Events, timers, alerts, services
|
||||
|
||||
```ts
|
||||
ctx.events.on("host.deleted", ({ hostId }) => cleanUp(hostId)); // core topics need events:core
|
||||
ctx.events.emit("plugin.hello.greeted", { name }); // your own: plugin.<id>.<name>
|
||||
ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
|
||||
```
|
||||
|
||||
- Alerts go through `ctx.notify.send` (`notify:send`). Never build your own notification system.
|
||||
- Offer a service: declare it in `provides` with a version and a permission, then
|
||||
`ctx.services.provide("hello.greetings", impl)`. The name starts with your id. Methods read the caller
|
||||
from `ctx.currentActor()`.
|
||||
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
|
||||
nothing while no provider runs, so always handle that.
|
||||
|
||||
### Shared packages
|
||||
|
||||
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
||||
`cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in
|
||||
`peerDependencies`. Any other npm package is bundled.
|
||||
|
||||
### Workers and WebAssembly
|
||||
|
||||
Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name
|
||||
each worker entry in `package.json`:
|
||||
|
||||
```json
|
||||
"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
|
||||
```
|
||||
|
||||
`build` bundles it alone into `dist/workers/engine.js`; start it with
|
||||
`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and
|
||||
can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with
|
||||
`postMessage`.
|
||||
|
||||
Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships
|
||||
inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module`
|
||||
and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }`
|
||||
to a `.d.ts` in the plugin.
|
||||
|
||||
## Database
|
||||
|
||||
Needs `db:own`. Define tables once in TypeScript, generate migrations for SQLite, PostgreSQL and MySQL,
|
||||
query with [Drizzle](https://orm.drizzle.team/).
|
||||
|
||||
```ts
|
||||
// src/backend/tables.ts
|
||||
import {
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix-ssh/plugin-sdk/db";
|
||||
|
||||
export const notes = defineTable(
|
||||
"notes",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
text: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{ indexes: [{ name: "idx_p_hello_notes_user_id", columns: ["userId"] }] },
|
||||
);
|
||||
|
||||
export const tables = [notes]; // termix-plugin migrations reads this
|
||||
```
|
||||
|
||||
The table is stored as `p_<id with dashes as underscores>_notes`. Index names should use the same prefix.
|
||||
|
||||
| Builder | Type |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `id()` | Auto increment key |
|
||||
| `text()` | Any length string. Not indexable |
|
||||
| `varchar(n)` | String up to `n` (255 default). Indexable |
|
||||
| `integer()`, `bigint()`, `real()`, `boolean()` | Numbers and booleans |
|
||||
| `timestamp()` | A time, stored as text |
|
||||
| `json()` | JSON stored as text. Not indexable |
|
||||
| `encryptedText()` | Text encrypted with the owning user's key |
|
||||
| `refUser()` | A user id. Rows are deleted with the user and included in their data export |
|
||||
| `refHost()` | A host id. Rows are deleted with the host |
|
||||
|
||||
Modifiers: `.notNull()`, `.unique()`, `.primaryKey()`, `.default(value)`, `.defaultNow()`.
|
||||
|
||||
After any change to `tables.ts`, run `npm run migrations` and read the three new files. Renames and type
|
||||
changes are refused rather than guessed: write those by hand, for all three databases. A migration may only
|
||||
touch your own tables. `createTestDb` applies them in tests, so a broken one fails `npm test`.
|
||||
|
||||
Query through `const db = await ctx.db.client()`, then `await ctx.db.persist()` after writes
|
||||
(`persist({ lazy: true })` for frequent low value writes). `ctx.db.dialect` says which database runs.
|
||||
|
||||
Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secrets` for secrets,
|
||||
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
||||
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
||||
|
||||
## Frontend
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useTranslation,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
import { EmptyState, PanelShell } from "@termix-ssh/plugin-sdk/ui";
|
||||
import { Hand } from "lucide-react";
|
||||
|
||||
function HelloTab(_props: TabProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell icon={<Hand className="size-4" />} title={t("tab.title")}>
|
||||
<EmptyState title={t("tab.empty")} />
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp) {
|
||||
app.registerRailItem({
|
||||
id: "hello",
|
||||
icon: Hand,
|
||||
titleKey: "tab.title",
|
||||
kind: "tab",
|
||||
permission: "use",
|
||||
});
|
||||
app.registerTab("hello", HelloTab, {
|
||||
icon: Hand,
|
||||
titleKey: "tab.title",
|
||||
singleton: true,
|
||||
hostless: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Every `register*` returns an undo function, and everything is undone on disable. Each component runs in
|
||||
your plugin's error boundary and Suspense.
|
||||
|
||||
| Member | What it does |
|
||||
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `registerRailItem` | A sidebar rail button |
|
||||
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
|
||||
| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle |
|
||||
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
|
||||
| `registerHostAction` | A host button, palette entry or default connect |
|
||||
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
||||
| `registerHostProtocol` | A connection protocol next to SSH |
|
||||
| `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
|
||||
| `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) |
|
||||
| `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) |
|
||||
| `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
|
||||
| `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
|
||||
| `registerSettingsComponent` | A component for a `custom` settings field |
|
||||
| `registerAction`, `invokeAction` | Actions across plugins |
|
||||
| `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
|
||||
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
||||
| `registerOnboardingStep` | A first run setup step (`section`, `position`, `replaces`) |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
||||
| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
|
||||
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
||||
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
||||
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
||||
| `onSettingsChanged`, `onDispose` | Hooks |
|
||||
|
||||
Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order`
|
||||
comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact
|
||||
`position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw
|
||||
those are not shown it again.
|
||||
|
||||
Branding: a plugin that owns the app name, tagline and logo provides
|
||||
`ctx.registry.provide("<id>.brandingProvider", async () => ({ appName, tagline, logo }))` in its backend.
|
||||
Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls
|
||||
back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null.
|
||||
|
||||
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
||||
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
||||
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
|
||||
|
||||
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
|
||||
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
|
||||
your own host settings, under `host.pluginSettings[<id>]`.
|
||||
|
||||
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
|
||||
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
|
||||
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
||||
https://docs.termix.site/develop/more-extension-points.
|
||||
|
||||
## Layouts and themes
|
||||
|
||||
A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
|
||||
admins set the default for everyone.
|
||||
|
||||
A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
|
||||
needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
|
||||
it in `activate`:
|
||||
|
||||
```tsx
|
||||
import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
ShellConfirmCapture,
|
||||
SurfaceScope,
|
||||
TabStage,
|
||||
} from "@termix-ssh/plugin-sdk/ui";
|
||||
|
||||
function MyLayout() {
|
||||
const shell = useShell();
|
||||
if (!shell) return null;
|
||||
return (
|
||||
<div className="flex h-dvh w-screen flex-col bg-background">
|
||||
<header>
|
||||
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
|
||||
</header>
|
||||
<SurfaceScope kind="tab">
|
||||
<ShellConfirmCapture />
|
||||
<TabStage />
|
||||
</SurfaceScope>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp) {
|
||||
app.registerLayout({
|
||||
id: "my-layout",
|
||||
label: "layout.name",
|
||||
component: MyLayout,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
|
||||
command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
|
||||
every session.
|
||||
- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
|
||||
keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
|
||||
close questions and inline views show over the tab area. Overlays and onboarding stay in core.
|
||||
- The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
|
||||
`palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
|
||||
- The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
|
||||
plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
|
||||
- Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
|
||||
`LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
|
||||
Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
|
||||
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
|
||||
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
|
||||
screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
|
||||
shipped. `?safe-layout=1` opens it any time.
|
||||
- Termix has no layout of its own. The default is the bundled Classic Layout plugin
|
||||
(https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
|
||||
sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
|
||||
`host-manager` and `settings` tab types, and offers the host list and dashboard as the components
|
||||
`classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
|
||||
It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
|
||||
`useShell()` and may change with Termix. A third party layout should start from `useShell()`.
|
||||
|
||||
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
|
||||
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
|
||||
so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
|
||||
inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
|
||||
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
|
||||
`--foreground`, `--card`, `--border`, `--muted` and the rest).
|
||||
|
||||
A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
|
||||
`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
|
||||
`validateThemeCss()`. Notes:
|
||||
|
||||
- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
|
||||
- The first theme a bundled default-look plugin lists is the default for new installs.
|
||||
- Themes show in the picker in the order core lists its own, then the plugin's order.
|
||||
- A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
|
||||
- Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
|
||||
- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example.
|
||||
|
||||
## Looking native
|
||||
|
||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
||||
exports with props is `ui.api.txt` in the Termix repo
|
||||
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
|
||||
|
||||
- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the
|
||||
page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders
|
||||
`fallback` in a browser.
|
||||
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
|
||||
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
|
||||
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
|
||||
- Small type: most text 11 to 13px.
|
||||
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
|
||||
`PanePrompt` for small prompts.
|
||||
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
||||
forms end with `FormFooter`, empty lists show `EmptyState`.
|
||||
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
|
||||
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
|
||||
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
|
||||
desktop layout.
|
||||
- Tailwind classes work. `build` compiles the ones you use against Termix's theme.
|
||||
- Link to your docs with `DocsLink` or `PanelShell`'s `docs` prop, never a hard coded URL.
|
||||
|
||||
## Settings
|
||||
|
||||
Declare them in `contributes.settings` and Termix draws the form, stores values, encrypts secrets and
|
||||
checks saves. You don't build settings UI.
|
||||
|
||||
- Scopes: `admin` (for everyone), `user` (each person), `host` (a section in the host editor, with an
|
||||
`enableKey` switch first).
|
||||
- Types: `boolean`, `string`, `number`, `select`, `multiselect`, `secret`, `textarea`, `json`, `custom`.
|
||||
- Options: `labelKey`, `descriptionKey`, `placeholderKey`, `default`, `options`, `min`, `max`, `requires`,
|
||||
`group`, `permission`, `hidden`, `component`.
|
||||
- Read: `ctx.settings.get(key)`, `getUser(userId, key)`, `getHost(hostId, key)`. Write: `set`, `setHost`.
|
||||
React: `ctx.settings.onChange`. Check: `ctx.settings.onValidate("admin", values => ({ key: "message" }))`.
|
||||
- Frontend: `useSettings("user")` gives values and `save`.
|
||||
|
||||
Prefer settings to environment variables. Use an env var only for something needed before Termix starts.
|
||||
|
||||
## Permissions
|
||||
|
||||
Declare short names in `contributes.permissions`. Termix registers them as `<id>.<name>`. `defaultRoles`
|
||||
gives them to the built in `admin` and `user` roles on first install. You can't claim a core group
|
||||
(`hosts`, `credentials`, `admin`) or another plugin's. Labels are locale keys.
|
||||
|
||||
## Translations
|
||||
|
||||
Every string goes in `locales/en.json`, nested by area. Manifest keys (`titleKey`, `labelKey`) and `t()`
|
||||
read it from your own namespace. Unknown keys fall back to Termix's shared strings like `common.save`.
|
||||
Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go in
|
||||
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
|
||||
Untranslated strings show in English.
|
||||
|
||||
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
|
||||
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
|
||||
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
|
||||
every language.
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": {
|
||||
"name": "Host Metrics",
|
||||
"description": "Live CPU, memory, disk, network and GPU stats."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
|
||||
it. More pages sit next to it with `title` and `order` front matter. Plain Markdown, no MDX. Images go in
|
||||
`docs/img/`.
|
||||
|
||||
Don't write by hand what is generated: features, settings, permissions, capabilities, env vars, services,
|
||||
the API reference (from `@openapi`) and the changelog. Write short sentences in plain words and name
|
||||
buttons exactly as the UI does.
|
||||
|
||||
## Auth and protocols
|
||||
|
||||
Login methods, second factors and SSH auth types need `auth:provide`, which is high risk and reviewed
|
||||
closely. Declare ids in `contributes.auth` and register them with `ctx.auth.registerLoginMethod`,
|
||||
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
|
||||
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
|
||||
|
||||
Login rules need `auth:policy`. `ctx.auth.policy.provide({ evaluate, allowRememberDevice })` is asked at
|
||||
every login with the user, the method, the device and every second factor (`ctx.auth.listSecondFactors()`
|
||||
lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`, `idleTimeoutSeconds` and
|
||||
`secondFactor` (`"always"` or `"new-device"`). The strictest answer from all plugins wins, and a throw
|
||||
refuses the login. A user who must have a second factor but has none gets a session that can only set one
|
||||
up, and `/users/me` reports `restriction: "second-factor-setup"`.
|
||||
|
||||
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
|
||||
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
|
||||
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
|
||||
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
|
||||
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
|
||||
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
|
||||
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
|
||||
|
||||
## Testing
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { pluginVitestConfig } from "@termix-ssh/plugin-sdk/vitest-preset";
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
```
|
||||
|
||||
Backend tests go in `tests/backend/` (Node), frontend tests in `tests/frontend/` (jsdom). Never next to the
|
||||
source.
|
||||
|
||||
Backend, from `@termix-ssh/plugin-sdk/testing`:
|
||||
|
||||
```ts
|
||||
const db = await createTestDb(pluginDir); // in memory SQLite, your migrations, stub users/ssh_data/roles
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities, // calls outside these throw, like the real thing
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: ["hello.use"], // leave out to allow everything
|
||||
});
|
||||
await activate(mock.ctx);
|
||||
mock.setActor("user-1");
|
||||
```
|
||||
|
||||
`createMockCtx` also takes `hosts` and `services`. `createFakeContext()` is a looser double that skips the
|
||||
capability checks. The template's `tests/backend/routes.test.ts` serves the router with Express and calls
|
||||
it with `fetch`: start from it.
|
||||
|
||||
Frontend:
|
||||
|
||||
```tsx
|
||||
import { renderWithApp } from "@termix-ssh/plugin-sdk/testing";
|
||||
import * as plugin from "../../src/frontend/index.js";
|
||||
|
||||
const app = await renderWithApp(plugin, {
|
||||
api: { get: async () => ({ data: [] }) },
|
||||
});
|
||||
expect(app.registered.tabs()).toContain("hello");
|
||||
```
|
||||
|
||||
Test every route, with and without the permission. Test that disable then enable works, and that the plugin
|
||||
works when an optional dependency is missing.
|
||||
|
||||
## Running it on a server
|
||||
|
||||
1. You need Node 24 and a Termix server (26.10 or newer) you are an admin on. A local Docker one is fine.
|
||||
2. In Termix, open Settings, General and turn on Plugin developer mode.
|
||||
3. Make an API key in Settings, API keys.
|
||||
4. Run `npx termix-plugin dev --server http://localhost:30001 --key tmx_...` (or set `TERMIX_SERVER_URL`
|
||||
and `TERMIX_API_KEY`). It builds, installs and reinstalls on every save. Reload the page for frontend
|
||||
changes. `--once` installs once.
|
||||
|
||||
Or `npm run build && npm run pack` and install the `.tmxplug` from Plugins, Install from file. Dev installs
|
||||
are marked unverified. You can't use the id of a plugin that ships with Termix, and a registry copy of the
|
||||
same plugin must be uninstalled first.
|
||||
|
||||
## Releasing
|
||||
|
||||
Plugins use semver. Work happens on a `dev-X.Y.Z` branch made from `main`, named after the version it will
|
||||
ship as. `main` only changes when a stable release merges a dev branch into it.
|
||||
|
||||
`CHANGELOG.md`, newest first, no `## Unreleased` section and no dates:
|
||||
|
||||
```md
|
||||
# Changelog
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Added
|
||||
|
||||
- A wave button in the terminal toolbar
|
||||
|
||||
### Fixed
|
||||
|
||||
- Notes keep their order after a restart
|
||||
```
|
||||
|
||||
Sections: `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security`.
|
||||
`termix-plugin changelog --version 1.1.0` prints one version's notes.
|
||||
|
||||
Run the Release workflow by hand from the dev branch on GitHub:
|
||||
|
||||
| Choice | What it does |
|
||||
| --------- | --------------------------------------------------------------------------------- |
|
||||
| stable | Tests, builds and publishes `X.Y.Z`, merges the branch into `main` and deletes it |
|
||||
| beta | Publishes `X.Y.Z-beta.N`, N counts up on its own |
|
||||
| overwrite | Releases the manifest version again from this commit |
|
||||
| dry-run | Builds and packs, publishes nothing |
|
||||
|
||||
No secrets are needed. The workflow records GitHub build provenance for the `.tmxplug`, and the run summary
|
||||
shows the `sha256` for a registry submission. If a dev branch changes `.github/workflows`, add a
|
||||
`TERMIX_PAT` secret (a token with `repo` and `workflow` scopes). To also sign with your own key, set
|
||||
`TERMIX_PLUGIN_SIGNING_KEY`. Keep `.github/workflows/release.yml` as the template has it: the community
|
||||
registry checks provenance from it.
|
||||
|
||||
## Submitting to the community registry
|
||||
|
||||
Before you submit:
|
||||
|
||||
- The repo is public with an open source license, made from the template or using its `release.yml`.
|
||||
- `npm test`, `npm run typecheck`, `npm run validate` and `npm run build` pass.
|
||||
- The `id` isn't used by an official plugin or another submission.
|
||||
- `name`, `description`, `author`, `repository` (your repo) and `docs` (or a README that explains use) are
|
||||
set.
|
||||
|
||||
Then release a stable or beta version and fork https://github.com/Termix-SSH/Termix-Registry. Add
|
||||
`community/plugins/<id>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "../../community-submission-schema.json",
|
||||
"id": "my-plugin",
|
||||
"repository": "https://github.com/you/termix-plugin-my-plugin",
|
||||
"maintainers": ["your-github-username"],
|
||||
"versions": [
|
||||
{ "version": "1.0.0", "sha256": "<from the release run summary>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Open a pull request. The Check submission job downloads the `v<version>` release and checks the `sha256`,
|
||||
that the manifest inside has your id, version and repo, that it has build provenance from the release
|
||||
workflow, and that you are a maintainer. Then a person reviews the source at the tag and merges it. The
|
||||
registry signs it with the community key and lists it a few minutes later.
|
||||
|
||||
Reviewers check that:
|
||||
|
||||
- The source does what the name and description say, and nothing else.
|
||||
- Nothing is obfuscated or minified, and no code is downloaded and run at runtime.
|
||||
- There are no network calls, tracking or telemetry the user didn't ask for.
|
||||
- Capabilities and permissions are the fewest needed.
|
||||
- `package.json` scripts and dependencies don't fetch or run anything unexpected at install or build.
|
||||
- Secrets are only used for what the plugin is for.
|
||||
- The name and icon don't pretend to be an official plugin.
|
||||
|
||||
Every version needs its own PR that adds it to the top of `versions`. Overwriting a reviewed release drops
|
||||
it from the registry until a new version is submitted. Admins see the plugin once they turn on the community
|
||||
registry in Plugins, Registries.
|
||||
|
||||
To run your own registry instead, see https://docs.termix.site/develop/registries.
|
||||
|
||||
## termix-plugin CLI
|
||||
|
||||
| Command | What it does |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` |
|
||||
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
|
||||
| `test` | Apply patches, run Vitest |
|
||||
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
|
||||
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
|
||||
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
|
||||
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
|
||||
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
|
||||
| `keygen` | Write a new signing key pair. `--out <dir>` |
|
||||
| `changelog` | Print one version's notes. `--version x.y.z` |
|
||||
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
|
||||
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
|
||||
|
||||
## SDK entry points
|
||||
|
||||
| Import | What it is |
|
||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
|
||||
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
|
||||
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
|
||||
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
|
||||
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
|
||||
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
|
||||
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
|
||||
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
||||
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
||||
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
||||
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
||||
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
|
||||
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
||||
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
|
||||
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
|
||||
| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
|
||||
| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Risk | What it allows |
|
||||
| ------------------------ | -------- | ------------------------------------------------------------- |
|
||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Add users and change their roles |
|
||||
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
||||
| `sync:provide` | high | Run sync: answer every /sync request |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||
| `hosts:export` | high | Export hosts with their saved logins |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `ui:layout` | high | Replace the whole app layout |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
|
||||
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
|
||||
| `network:probe` | medium | Check that a host answers on a TCP port |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
| `ui:theme` | low | Add color themes, CSS only |
|
||||
|
||||
An update that adds a capability waits for an admin to approve it.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- Tests fail after `npm install`: usually a missing devDependency (`@testing-library/dom`, `jsdom`,
|
||||
`better-sqlite3`) or a newer TypeScript major pulled in by another package. Pin `typescript` to the
|
||||
template's range.
|
||||
- `hello-world`, `hello_world` or `Hello World` left over from the template in `package.json`, locales,
|
||||
index names, API paths, tab ids or tests.
|
||||
- Changed `tables.ts` without running `npm run migrations`, or kept the template's `migrations/` folder
|
||||
after renaming the id. Delete it and run `npm run migrations` once on a fresh plugin.
|
||||
- A rail item or tab id that isn't declared in `contributes`.
|
||||
- A string typed straight into JSX instead of `t()`.
|
||||
- Forgetting `await ctx.db.persist()` after a write.
|
||||
- Frontend changes not showing during `dev`: reload the page.
|
||||
- An id clash with an official plugin: dev installs and registry submissions are refused.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- Docs: https://docs.termix.site/develop
|
||||
- Official plugins are all open source and built the same way. Plugin-Workspaces is a small one to start
|
||||
from, Plugin-Docker runs commands on hosts, Plugin-Snippets offers a service, Plugin-Automations uses
|
||||
services, Plugin-SSO and Plugin-TOTP add sign in, Plugin-Vault adds an SSH auth type and
|
||||
Plugin-Remote-Desktop adds a protocol. All at https://github.com/Termix-SSH.
|
||||
- Help: the Discord at https://discord.gg/jVQGdvHDrf. SDK bugs go in
|
||||
https://github.com/Termix-SSH/Termix/issues.
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.svg" width="120" height="120" alt="Termix Logo" />
|
||||
<img src="https://termix.site/img/logo.svg" width="120" alt="Termix Logo" />
|
||||
|
||||
<h1>Termix Plugin SDK</h1>
|
||||
|
||||
@@ -50,7 +50,7 @@ in place of the flags. Reload the page to pick up frontend changes.
|
||||
|
||||
## Support
|
||||
|
||||
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
|
||||
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Add as much detail as you can, in English if possible.
|
||||
|
||||
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
|
||||
@@ -58,4 +58,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE) for more information.
|
||||
Apache License 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE).
|
||||
@@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||
import {
|
||||
readManifest,
|
||||
resolveEntry,
|
||||
copyDir,
|
||||
isThemeOnly,
|
||||
} from "../lib/plugin-dir.mjs";
|
||||
import { openapi } from "./openapi.mjs";
|
||||
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||
|
||||
@@ -52,6 +57,43 @@ export function externalRequireInterop(externals) {
|
||||
};
|
||||
}
|
||||
|
||||
// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle,
|
||||
// so it ships in backend.js or a worker with no separate file to find.
|
||||
export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"];
|
||||
const BINARY_LOADERS = Object.fromEntries(
|
||||
BINARY_EXTENSIONS.map((ext) => [ext, "binary"]),
|
||||
);
|
||||
|
||||
const REQUIRE_BANNER =
|
||||
"import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);";
|
||||
|
||||
/**
|
||||
* package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
|
||||
* bundles each entry on its own into dist/workers/<name>.js, for
|
||||
* new Worker(new URL("./workers/<name>.js", import.meta.url)). Nothing is left
|
||||
* external: core's import fallback only runs on the main thread, so a worker
|
||||
* can't rely on host packages and must not import the SDK.
|
||||
*/
|
||||
export function readWorkerEntries(cwd, pluginId) {
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
const pkg = fs.existsSync(pkgPath)
|
||||
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
||||
: {};
|
||||
const workers = pkg.termix?.workers ?? {};
|
||||
return Object.entries(workers).map(([name, entry]) => {
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
throw new Error(`${pluginId}: worker name "${name}" must be lowercase`);
|
||||
}
|
||||
const file = path.resolve(cwd, String(entry));
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(
|
||||
`${pluginId}: worker "${name}" entry ${entry} is missing`,
|
||||
);
|
||||
}
|
||||
return { name, file };
|
||||
});
|
||||
}
|
||||
|
||||
const SDK_PATCHES = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
@@ -195,33 +237,56 @@ export async function build({ cwd }) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
||||
if (!backendEntry) {
|
||||
if (!backendEntry && !isThemeOnly(manifest)) {
|
||||
throw new Error(
|
||||
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [backendEntry],
|
||||
outfile: path.join(outDir, "backend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
// A native dependency's compiled .node binary is resolved by the
|
||||
// package's own relative paths, which break once esbuild inlines its JS
|
||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||
// node_modules import instead, resolved at runtime like a host-provided
|
||||
// package.
|
||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||
banner: {
|
||||
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
|
||||
},
|
||||
});
|
||||
if (backendEntry) {
|
||||
await esbuild.build({
|
||||
entryPoints: [backendEntry],
|
||||
outfile: path.join(outDir, "backend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
// A native dependency's compiled .node binary is resolved by the
|
||||
// package's own relative paths, which break once esbuild inlines its JS
|
||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||
// node_modules import instead, resolved at runtime like a host-provided
|
||||
// package.
|
||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||
loader: BINARY_LOADERS,
|
||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||
banner: { js: REQUIRE_BANNER },
|
||||
});
|
||||
} else {
|
||||
// Core loads every plugin through a backend entry, so a theme only
|
||||
// plugin gets an empty one.
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "backend.js"),
|
||||
"export function activate() {}\n",
|
||||
);
|
||||
}
|
||||
|
||||
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
||||
await esbuild.build({
|
||||
entryPoints: [worker.file],
|
||||
outfile: path.join(outDir, "workers", `${worker.name}.js`),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
loader: BINARY_LOADERS,
|
||||
banner: { js: REQUIRE_BANNER },
|
||||
});
|
||||
}
|
||||
|
||||
// Without this Node finds the host's typeless package.json, tries backend.js
|
||||
// as CommonJS first and warns before reparsing it as ESM.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
|
||||
import {
|
||||
collectFiles,
|
||||
createTmxplug,
|
||||
themeFiles,
|
||||
TMXPLUG_CONTENTS,
|
||||
} from "../lib/tmxplug.mjs";
|
||||
import { sha256 } from "../lib/signing.mjs";
|
||||
import { validate } from "./validate.mjs";
|
||||
|
||||
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
|
||||
|
||||
const archive = createTmxplug(cwd, collectFiles(cwd));
|
||||
const archive = createTmxplug(
|
||||
cwd,
|
||||
collectFiles(cwd, [
|
||||
...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
|
||||
]),
|
||||
);
|
||||
fs.writeFileSync(outfile, archive);
|
||||
|
||||
console.log(`packed ${path.basename(outfile)}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
|
||||
|
||||
/**
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
|
||||
|
||||
export async function validate({ cwd }) {
|
||||
const raw = readManifest(cwd);
|
||||
const { parseManifest } = await loadSdkModule("manifest");
|
||||
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
|
||||
const { errors } = parseManifest(raw);
|
||||
|
||||
const problems = [...errors];
|
||||
@@ -25,7 +25,8 @@ export async function validate({ cwd }) {
|
||||
// The manifest names files. They have to be there.
|
||||
const referenced = [
|
||||
raw.backend ?? "dist/backend.js",
|
||||
raw.frontend ?? "dist/frontend.js",
|
||||
// A theme only plugin has no frontend code.
|
||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||
raw.locales ?? "locales",
|
||||
];
|
||||
for (const rel of referenced) {
|
||||
@@ -34,6 +35,8 @@ export async function validate({ cwd }) {
|
||||
}
|
||||
}
|
||||
|
||||
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
||||
problems.push(...validateLookCapabilities(cwd, raw));
|
||||
problems.push(
|
||||
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
||||
);
|
||||
@@ -53,6 +56,63 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/** Every declared theme file exists and holds only CSS a theme may hold. */
|
||||
export function validateThemeFiles(cwd, raw, validateThemeCss) {
|
||||
const problems = [];
|
||||
const themes = raw?.contributes?.themes;
|
||||
if (!Array.isArray(themes)) return problems;
|
||||
for (const theme of themes) {
|
||||
if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(cwd, theme.css);
|
||||
if (!fs.existsSync(file)) {
|
||||
problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
|
||||
continue;
|
||||
}
|
||||
for (const error of validateThemeCss(
|
||||
fs.readFileSync(file, "utf8"),
|
||||
theme.id,
|
||||
)) {
|
||||
problems.push(`theme ${theme.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* ui:layout and ui:theme must match what the manifest contributes, both
|
||||
* ways. The manifest check already flags a contribution without its
|
||||
* capability; this flags a capability with nothing behind it, and a layout
|
||||
* with no frontend to register it.
|
||||
*/
|
||||
export function validateLookCapabilities(cwd, raw) {
|
||||
const problems = [];
|
||||
const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
|
||||
const layouts = raw?.contributes?.layouts;
|
||||
const themes = raw?.contributes?.themes;
|
||||
const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
|
||||
const hasThemes = Array.isArray(themes) && themes.length > 0;
|
||||
if (capabilities.includes("ui:layout") && !hasLayouts) {
|
||||
problems.push('"ui:layout" is declared but contributes.layouts is empty');
|
||||
}
|
||||
if (capabilities.includes("ui:theme") && !hasThemes) {
|
||||
problems.push('"ui:theme" is declared but contributes.themes is empty');
|
||||
}
|
||||
if (hasLayouts) {
|
||||
const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
|
||||
const frontend = entries.some((entry) =>
|
||||
fs.existsSync(path.join(cwd, "src", "frontend", entry)),
|
||||
);
|
||||
if (!frontend && raw.frontend === undefined) {
|
||||
problems.push(
|
||||
"contributes.layouts needs a frontend that calls app.registerLayout",
|
||||
);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||
const CORE_ENV = new Set([
|
||||
"DATA_DIR",
|
||||
|
||||
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
|
||||
}
|
||||
|
||||
/** The first of these that exists, or null. */
|
||||
/** Only themes: CSS files and no code, so no source entry is needed. */
|
||||
export function isThemeOnly(manifest) {
|
||||
const capabilities = manifest.capabilities ?? [];
|
||||
return (
|
||||
capabilities.length > 0 &&
|
||||
capabilities.every((capability) => capability === "ui:theme") &&
|
||||
Array.isArray(manifest.contributes?.themes) &&
|
||||
manifest.contributes.themes.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveEntry(cwd, candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const full = path.join(cwd, candidate);
|
||||
|
||||
@@ -2,6 +2,15 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
/** Theme CSS files a manifest declares, which travel with the plugin too. */
|
||||
export function themeFiles(manifest) {
|
||||
const themes = manifest?.contributes?.themes;
|
||||
if (!Array.isArray(themes)) return [];
|
||||
return themes
|
||||
.map((theme) => theme?.css)
|
||||
.filter((css) => typeof css === "string");
|
||||
}
|
||||
|
||||
/** What a .tmxplug holds, in the order the spec lists it. */
|
||||
export const TMXPLUG_CONTENTS = [
|
||||
"manifest.json",
|
||||
@@ -11,6 +20,9 @@ export const TMXPLUG_CONTENTS = [
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"icon.svg",
|
||||
// Licence texts, so third-party notices travel with the plugin.
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
];
|
||||
|
||||
const BLOCK = 512;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.18",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -85,6 +85,10 @@
|
||||
"types": "./dist/ssh-certs.d.ts",
|
||||
"default": "./dist/ssh-certs.js"
|
||||
},
|
||||
"./ssh-keys": {
|
||||
"types": "./dist/ssh-keys.d.ts",
|
||||
"default": "./dist/ssh-keys.js"
|
||||
},
|
||||
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
||||
"./ui": {
|
||||
@@ -103,6 +107,7 @@
|
||||
"tsconfig.plugin.json",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"PLUGIN-GUIDE.md",
|
||||
"client.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
|
||||
@@ -82,7 +82,11 @@
|
||||
"features": {
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"description": "Short lines listing what the plugin does, shown on its page."
|
||||
},
|
||||
"docs": {
|
||||
@@ -98,11 +102,24 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "description"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
||||
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
||||
"default": { "type": "string" },
|
||||
"required": { "type": "boolean" },
|
||||
"secret": { "type": "boolean" }
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z][A-Z0-9_]{0,63}$"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 300
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"secret": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -134,9 +151,13 @@
|
||||
"process:spawn",
|
||||
"users:write",
|
||||
"auth:provide",
|
||||
"auth:policy",
|
||||
"sync:provide",
|
||||
"ssh:transport",
|
||||
"system:tls",
|
||||
"device:serial",
|
||||
"hosts:write",
|
||||
"hosts:export",
|
||||
"credentials:use",
|
||||
"credentials:write",
|
||||
"network:outbound",
|
||||
@@ -146,7 +167,12 @@
|
||||
"events:core",
|
||||
"notify:send",
|
||||
"audit:read",
|
||||
"network:probe",
|
||||
"hosts:status-provide",
|
||||
"hosts:activity-provide",
|
||||
"hosts:lock",
|
||||
"desktop:window",
|
||||
"desktop:local",
|
||||
"users:impersonate",
|
||||
"db:core-refs",
|
||||
"hosts:read",
|
||||
@@ -155,7 +181,11 @@
|
||||
"files:own",
|
||||
"secrets:own",
|
||||
"settings:read-core",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"notify:hub",
|
||||
"plugins:read",
|
||||
"ui:layout",
|
||||
"ui:theme"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -944,7 +974,9 @@
|
||||
"defaultLevels": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "enum": ["admin", "user", "folder"] },
|
||||
"items": {
|
||||
"enum": ["admin", "user", "folder"]
|
||||
},
|
||||
"description": "Levels that may set a default for the field. All three by default."
|
||||
},
|
||||
"personal": {
|
||||
@@ -1127,6 +1159,86 @@
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"type": "array",
|
||||
"description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "i18n key in the plugin namespace, or plain text."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"defaultTheme": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||
"description": "Theme picked with this layout when nobody chose one."
|
||||
},
|
||||
"default": {
|
||||
"type": "boolean",
|
||||
"description": "The layout a default-look plugin starts with. At most one."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"type": "array",
|
||||
"description": "Themes: one CSS file each with token rules scoped under .<id>. Needs the ui:theme capability.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label", "mode", "css"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"]
|
||||
},
|
||||
"css": {
|
||||
"type": "string",
|
||||
"pattern": ".css$",
|
||||
"description": "Path of the CSS file inside the plugin."
|
||||
},
|
||||
"swatch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["background", "panel", "text"],
|
||||
"properties": {
|
||||
"background": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
},
|
||||
"panel": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1158,6 +1270,11 @@
|
||||
"enum": ["mirror", "local", "server"],
|
||||
"description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror."
|
||||
},
|
||||
"runtime": {
|
||||
"type": "string",
|
||||
"enum": ["desktop"],
|
||||
"description": "desktop: the plugin only loads on a desktop app's own embedded backend, never on a server. It implies desktop local and may declare desktop:local."
|
||||
},
|
||||
"nativeDependencies": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
|
||||
@@ -47,20 +47,30 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("users:impersonate", "high"),
|
||||
entry("db:core-refs", "high"),
|
||||
entry("auth:provide", "high"),
|
||||
entry("auth:policy", "high"),
|
||||
entry("sync:provide", "high"),
|
||||
entry("ssh:transport", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
entry("ui:layout", "high"),
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
entry("hosts:export", "high"),
|
||||
entry("credentials:use", "medium"),
|
||||
entry("credentials:write", "medium"),
|
||||
entry("network:outbound", "medium"),
|
||||
entry("network:serve", "medium"),
|
||||
entry("network:broadcast", "medium"),
|
||||
entry("network:probe", "medium"),
|
||||
entry("hosts:status-provide", "medium"),
|
||||
entry("hosts:activity-provide", "medium"),
|
||||
entry("hosts:lock", "medium"),
|
||||
entry("users:read", "medium"),
|
||||
entry("events:core", "medium"),
|
||||
entry("notify:send", "medium"),
|
||||
entry("audit:read", "medium"),
|
||||
entry("desktop:window", "medium"),
|
||||
entry("desktop:local", "high"),
|
||||
|
||||
entry("hosts:read", "low"),
|
||||
entry("db:own", "low"),
|
||||
@@ -70,6 +80,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("settings:read-core", "low"),
|
||||
entry("plugins:read", "low"),
|
||||
entry("ui:surface", "low"),
|
||||
entry("ui:theme", "low"),
|
||||
] as const;
|
||||
|
||||
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
||||
|
||||
@@ -256,6 +256,7 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
|
||||
homepage_items: "homepage",
|
||||
homepage_layouts: "homepage",
|
||||
dashboard_service_links: "homepage",
|
||||
recent_activity: "homepage",
|
||||
secret_sources: "secret-sources",
|
||||
opkssh_tokens: "opkssh",
|
||||
vault_profiles: "vault",
|
||||
|
||||
@@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] {
|
||||
continue;
|
||||
}
|
||||
if (char === ";") {
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
if (hasSql(current)) statements.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
if (hasSql(current)) statements.push(current.trim());
|
||||
return statements;
|
||||
}
|
||||
|
||||
/** False for a chunk that is only comments, which a driver refuses to run. */
|
||||
function hasSql(chunk: string): boolean {
|
||||
return (
|
||||
chunk
|
||||
.replace(/--[^\n]*/g, "")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
/** DROP statement for a definition, for removePluginData. */
|
||||
export function dropTableSql(
|
||||
dialect: SqlDialect,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* only inside the Termix desktop app; check isElectron before calling it.
|
||||
*/
|
||||
|
||||
import type { PluginSshPromptRequest } from "./backend.js";
|
||||
|
||||
export interface ServerConfig {
|
||||
serverUrl?: string;
|
||||
allowInvalidCertificate?: boolean;
|
||||
@@ -33,27 +31,6 @@ export interface DialogResult {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LocalDirectoryResult {
|
||||
success: boolean;
|
||||
path: string;
|
||||
parent?: string;
|
||||
entries: LocalFileEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LocalCollectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
created?: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export type LocalPathMutationResult =
|
||||
| ({ success: true } & Partial<LocalFileEntry>)
|
||||
| { success: false; error?: string };
|
||||
|
||||
export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
setZoomFactor?: (factor: number) => void;
|
||||
@@ -67,60 +44,6 @@ export interface ElectronAPI {
|
||||
serverUrl: string,
|
||||
allowInvalidCertificate?: boolean,
|
||||
) => Promise<ConnectionTestResult>;
|
||||
answerC2SAuth: (id: string, answer: string | null) => Promise<boolean>;
|
||||
onC2SAuthPrompt: (
|
||||
callback: (prompt: {
|
||||
id: string;
|
||||
closed?: boolean;
|
||||
tunnelName?: string;
|
||||
request?: PluginSshPromptRequest;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
getC2STunnelConfig: () => Promise<unknown[]>;
|
||||
saveC2STunnelConfig: (
|
||||
config: unknown[],
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
checkLocalPortAvailable: (
|
||||
host: string,
|
||||
port: number,
|
||||
) => Promise<{ available: boolean; error?: string }>;
|
||||
getC2STunnelPresetDefaultName: () => Promise<string>;
|
||||
startC2STunnel: (
|
||||
tunnel: unknown,
|
||||
index: number,
|
||||
authToken?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
tunnelName?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}>;
|
||||
testC2STunnel: (
|
||||
tunnel: unknown,
|
||||
index: number,
|
||||
authToken?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}>;
|
||||
stopC2STunnel: (
|
||||
tunnelName: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
|
||||
onC2STunnelStatuses?: (
|
||||
callback: (statuses: Record<string, unknown>) => void,
|
||||
) => () => void;
|
||||
startC2SAutoStartTunnels: () => Promise<{
|
||||
success: boolean;
|
||||
started: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
|
||||
setC2SRelayPath?: (
|
||||
relayPath: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onCloseActiveTab?: (callback: () => void) => () => void;
|
||||
clearSessionCookies: () => Promise<void>;
|
||||
getSessionCookie: (
|
||||
@@ -136,62 +59,6 @@ export interface ElectronAPI {
|
||||
|
||||
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
getLocalHomeDirectory?: () => Promise<string>;
|
||||
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
|
||||
statLocalPaths?: (
|
||||
paths: string[],
|
||||
) => Promise<
|
||||
Array<
|
||||
| (LocalFileEntry & { success: true })
|
||||
| { success: false; path: string; error?: string }
|
||||
>
|
||||
>;
|
||||
collectLocalFiles?: (paths: string[]) => Promise<{
|
||||
success: boolean;
|
||||
files: LocalCollectedFile[];
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
createLocalFolder?: (
|
||||
parentPath: string,
|
||||
folderName: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
renameLocalPath?: (
|
||||
path: string,
|
||||
newName: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
|
||||
chmodLocalPath?: (
|
||||
path: string,
|
||||
permissions: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
|
||||
openExternalEditor: (fileData: {
|
||||
fileName: string;
|
||||
content: string;
|
||||
encoding?: "utf8" | "base64";
|
||||
editorPath?: string | null;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
editId?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
closeExternalEditor: (editId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
onExternalEditorSaved?: (
|
||||
callback: (payload: {
|
||||
editId: string;
|
||||
content: string;
|
||||
encoding: "utf8";
|
||||
path: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
|
||||
onUpdateAvailable: (callback: () => void) => void;
|
||||
onUpdateDownloaded: (callback: () => void) => void;
|
||||
|
||||
@@ -238,159 +105,6 @@ export interface ElectronAPI {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
startLocalTerminal(dimensions: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
shell?: "default" | "wsl";
|
||||
}): Promise<{ sessionId: string; shell: string }>;
|
||||
readyLocalTerminal(sessionId: string): Promise<boolean>;
|
||||
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
|
||||
resizeLocalTerminal(
|
||||
sessionId: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): Promise<boolean>;
|
||||
closeLocalTerminal(sessionId: string): Promise<boolean>;
|
||||
onLocalTerminalData(
|
||||
sessionId: string,
|
||||
callback: (data: string) => void,
|
||||
): () => void;
|
||||
onLocalTerminalExit(
|
||||
sessionId: string,
|
||||
callback: (exitCode: number) => void,
|
||||
): () => void;
|
||||
|
||||
/** Local disk browsing for the dual-pane file manager (desktop only). */
|
||||
localFs?: {
|
||||
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
|
||||
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
|
||||
mkdir: (
|
||||
parentPath: string,
|
||||
name: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
createFile: (
|
||||
parentPath: string,
|
||||
name: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
rename: (
|
||||
oldPath: string,
|
||||
newName: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
|
||||
ensureDir: (
|
||||
dirPath: string,
|
||||
rootPath?: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
|
||||
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
|
||||
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
||||
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
||||
};
|
||||
|
||||
/** Streamed local<->remote transfers driven by the main process. */
|
||||
localTransfer?: {
|
||||
/** The /plugin-api/<id> path that serves the streaming routes. */
|
||||
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
|
||||
upload: (
|
||||
options: LocalUploadRequest,
|
||||
) => Promise<LocalFsResult<{ bytes: number }>>;
|
||||
download: (
|
||||
options: LocalDownloadRequest,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
cancel: (
|
||||
transferId: string,
|
||||
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
|
||||
onProgress: (
|
||||
callback: (payload: LocalTransferProgress) => void,
|
||||
) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalFsResult<T> =
|
||||
({ success: true } & T) | { success: false; error: string; code?: string };
|
||||
|
||||
export interface LocalFsHomeInfo {
|
||||
home: string;
|
||||
separator: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface LocalFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory" | "link" | "other";
|
||||
created?: string;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
size: number;
|
||||
modifiedTimestamp?: number;
|
||||
linkTarget?: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface LocalDirectoryListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: LocalFileEntry[];
|
||||
}
|
||||
|
||||
export interface LocalTrashResult {
|
||||
trashed: number;
|
||||
failed: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
export interface LocalWalkFile {
|
||||
localPath: string;
|
||||
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
|
||||
relativePath: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LocalWalkResult {
|
||||
files: LocalWalkFile[];
|
||||
emptyDirs: string[];
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Termix backend a transfer talks to. The main process resolves the
|
||||
* actual URL and credentials for the origin; the renderer never supplies them.
|
||||
*/
|
||||
export type LocalTransferOrigin = "local" | "remote";
|
||||
|
||||
export interface LocalUploadRequest {
|
||||
transferId: string;
|
||||
origin: LocalTransferOrigin;
|
||||
fields: Record<string, string>;
|
||||
localPath: string;
|
||||
fileName: string;
|
||||
deviceId?: string;
|
||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface LocalDownloadRequest {
|
||||
transferId: string;
|
||||
origin: LocalTransferOrigin;
|
||||
body: Record<string, unknown>;
|
||||
destPath: string;
|
||||
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
|
||||
rootPath: string;
|
||||
expectedSize?: number;
|
||||
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
|
||||
overwrite?: boolean;
|
||||
deviceId?: string;
|
||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface LocalTransferProgress {
|
||||
transferId: string;
|
||||
transferred: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -248,6 +248,129 @@ export interface TabsApi extends Pick<
|
||||
onReady: (listener: () => void) => Disposer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layouts (plugin API 1.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A whole-app layout. Core renders the chosen one inside its providers and an
|
||||
* error boundary; everything it shows comes from useShell(). Declare it in
|
||||
* manifest contributes.layouts and add the ui:layout capability.
|
||||
*/
|
||||
export interface LayoutContribution {
|
||||
id: string;
|
||||
/** Your own i18n key or plain text, the same as the manifest's label. */
|
||||
label: string;
|
||||
component: ComponentType;
|
||||
/** Shown on phones and narrow windows. The desktop app never uses it. */
|
||||
mobileComponent?: ComponentType;
|
||||
/** Theme to start with when nobody chose one. */
|
||||
defaultTheme?: string;
|
||||
}
|
||||
|
||||
/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */
|
||||
export interface ShellTabState extends PluginTabRecord {
|
||||
instanceId: string;
|
||||
customLabel?: string;
|
||||
/** Set while the tab sits in a split pane. */
|
||||
parentSplitTabId?: string;
|
||||
openedAt?: number;
|
||||
}
|
||||
|
||||
/** What a dock region shows and how it is sized, in Normal-size pixels. */
|
||||
export interface ShellDockState {
|
||||
view: string | null;
|
||||
open: boolean;
|
||||
width: number;
|
||||
dragging: boolean;
|
||||
setView: (view: string | null) => void;
|
||||
setWidth: (width: number) => void;
|
||||
/** Starts a drag from the dock's edge, from a mousedown. */
|
||||
startResize: (event: { clientX: number; preventDefault: () => void }) => void;
|
||||
resetWidth: () => void;
|
||||
}
|
||||
|
||||
/** The live shell: state and actions, nothing drawn. Read with useShell(). */
|
||||
export interface ShellController {
|
||||
tabs: {
|
||||
list: ShellTabState[];
|
||||
/** What a tab bar shows: tabs not sitting in a split. */
|
||||
topLevel: ShellTabState[];
|
||||
activeId: string;
|
||||
/** True once saved tabs were restored after login. */
|
||||
ready: boolean;
|
||||
/** The user turned on tab numbers. */
|
||||
showNumbers: boolean;
|
||||
setActive: (id: string) => void;
|
||||
reorder: (topLevel: ShellTabState[]) => void;
|
||||
close: (id: string) => Promise<void>;
|
||||
rename: (id: string, label: string) => void;
|
||||
/** Reconnects or reloads a session tab. */
|
||||
refresh: (id: string) => void;
|
||||
reconnectAll: () => void;
|
||||
openSingleton: (type: string) => string | undefined;
|
||||
};
|
||||
split: {
|
||||
/** The split shown now, or null. */
|
||||
activeId: string | null;
|
||||
focusedPaneId: string | null;
|
||||
/** Every split with its panes, for a split menu. */
|
||||
summaries: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
full: boolean;
|
||||
panes: Array<{
|
||||
id: string;
|
||||
index: number;
|
||||
tabId: string | null;
|
||||
label: string | null;
|
||||
}>;
|
||||
}>;
|
||||
activeFull: boolean;
|
||||
/** Split a tab, apply a preset, unsplit, add to a pane. */
|
||||
handleAction: (action: Record<string, unknown> & { kind: string }) => void;
|
||||
};
|
||||
hosts: {
|
||||
list: PluginHostRecord[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
regions: {
|
||||
left: ShellDockState & { view: string };
|
||||
right: ShellDockState;
|
||||
/** What a rail click does: toggles or switches the left dock. */
|
||||
railClick: (view: string) => void;
|
||||
openInRightDock: (view: string) => void;
|
||||
toggleRightDock: () => void;
|
||||
};
|
||||
palette: { isOpen: boolean; open: () => void; close: () => void };
|
||||
fullscreen: { active: boolean; toggle: () => void };
|
||||
/** The same callbacks tabs and panels get. */
|
||||
actions: ShellApi;
|
||||
env: {
|
||||
isMobile: boolean;
|
||||
isElectron: boolean;
|
||||
user: { username: string; isAdmin: boolean };
|
||||
permissions: string[];
|
||||
branding: {
|
||||
appName: string | null;
|
||||
tagline: string | null;
|
||||
logo: string | null;
|
||||
};
|
||||
};
|
||||
/** Core registries, as hooks a layout calls while rendering. */
|
||||
registries: {
|
||||
useRailItems: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
usePanels: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
useTabTypes: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
useDashboardCards: () => ReadonlyArray<
|
||||
{ id: string } & Record<string, unknown>
|
||||
>;
|
||||
};
|
||||
logout: (options?: { manual?: boolean }) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registrations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -530,8 +653,16 @@ export interface HostBadgeContribution {
|
||||
id: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
component: ComponentType<{ host: PluginHostRecord }>;
|
||||
/** Lower comes first. Ties sort by id. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** The dot or stripe that shows a host's status on its row. */
|
||||
export type HostStatusIndicatorContribution = HostBadgeContribution;
|
||||
|
||||
/** A control in the host editor's toolbar. */
|
||||
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
||||
|
||||
export interface HostContextMenuItemContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
@@ -683,6 +814,46 @@ export interface SettingsComponentProps {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/** What a companion starts with. Read from the plugin's own settings. */
|
||||
export interface CompanionDefaults {
|
||||
/** Open by itself the first time a matching tab shows. */
|
||||
open?: boolean;
|
||||
side?: "left" | "right" | "bottom";
|
||||
/** Pixels of width, or height at the bottom. */
|
||||
size?: number;
|
||||
/** Show the edge button that opens and closes it. Default true. */
|
||||
edgeButton?: boolean;
|
||||
}
|
||||
|
||||
export interface CompanionProps {
|
||||
/** The tab it sits beside. */
|
||||
tab: PluginTabRecord;
|
||||
host?: PluginHostRecord;
|
||||
/** The host in the shape the connection APIs take. */
|
||||
sshHost?: Record<string, unknown>;
|
||||
/** The tab's surface handle (see TabHandle), read at call time. */
|
||||
getHandle: () => TabHandle | null;
|
||||
side: "left" | "right" | "bottom";
|
||||
/** False while the tab is in the background or the panel is closed. */
|
||||
isVisible: boolean;
|
||||
close: () => void;
|
||||
setSide: (side: "left" | "right" | "bottom") => void;
|
||||
}
|
||||
|
||||
export interface CompanionOptions {
|
||||
icon?: IconComponent;
|
||||
titleKey?: string;
|
||||
order?: number;
|
||||
/** Tab types it applies to. Any tab with a host when empty. */
|
||||
tabTypes?: string[];
|
||||
when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean;
|
||||
/**
|
||||
* A hook returning the starting state. It runs in the plugin's scope, so it
|
||||
* can read the plugin's settings.
|
||||
*/
|
||||
useDefaults?: () => CompanionDefaults;
|
||||
}
|
||||
|
||||
export type ActionHandler = (...args: never[]) => unknown;
|
||||
|
||||
export interface ActionSlotDefinition {
|
||||
@@ -715,10 +886,11 @@ export type OnboardingMode = "full" | "partial" | "rerun";
|
||||
* Where a plugin step sits in onboarding. "setup" comes right after the
|
||||
* appearance steps, for things that need doing before the feature works
|
||||
* (like pointing at a guacd server). "explore" (the default) comes after
|
||||
* that, "security" after the account security step. Onboarding is for
|
||||
* that, "security" after the account security step. "start" comes before
|
||||
* the appearance steps, for choices that shape how the rest looks. Onboarding is for
|
||||
* choices, not for explaining features.
|
||||
*/
|
||||
export type OnboardingSection = "setup" | "explore" | "security";
|
||||
export type OnboardingSection = "start" | "setup" | "explore" | "security";
|
||||
|
||||
export interface OnboardingStepProps {
|
||||
/**
|
||||
@@ -761,6 +933,16 @@ export interface OnboardingStepContribution {
|
||||
permission?: string;
|
||||
section?: OnboardingSection;
|
||||
order?: number;
|
||||
/**
|
||||
* Exact place in the flow, 1 to 99, instead of a section. Core steps sit at
|
||||
* 10 (plugins), 20, 25, 30 (preset), 40 (appearance) and 90 (security).
|
||||
*/
|
||||
position?: number;
|
||||
/**
|
||||
* Older step keys this step took over, like "preset". People who already
|
||||
* saw one of them are not shown this step again.
|
||||
*/
|
||||
replaces?: string[];
|
||||
when?: (context: {
|
||||
isAdmin: boolean;
|
||||
isDesktop: boolean;
|
||||
@@ -926,12 +1108,32 @@ export interface TermixApp extends TermixAppInfo {
|
||||
component: ComponentType<TabProps>,
|
||||
options?: TabOptions,
|
||||
) => Disposer;
|
||||
/**
|
||||
* A panel that sits beside any host tab the options match (a terminal, a
|
||||
* desktop, ...), bound to that tab's host. The user opens, resizes and moves
|
||||
* it; the tab keeps running underneath.
|
||||
*/
|
||||
registerCompanion: (
|
||||
id: string,
|
||||
component: ComponentType<CompanionProps>,
|
||||
options?: CompanionOptions,
|
||||
) => Disposer;
|
||||
/** Opens a companion on a tab, or closes it when it is showing. */
|
||||
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
|
||||
registerHostEditorSection: (
|
||||
section: HostEditorSectionContribution,
|
||||
) => Disposer;
|
||||
registerHostAction: (action: HostActionContribution) => Disposer;
|
||||
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
|
||||
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
|
||||
/** Draws a host row's status. A layout renders every registered one. */
|
||||
registerHostStatusIndicator: (
|
||||
indicator: HostStatusIndicatorContribution,
|
||||
) => Disposer;
|
||||
/** Adds a control to the host editor's toolbar. */
|
||||
registerHostEditorToolbarItem: (
|
||||
item: HostEditorToolbarItemContribution,
|
||||
) => Disposer;
|
||||
registerHostContextMenuItem: (
|
||||
item: HostContextMenuItemContribution,
|
||||
) => Disposer;
|
||||
@@ -988,6 +1190,12 @@ export interface TermixApp extends TermixAppInfo {
|
||||
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
||||
/**
|
||||
* A whole-app layout. Declared in manifest contributes.layouts with the
|
||||
* ui:layout capability. Only the chosen layout's plugin is loaded, so
|
||||
* register it in activate. Needs plugin API 1.4.
|
||||
*/
|
||||
registerLayout: (layout: LayoutContribution) => Disposer;
|
||||
|
||||
/**
|
||||
* Translates a key from the plugin's own locales, for code outside a
|
||||
@@ -1122,6 +1330,7 @@ export interface PluginHostBridge {
|
||||
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
||||
useCurrentUser: () => CurrentUser | null;
|
||||
useTheme: () => { theme: "light" | "dark" };
|
||||
useShell: () => ShellController | null;
|
||||
toast: ToastApi;
|
||||
getApi: (pluginId: string) => PluginApiClient;
|
||||
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||
@@ -1372,6 +1581,14 @@ export function useTheme(): { theme: "light" | "dark" } {
|
||||
return requireHost().useTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* The live shell, for a layout. Null outside the signed-in app. Needs plugin
|
||||
* API 1.4.
|
||||
*/
|
||||
export function useShell(): ShellController | null {
|
||||
return requireHost().useShell();
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
return requireHost().toast;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type PluginEnvVar,
|
||||
} from "./docs.js";
|
||||
|
||||
export { validateThemeCss } from "./theme-css.js";
|
||||
export {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
@@ -36,7 +37,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.2.0";
|
||||
export const PLUGIN_API_VERSION = "1.10.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
|
||||
keyboardInteractive?: string[];
|
||||
}
|
||||
|
||||
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
|
||||
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
|
||||
/**
|
||||
* A whole-app layout the frontend registers with app.registerLayout. Needs
|
||||
* the ui:layout capability. Declared here so core can list and choose it
|
||||
* before any plugin code runs.
|
||||
*/
|
||||
export interface PluginLayoutContribution {
|
||||
id: string;
|
||||
/** i18n key in the plugin's namespace, or plain text. */
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Theme picked with this layout when nobody chose one. */
|
||||
defaultTheme?: string;
|
||||
/** The layout a default-look plugin starts with. */
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
|
||||
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
|
||||
*/
|
||||
export interface PluginThemeContribution {
|
||||
id: string;
|
||||
/** i18n key in the plugin's namespace, or plain text. */
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
/** Path of the CSS file inside the plugin. */
|
||||
css: string;
|
||||
/** Colors the theme picker previews it with. */
|
||||
swatch?: { background: string; panel: string; text: string };
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
@@ -487,6 +522,10 @@ export interface PluginContributions {
|
||||
* ctx.credentials.resolveHostProtocol.
|
||||
*/
|
||||
protocols?: PluginProtocolContribution[];
|
||||
/** Whole-app layouts. Needs the ui:layout capability. */
|
||||
layouts?: PluginLayoutContribution[];
|
||||
/** Themes. Needs the ui:theme capability. */
|
||||
themes?: PluginThemeContribution[];
|
||||
}
|
||||
|
||||
/** A field a protocol login carries besides username and password. */
|
||||
@@ -649,6 +688,21 @@ export interface PluginManifest {
|
||||
nativeDependencies?: string[];
|
||||
/** How a linked desktop treats this plugin. Defaults to "mirror". */
|
||||
desktop?: PluginDesktopMode;
|
||||
/**
|
||||
* "desktop": the plugin only loads on a desktop app's own embedded backend,
|
||||
* never on a server. It implies desktop "local" and may declare desktop:local.
|
||||
*/
|
||||
runtime?: PluginRuntime;
|
||||
}
|
||||
|
||||
export type PluginRuntime = "desktop";
|
||||
export const PLUGIN_RUNTIMES: readonly PluginRuntime[] = ["desktop"];
|
||||
|
||||
/** The desktop mode a manifest resolves to. */
|
||||
export function resolvePluginDesktopMode(
|
||||
m: Pick<PluginManifest, "desktop" | "runtime">,
|
||||
): PluginDesktopMode {
|
||||
return m.desktop ?? (m.runtime === "desktop" ? "local" : "mirror");
|
||||
}
|
||||
|
||||
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
|
||||
@@ -688,6 +742,7 @@ const ALLOWED_TOP_LEVEL = new Set([
|
||||
"platforms",
|
||||
"nativeDependencies",
|
||||
"desktop",
|
||||
"runtime",
|
||||
]);
|
||||
|
||||
const ALLOWED_CONTRIBUTES = new Set([
|
||||
@@ -708,6 +763,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"uses",
|
||||
"keybindingActions",
|
||||
"protocols",
|
||||
"layouts",
|
||||
"themes",
|
||||
]);
|
||||
|
||||
const ALLOWED_SETTINGS_FIELD = [
|
||||
@@ -863,6 +920,21 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
m.runtime !== undefined &&
|
||||
!PLUGIN_RUNTIMES.includes(m.runtime as PluginRuntime)
|
||||
) {
|
||||
errors.push(
|
||||
`Field "runtime" must be one of: ${PLUGIN_RUNTIMES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
m.runtime === "desktop" &&
|
||||
m.desktop !== undefined &&
|
||||
m.desktop !== "local"
|
||||
) {
|
||||
errors.push(`A "desktop" runtime plugin must use desktop "local"`);
|
||||
}
|
||||
validateDependencyMap(m.dependencies, "dependencies", errors);
|
||||
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
|
||||
validateProvides(m.provides, errors);
|
||||
@@ -1300,6 +1372,111 @@ function validateContributes(
|
||||
validateUiPresets(contributes.uiPresets, errors);
|
||||
validateKeybindingActions(contributes.keybindingActions, errors);
|
||||
validateProtocols(contributes.protocols, errors);
|
||||
validateLayouts(contributes.layouts, errors);
|
||||
validateThemes(contributes.themes, errors);
|
||||
}
|
||||
|
||||
/** An array of objects with unique look ids; calls `each` for every valid entry. */
|
||||
function validateLookList(
|
||||
value: unknown,
|
||||
field: string,
|
||||
allowed: string[],
|
||||
errors: string[],
|
||||
each: (raw: Record<string, unknown>, where: string) => void,
|
||||
): void {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`Field "contributes.${field}" must be an array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
value.forEach((raw, index) => {
|
||||
const where = `contributes.${field}[${index}]`;
|
||||
if (!isPlainObject(raw)) {
|
||||
errors.push(`${where} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(raw, allowed, where, errors);
|
||||
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
|
||||
errors.push(
|
||||
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||
);
|
||||
} else {
|
||||
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
|
||||
seen.add(raw.id);
|
||||
}
|
||||
requireString(raw.label, `${where}.label`, errors);
|
||||
each(raw, where);
|
||||
});
|
||||
}
|
||||
|
||||
function validateLayouts(value: unknown, errors: string[]): void {
|
||||
validateLookList(
|
||||
value,
|
||||
"layouts",
|
||||
["id", "label", "description", "defaultTheme", "default"],
|
||||
errors,
|
||||
(raw, where) => {
|
||||
if (raw.description !== undefined)
|
||||
requireString(raw.description, `${where}.description`, errors);
|
||||
if (
|
||||
raw.defaultTheme !== undefined &&
|
||||
(typeof raw.defaultTheme !== "string" ||
|
||||
!LOOK_ID_PATTERN.test(raw.defaultTheme))
|
||||
) {
|
||||
errors.push(`${where}.defaultTheme must be a theme id`);
|
||||
}
|
||||
if (raw.default !== undefined && typeof raw.default !== "boolean") {
|
||||
errors.push(`${where}.default must be a boolean`);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
|
||||
) {
|
||||
errors.push('Only one entry in "contributes.layouts" may set default');
|
||||
}
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
function validateThemes(value: unknown, errors: string[]): void {
|
||||
validateLookList(
|
||||
value,
|
||||
"themes",
|
||||
["id", "label", "mode", "css", "swatch"],
|
||||
errors,
|
||||
(raw, where) => {
|
||||
if (raw.mode !== "light" && raw.mode !== "dark") {
|
||||
errors.push(`${where}.mode must be "light" or "dark"`);
|
||||
}
|
||||
if (requireString(raw.css, `${where}.css`, errors)) {
|
||||
requireRelativePath(raw.css, `${where}.css`, errors);
|
||||
if (!raw.css.endsWith(".css")) {
|
||||
errors.push(`${where}.css must be a .css file`);
|
||||
}
|
||||
}
|
||||
if (raw.swatch !== undefined) {
|
||||
if (!isPlainObject(raw.swatch)) {
|
||||
errors.push(`${where}.swatch must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
raw.swatch,
|
||||
["background", "panel", "text"],
|
||||
`${where}.swatch`,
|
||||
errors,
|
||||
);
|
||||
for (const key of ["background", "panel", "text"]) {
|
||||
const color = raw.swatch[key];
|
||||
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
|
||||
errors.push(`${where}.swatch.${key} must be a hex color`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
@@ -2324,6 +2501,23 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
|
||||
!manifest.capabilities.includes("ui:layout")
|
||||
) {
|
||||
errors.push(
|
||||
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
|
||||
);
|
||||
}
|
||||
if (
|
||||
(manifest.contributes?.themes?.length ?? 0) > 0 &&
|
||||
!manifest.capabilities.includes("ui:theme")
|
||||
) {
|
||||
errors.push(
|
||||
'contributes.themes needs the "ui:theme" capability in the capabilities array',
|
||||
);
|
||||
}
|
||||
|
||||
for (const secret of manifest.requiresSecret ?? []) {
|
||||
if (secret.plugin === manifest.id) {
|
||||
errors.push(
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* SSH key helpers for plugins that make, check or deploy keys: generate a key
|
||||
* pair, tell what a key is, derive a public key and check a pair belongs
|
||||
* together. Pure functions over ssh2, which the host supplies. Core keeps its
|
||||
* own copy for the connect pipeline.
|
||||
*/
|
||||
|
||||
import ssh2 from "ssh2";
|
||||
|
||||
export type SshKeyKind = "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256";
|
||||
|
||||
export interface GeneratedSshKeyPair {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
keyType: SshKeyKind;
|
||||
}
|
||||
|
||||
export interface GenerateSshKeyPairOptions {
|
||||
/** RSA only. 2048 to 8192, default 4096. */
|
||||
bits?: number;
|
||||
passphrase?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/** Makes a new key pair; the public half is one authorized_keys line. */
|
||||
export function generateSshKeyPair(
|
||||
type: SshKeyKind,
|
||||
options: GenerateSshKeyPairOptions = {},
|
||||
): GeneratedSshKeyPair {
|
||||
const algorithm =
|
||||
type === "ssh-rsa" ? "rsa" : type === "ssh-ed25519" ? "ed25519" : "ecdsa";
|
||||
if (
|
||||
type !== "ssh-rsa" &&
|
||||
type !== "ssh-ed25519" &&
|
||||
type !== "ecdsa-sha2-nistp256"
|
||||
) {
|
||||
throw new Error(`Unsupported key type: ${String(type)}`);
|
||||
}
|
||||
const generateOptions: Record<string, unknown> = {
|
||||
comment: options.comment ?? "",
|
||||
};
|
||||
if (options.passphrase) {
|
||||
generateOptions.passphrase = options.passphrase;
|
||||
generateOptions.cipher = "aes256-ctr";
|
||||
}
|
||||
if (type === "ssh-rsa") {
|
||||
const bits = options.bits ?? 4096;
|
||||
if (!Number.isInteger(bits) || bits < 2048 || bits > 8192) {
|
||||
throw new Error("RSA keys need 2048 to 8192 bits");
|
||||
}
|
||||
generateOptions.bits = bits;
|
||||
}
|
||||
if (type === "ecdsa-sha2-nistp256") generateOptions.bits = 256;
|
||||
|
||||
const pair = ssh2.utils.generateKeyPairSync(
|
||||
algorithm as "rsa",
|
||||
generateOptions as never,
|
||||
);
|
||||
return {
|
||||
privateKey: pair.private,
|
||||
publicKey: pair.public.trim(),
|
||||
keyType: type,
|
||||
};
|
||||
}
|
||||
|
||||
function parse(key: string, passphrase?: string) {
|
||||
const parsed = ssh2.utils.parseKey(key.trim(), passphrase || undefined);
|
||||
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (!first || first instanceof Error) {
|
||||
throw first instanceof Error ? first : new Error("Not a readable SSH key");
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */
|
||||
export function detectSshKeyType(
|
||||
key: string,
|
||||
passphrase?: string,
|
||||
): string | null {
|
||||
try {
|
||||
return parse(key, passphrase).type;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The authorized_keys line for a private key, or null. */
|
||||
export function publicKeyFromPrivate(
|
||||
privateKey: string,
|
||||
passphrase?: string,
|
||||
): string | null {
|
||||
try {
|
||||
const parsed = parse(privateKey, passphrase);
|
||||
return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a public key line belongs to the private key. */
|
||||
export function validateSshKeyPair(
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
passphrase?: string,
|
||||
): { isValid: boolean; error?: string } {
|
||||
try {
|
||||
const priv = parse(privateKey, passphrase);
|
||||
const pub = parse(publicKey);
|
||||
const same = priv.getPublicSSH().equals(pub.getPublicSSH());
|
||||
return same
|
||||
? { isValid: true }
|
||||
: {
|
||||
isValid: false,
|
||||
error: "The public key does not match the private key",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
PluginCapabilityError,
|
||||
} from "./backend.js";
|
||||
import type {
|
||||
PluginAuditEntry,
|
||||
PluginAuditFilters,
|
||||
PluginContext,
|
||||
PluginDesktopLocal,
|
||||
PluginDisposables,
|
||||
PluginLogger,
|
||||
} from "./backend.js";
|
||||
@@ -65,12 +68,19 @@ import type {
|
||||
PluginSshKeyCredential,
|
||||
PluginSshKeyCredentialInput,
|
||||
} from "./backend.js";
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
import type {
|
||||
PluginExternalClientRequest,
|
||||
PluginProtocolTarget,
|
||||
PluginHostProbeTarget,
|
||||
PluginHostStatusEntry,
|
||||
PluginActivityProvider,
|
||||
PluginHostLockProvider,
|
||||
PluginAuthPolicyProvider,
|
||||
PluginCredentialSummary,
|
||||
PluginHostShareGrant,
|
||||
PluginHostStatusProvider,
|
||||
PluginNotification,
|
||||
PluginNotificationChannel,
|
||||
PluginNotifyHub,
|
||||
@@ -94,6 +104,8 @@ export interface FakeScheduledJob {
|
||||
|
||||
export interface FakeContextOptions {
|
||||
pluginId?: string;
|
||||
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||
syncHost?: Partial<PluginSyncHost>;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Acting user returned by currentActor and used by asUser. */
|
||||
actor?: string;
|
||||
@@ -101,6 +113,8 @@ export interface FakeContextOptions {
|
||||
settings?: Record<string, unknown>;
|
||||
/** Seed for the core settings ctx.settings.readCore can reach. */
|
||||
coreSettings?: Record<string, string>;
|
||||
/** What ctx.files.dataDir() answers. Defaults to /tmp/<pluginId>. */
|
||||
dataDir?: string;
|
||||
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
||||
sshClient?: unknown;
|
||||
/** A real database behind ctx.db, usually createTestDb().database. */
|
||||
@@ -132,10 +146,22 @@ export interface FakeContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.credentials.listSshKeys answers. */
|
||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||
/** What ctx.credentials.list answers. */
|
||||
credentialSummaries?: PluginCredentialSummary[];
|
||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.desktop.local is. Calls throw when this is not given. */
|
||||
desktopLocal?: PluginDesktopLocal;
|
||||
/** The stored audit lines ctx.audit.list and exportBatches serve. */
|
||||
auditEntries?: PluginAuditEntry[];
|
||||
/** What ctx.hosts.status.get and check answer, by host id. */
|
||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||
/** What ctx.hosts.probeTargets answers. */
|
||||
probeTargets?: PluginHostProbeTarget[];
|
||||
/** Host ids ctx.hosts.sessionActive answers true for. */
|
||||
activeSessions?: number[];
|
||||
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. Defaults to false. */
|
||||
reachable?: (target: string | number, port?: number) => boolean;
|
||||
/** The acting user's channels, what ctx.notify.channels answers. */
|
||||
notificationChannels?: PluginNotificationChannel[];
|
||||
/**
|
||||
@@ -172,8 +198,12 @@ export interface FakeContextOptions {
|
||||
baseUrl?: string;
|
||||
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
|
||||
linkedUsers?: Record<string, number>;
|
||||
/** Second factors from other plugins that ctx.auth.listSecondFactors adds. */
|
||||
secondFactors?: { id: string; pluginId: string }[];
|
||||
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
||||
loginAttemptLimit?: number;
|
||||
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
|
||||
locales?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FakeProcessOptions {
|
||||
@@ -247,6 +277,10 @@ export interface FakeAuthRegistrations {
|
||||
}>;
|
||||
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
|
||||
loginFailures: Map<string, number>;
|
||||
/** Every ctx.auth.issueUserToken call, in order. */
|
||||
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
|
||||
/** Providers passed to ctx.auth.policy.provide that are still registered. */
|
||||
policies: PluginAuthPolicyProvider[];
|
||||
}
|
||||
|
||||
export interface FakePluginContext {
|
||||
@@ -259,6 +293,12 @@ export interface FakePluginContext {
|
||||
) => Promise<Record<string, string>>;
|
||||
/** Everything the plugin registered through ctx.disposables. */
|
||||
disposals: Array<() => void | Promise<void>>;
|
||||
/** Providers registered through ctx.hosts.status.provide. */
|
||||
hostStatusProviders: PluginHostStatusProvider[];
|
||||
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
|
||||
activityProviders: PluginActivityProvider[];
|
||||
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
|
||||
hostLockProviders: PluginHostLockProvider[];
|
||||
/** Topics emitted through ctx.events.emit, in order. */
|
||||
emitted: Array<{ topic: string; payload: unknown }>;
|
||||
/** Backing store behind ctx.kv. */
|
||||
@@ -291,6 +331,8 @@ export interface FakePluginContext {
|
||||
permissionLevel: string;
|
||||
durationHours?: number;
|
||||
}>;
|
||||
/** Every ctx.hosts.setShares call, in order. */
|
||||
hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>;
|
||||
/** Everything registered through ctx.auth. */
|
||||
auth: FakeAuthRegistrations;
|
||||
/** Every ctx.desktop.openIsolatedWindow call, in order. */
|
||||
@@ -334,6 +376,8 @@ export interface FakePluginContext {
|
||||
challenges: Map<string, string>;
|
||||
renewers: number;
|
||||
};
|
||||
/** Calls every ctx.audit.subscribe handler, like a line core just accepted. */
|
||||
emitAudit: (entry: PluginAuditEntry) => void;
|
||||
/** Every ctx.audit.record entry, in order. */
|
||||
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
|
||||
/** Every ctx.hosts.recordActivity call, in order. */
|
||||
@@ -460,7 +504,14 @@ function applyHostInput(
|
||||
const {
|
||||
tags,
|
||||
pluginSettings: _settings,
|
||||
pluginEnable: _pluginEnable,
|
||||
forceKeyboardInteractive: _fki,
|
||||
password: _password,
|
||||
key: _key,
|
||||
keyPassword: _keyPassword,
|
||||
keyType: _keyType,
|
||||
sudoPassword: _sudoPassword,
|
||||
protocolAuth: _protocolAuth,
|
||||
...rest
|
||||
} = input;
|
||||
const next: PluginHostRecord = { ...record };
|
||||
@@ -487,6 +538,9 @@ export function createFakeContext(
|
||||
const pluginId = options.pluginId ?? "test-plugin";
|
||||
const disposals: Array<() => void | Promise<void>> = [];
|
||||
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
||||
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
||||
const activityProviders: PluginActivityProvider[] = [];
|
||||
const hostLockProviders: PluginHostLockProvider[] = [];
|
||||
const kv = new Map<string, unknown>();
|
||||
const tables: PluginTableDefinition[] = [];
|
||||
const syncEntities: SyncEntityRegistration[] = [];
|
||||
@@ -504,6 +558,7 @@ export function createFakeContext(
|
||||
}>();
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const hostShares: FakePluginContext["hostShares"] = [];
|
||||
const hostShareSets: FakePluginContext["hostShareSets"] = [];
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
|
||||
[];
|
||||
@@ -527,6 +582,7 @@ export function createFakeContext(
|
||||
};
|
||||
const secretStore = new Map<string, string>();
|
||||
const audits: FakePluginContext["audits"] = [];
|
||||
const auditSubscribers = new Set<(entry: PluginAuditEntry) => void>();
|
||||
const activities: FakePluginContext["activities"] = [];
|
||||
const trackedSessions: number[] = [];
|
||||
const interactions: FakePluginContext["interactions"] = [];
|
||||
@@ -574,6 +630,8 @@ export function createFakeContext(
|
||||
completedLogins: [],
|
||||
revokedSessions: [],
|
||||
loginFailures: new Map(),
|
||||
userTokens: [],
|
||||
policies: [],
|
||||
};
|
||||
const sshClient = options.sshClient ?? {};
|
||||
let actor = options.actor;
|
||||
@@ -660,7 +718,7 @@ export function createFakeContext(
|
||||
files: {
|
||||
// A fake path; a test that needs a real folder uses the OS temp dir
|
||||
// itself, not this double.
|
||||
dataDir: async () => `/tmp/${pluginId}`,
|
||||
dataDir: async () => options.dataDir ?? `/tmp/${pluginId}`,
|
||||
},
|
||||
|
||||
db: options.db
|
||||
@@ -700,6 +758,12 @@ export function createFakeContext(
|
||||
registerEntity: (entity) => {
|
||||
syncEntities.push(entity);
|
||||
},
|
||||
provide: () => () => {},
|
||||
get host() {
|
||||
if (!options.syncHost)
|
||||
throw new Error("Pass syncHost to use ctx.sync.host");
|
||||
return options.syncHost as PluginSyncHost;
|
||||
},
|
||||
},
|
||||
|
||||
registry: {
|
||||
@@ -897,6 +961,27 @@ export function createFakeContext(
|
||||
disposables,
|
||||
|
||||
hosts: {
|
||||
importMany: async (rows) => {
|
||||
const created = rows.map(() => nextHostId++);
|
||||
return {
|
||||
success: created.length,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
createdHostIds: created,
|
||||
};
|
||||
},
|
||||
importSshConfig: async () => ({
|
||||
success: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
createdHostIds: [],
|
||||
}),
|
||||
exportMany: async () => ({ hosts: [] }),
|
||||
registerImportNormalizer: () => () => {},
|
||||
list: async () => [...hostsById.values()],
|
||||
get: async (hostId) => hostsById.get(hostId) ?? null,
|
||||
checkAccess: async (hostId): Promise<PluginHostAccess> => {
|
||||
@@ -951,6 +1036,10 @@ export function createFakeContext(
|
||||
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
setShares: async (hostId, grants) => {
|
||||
hostShareSets.push({ hostId, grants: [...grants] });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
listUsers: async () => options.shareableUsers ?? [],
|
||||
listRoles: async () => options.shareableRoles ?? [],
|
||||
trackSession: (hostId) => {
|
||||
@@ -966,7 +1055,42 @@ export function createFakeContext(
|
||||
recordActivity: async (hostId, type, hostName) => {
|
||||
activities.push({ hostId, type, hostName });
|
||||
},
|
||||
probeTargets: async (filter) =>
|
||||
(options.probeTargets ?? []).filter(
|
||||
(target) => !filter?.hostIds || filter.hostIds.includes(target.id),
|
||||
),
|
||||
sessionActive: (hostId) =>
|
||||
options.activeSessions?.includes(hostId) ?? false,
|
||||
activity: {
|
||||
provide: (provider) => {
|
||||
activityProviders.push(provider);
|
||||
return () => {
|
||||
const at = activityProviders.indexOf(provider);
|
||||
if (at >= 0) activityProviders.splice(at, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
ensure: async () => {},
|
||||
removeEmpty: async () => true,
|
||||
},
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
hostLockProviders.push(provider);
|
||||
return () => {
|
||||
const at = hostLockProviders.indexOf(provider);
|
||||
if (at >= 0) hostLockProviders.splice(at, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
provide: (provider) => {
|
||||
hostStatusProviders.push(provider);
|
||||
return () => {
|
||||
const at = hostStatusProviders.indexOf(provider);
|
||||
if (at >= 0) hostStatusProviders.splice(at, 1);
|
||||
};
|
||||
},
|
||||
get: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
check: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
reportLogin: (hostId, outcome) => {
|
||||
@@ -991,12 +1115,46 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
audit: {
|
||||
subscribe: (handler) => {
|
||||
auditSubscribers.add(handler);
|
||||
return () => {
|
||||
auditSubscribers.delete(handler);
|
||||
};
|
||||
},
|
||||
list: async (listOptions) => {
|
||||
const matching = filterAuditEntries(
|
||||
options.auditEntries ?? [],
|
||||
listOptions?.filters,
|
||||
).reverse();
|
||||
const offset = listOptions?.offset ?? 0;
|
||||
return {
|
||||
entries: matching.slice(offset, offset + (listOptions?.limit ?? 50)),
|
||||
total: matching.length,
|
||||
};
|
||||
},
|
||||
exportBatches: async function* (filters, batchSize) {
|
||||
const matching = filterAuditEntries(
|
||||
options.auditEntries ?? [],
|
||||
filters,
|
||||
);
|
||||
const size = batchSize ?? 500;
|
||||
for (let at = 0; at < matching.length; at += size) {
|
||||
yield matching.slice(at, at + size);
|
||||
}
|
||||
},
|
||||
record: async (entry) => {
|
||||
audits.push({ ...entry });
|
||||
},
|
||||
},
|
||||
|
||||
network: {
|
||||
tcpProbe: async (host, port) => options.reachable?.(host, port) ?? false,
|
||||
},
|
||||
|
||||
ssh: {
|
||||
transports: { provide: () => () => {} },
|
||||
probe: async (hostId, probeOptions) =>
|
||||
options.reachable?.(hostId, probeOptions?.port) ?? false,
|
||||
connect: async (host, connectOptions) => {
|
||||
sshConnections.push({ host, options: connectOptions });
|
||||
return {
|
||||
@@ -1008,6 +1166,16 @@ export function createFakeContext(
|
||||
dispose: () => {},
|
||||
};
|
||||
},
|
||||
exec: async (host) => {
|
||||
sshConnections.push({ host });
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
truncated: false,
|
||||
timedOut: false,
|
||||
};
|
||||
},
|
||||
withConnection: async (host, connectOptions, fn) => {
|
||||
sshConnections.push({ host, pool: connectOptions.pool });
|
||||
return fn(sshClient as never);
|
||||
@@ -1140,6 +1308,34 @@ export function createFakeContext(
|
||||
},
|
||||
countLinkedUsers: async (provider) =>
|
||||
options.linkedUsers?.[provider] ?? 0,
|
||||
issueUserToken: async (userId, tokenOptions) => {
|
||||
auth.userTokens.push({ userId, ...tokenOptions });
|
||||
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
|
||||
return {
|
||||
token: `fake-token-${auth.userTokens.length}`,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
||||
};
|
||||
},
|
||||
policy: {
|
||||
provide: (provider) => {
|
||||
auth.policies.push(provider);
|
||||
return () => {
|
||||
const at = auth.policies.indexOf(provider);
|
||||
if (at >= 0) auth.policies.splice(at, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
listSecondFactors: () => [
|
||||
...auth.secondFactors.map((factor) => ({
|
||||
id: factor.id,
|
||||
pluginId,
|
||||
})),
|
||||
...(options.secondFactors ?? []),
|
||||
],
|
||||
},
|
||||
|
||||
users: {
|
||||
getLocale: async (userId) => options.locales?.[userId] ?? null,
|
||||
},
|
||||
|
||||
desktop: {
|
||||
@@ -1152,9 +1348,11 @@ export function createFakeContext(
|
||||
return { success: true };
|
||||
},
|
||||
available: () => options.desktopAvailable ?? false,
|
||||
local: options.desktopLocal ?? unavailableDesktopLocal(),
|
||||
},
|
||||
|
||||
credentials: {
|
||||
list: async () => [...(options.credentialSummaries ?? [])],
|
||||
listSshKeys: async () => [
|
||||
...(options.sshKeyCredentials ?? []),
|
||||
...createdSshKeys.map((key) => ({
|
||||
@@ -1178,6 +1376,13 @@ export function createFakeContext(
|
||||
credentialReads.push({ hostId, protocol });
|
||||
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
|
||||
},
|
||||
publicKey: async (credentialId) =>
|
||||
(options.sshKeyCredentials ?? []).find((k) => k.id === credentialId)
|
||||
?.publicKey ?? null,
|
||||
resolveKeyRefs: async (key, passphrase) => ({
|
||||
key,
|
||||
passphrase: passphrase ?? undefined,
|
||||
}),
|
||||
registerSecretResolver: (scheme, resolve) => {
|
||||
auth.secretResolvers.set(scheme, resolve);
|
||||
disposals.push(() => {
|
||||
@@ -1328,6 +1533,9 @@ export function createFakeContext(
|
||||
return errors;
|
||||
},
|
||||
disposals,
|
||||
hostStatusProviders,
|
||||
activityProviders,
|
||||
hostLockProviders,
|
||||
emitted,
|
||||
kv,
|
||||
tables,
|
||||
@@ -1338,6 +1546,7 @@ export function createFakeContext(
|
||||
coreSettings,
|
||||
sshConnections,
|
||||
hostShares,
|
||||
hostShareSets,
|
||||
auth,
|
||||
desktopWindows,
|
||||
externalClientLaunches,
|
||||
@@ -1351,6 +1560,9 @@ export function createFakeContext(
|
||||
tls,
|
||||
secretStore,
|
||||
audits,
|
||||
emitAudit: (entry) => {
|
||||
for (const handler of auditSubscribers) handler(entry);
|
||||
},
|
||||
activities,
|
||||
trackedSessions,
|
||||
interactions,
|
||||
@@ -1383,6 +1595,8 @@ export function createFakeContext(
|
||||
|
||||
export interface MockContextOptions {
|
||||
pluginId?: string;
|
||||
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||
syncHost?: Partial<PluginSyncHost>;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Capabilities the plugin is granted. Anything else throws. */
|
||||
capabilities?: string[];
|
||||
@@ -1392,6 +1606,8 @@ export interface MockContextOptions {
|
||||
settings?: Record<string, unknown>;
|
||||
/** Seed for the core settings readCore can reach. */
|
||||
coreSettings?: Record<string, string>;
|
||||
/** What ctx.files.dataDir() answers. */
|
||||
dataDir?: string;
|
||||
/** Acting user returned by currentActor and used by asUser. */
|
||||
actor?: string;
|
||||
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
||||
@@ -1415,10 +1631,22 @@ export interface MockContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.credentials.listSshKeys serves. */
|
||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||
/** What ctx.credentials.list serves. */
|
||||
credentialSummaries?: PluginCredentialSummary[];
|
||||
/** What ctx.desktop.available() answers. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.desktop.local is. See FakeContextOptions. */
|
||||
desktopLocal?: PluginDesktopLocal;
|
||||
/** See FakeContextOptions. */
|
||||
auditEntries?: PluginAuditEntry[];
|
||||
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||
/** What ctx.hosts.probeTargets answers. */
|
||||
probeTargets?: PluginHostProbeTarget[];
|
||||
/** Host ids ctx.hosts.sessionActive answers true for. */
|
||||
activeSessions?: number[];
|
||||
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. */
|
||||
reachable?: (target: string | number, port?: number) => boolean;
|
||||
/** The acting user's channels, what ctx.notify.channels answers. */
|
||||
notificationChannels?: PluginNotificationChannel[];
|
||||
/**
|
||||
@@ -1442,6 +1670,10 @@ export interface MockContextOptions {
|
||||
linkedUsers?: Record<string, number>;
|
||||
/** See FakeContextOptions. */
|
||||
loginAttemptLimit?: number;
|
||||
/** See FakeContextOptions. */
|
||||
secondFactors?: FakeContextOptions["secondFactors"];
|
||||
/** See FakeContextOptions. */
|
||||
locales?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -1458,9 +1690,67 @@ export interface MockPluginContext extends FakePluginContext {
|
||||
* topic outside the plugin's own namespace, settings:read-core on
|
||||
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
|
||||
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
|
||||
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
|
||||
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
|
||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
function filterAuditEntries(
|
||||
entries: PluginAuditEntry[],
|
||||
filters: PluginAuditFilters | undefined,
|
||||
): PluginAuditEntry[] {
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
(filters?.userId === undefined || entry.userId === filters.userId) &&
|
||||
(filters?.action === undefined || entry.action === filters.action) &&
|
||||
(filters?.resourceType === undefined ||
|
||||
entry.resourceType === filters.resourceType) &&
|
||||
(filters?.success === undefined || entry.success === filters.success) &&
|
||||
(filters?.startDate === undefined ||
|
||||
entry.timestamp >= filters.startDate) &&
|
||||
(filters?.endDate === undefined || entry.timestamp <= filters.endDate),
|
||||
);
|
||||
}
|
||||
|
||||
function gateDesktopLocal(
|
||||
target: PluginDesktopLocal,
|
||||
check: () => void,
|
||||
): PluginDesktopLocal {
|
||||
const wrap = (value: unknown): unknown => {
|
||||
if (typeof value === "function") {
|
||||
return (...args: unknown[]) => {
|
||||
check();
|
||||
return (value as (...a: unknown[]) => unknown)(...args);
|
||||
};
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return new Proxy(value as object, {
|
||||
get: (obj, key) => wrap((obj as Record<string | symbol, unknown>)[key]),
|
||||
});
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return new Proxy(target, {
|
||||
get: (obj, key) =>
|
||||
key === "available"
|
||||
? obj.available
|
||||
: wrap((obj as unknown as Record<string | symbol, unknown>)[key]),
|
||||
});
|
||||
}
|
||||
|
||||
function unavailableDesktopLocal(): PluginDesktopLocal {
|
||||
const fail = (): never => {
|
||||
throw new Error("ctx.desktop.local needs the desktopLocal test option");
|
||||
};
|
||||
const deep = (): unknown =>
|
||||
new Proxy(() => fail(), { get: () => deep(), apply: () => fail() });
|
||||
return {
|
||||
available: () => false,
|
||||
info: fail,
|
||||
fs: deep(),
|
||||
pty: deep(),
|
||||
} as unknown as PluginDesktopLocal;
|
||||
}
|
||||
|
||||
export function createMockCtx(
|
||||
options: MockContextOptions = {},
|
||||
): MockPluginContext {
|
||||
@@ -1469,9 +1759,11 @@ export function createMockCtx(
|
||||
|
||||
const base = createFakeContext({
|
||||
pluginId: options.pluginId,
|
||||
syncHost: options.syncHost,
|
||||
actor: options.actor,
|
||||
settings: options.settings,
|
||||
coreSettings: options.coreSettings,
|
||||
dataDir: options.dataDir,
|
||||
sshClient: options.sshClient,
|
||||
db: options.db,
|
||||
router: options.router,
|
||||
@@ -1483,8 +1775,14 @@ export function createMockCtx(
|
||||
services: options.services,
|
||||
protocolTargets: options.protocolTargets,
|
||||
sshKeyCredentials: options.sshKeyCredentials,
|
||||
credentialSummaries: options.credentialSummaries,
|
||||
desktopAvailable: options.desktopAvailable,
|
||||
desktopLocal: options.desktopLocal,
|
||||
auditEntries: options.auditEntries,
|
||||
hostStatuses: options.hostStatuses,
|
||||
probeTargets: options.probeTargets,
|
||||
activeSessions: options.activeSessions,
|
||||
reachable: options.reachable,
|
||||
notificationChannels: options.notificationChannels,
|
||||
fetch: options.fetch,
|
||||
process: options.process,
|
||||
@@ -1495,6 +1793,8 @@ export function createMockCtx(
|
||||
baseUrl: options.baseUrl,
|
||||
linkedUsers: options.linkedUsers,
|
||||
loginAttemptLimit: options.loginAttemptLimit,
|
||||
secondFactors: options.secondFactors,
|
||||
locales: options.locales,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -1629,6 +1929,23 @@ export function createMockCtx(
|
||||
},
|
||||
|
||||
hosts: {
|
||||
importMany: async (rows, importOptions) => {
|
||||
require("hosts:write");
|
||||
if (importOptions?.credentials?.length) require("credentials:write");
|
||||
return ctx.hosts.importMany(rows, importOptions);
|
||||
},
|
||||
importSshConfig: async (content, importOptions) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.importSshConfig(content, importOptions);
|
||||
},
|
||||
exportMany: async (exportOptions) => {
|
||||
require("hosts:export");
|
||||
return ctx.hosts.exportMany(exportOptions);
|
||||
},
|
||||
registerImportNormalizer: (normalizer) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.registerImportNormalizer(normalizer);
|
||||
},
|
||||
list: async () => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.list();
|
||||
@@ -1661,6 +1978,10 @@ export function createMockCtx(
|
||||
require("hosts:write");
|
||||
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
||||
},
|
||||
setShares: async (hostId, grants) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.setShares(hostId, grants);
|
||||
},
|
||||
listUsers: async () => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.listUsers();
|
||||
@@ -1677,7 +1998,42 @@ export function createMockCtx(
|
||||
require("hosts:read");
|
||||
return ctx.hosts.recordActivity(hostId, type, hostName);
|
||||
},
|
||||
probeTargets: async (filter) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.probeTargets(filter);
|
||||
},
|
||||
sessionActive: (hostId) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.sessionActive(hostId);
|
||||
},
|
||||
activity: {
|
||||
provide: (provider) => {
|
||||
require("hosts:activity-provide");
|
||||
return ctx.hosts.activity.provide(provider);
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
ensure: async (name, style) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.folders.ensure(name, style);
|
||||
},
|
||||
removeEmpty: async (name) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.folders.removeEmpty(name);
|
||||
},
|
||||
},
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
require("hosts:lock");
|
||||
return ctx.hosts.locks.provide(provider);
|
||||
},
|
||||
},
|
||||
status: {
|
||||
provide: (provider) => {
|
||||
require("hosts:read");
|
||||
require("hosts:status-provide");
|
||||
return ctx.hosts.status.provide(provider);
|
||||
},
|
||||
get: async (hostId) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.status.get(hostId);
|
||||
@@ -1699,11 +2055,26 @@ export function createMockCtx(
|
||||
|
||||
ssh: {
|
||||
...ctx.ssh,
|
||||
transports: {
|
||||
provide: (provider) => {
|
||||
require("ssh:transport");
|
||||
return ctx.ssh.transports.provide(provider);
|
||||
},
|
||||
},
|
||||
probe: async (hostId, probeOptions) => {
|
||||
require("ssh:connect");
|
||||
return ctx.ssh.probe(hostId, probeOptions);
|
||||
},
|
||||
connect: async (host, connectOptions) => {
|
||||
require("ssh:connect");
|
||||
require("credentials:use");
|
||||
return ctx.ssh.connect(host, connectOptions);
|
||||
},
|
||||
exec: async (host, command, execOptions) => {
|
||||
require("ssh:connect");
|
||||
require("credentials:use");
|
||||
return ctx.ssh.exec(host, command, execOptions);
|
||||
},
|
||||
withConnection: async (host, connectOptions, fn) => {
|
||||
require("ssh:connect");
|
||||
require("credentials:use");
|
||||
@@ -1786,6 +2157,40 @@ export function createMockCtx(
|
||||
require("auth:provide");
|
||||
return ctx.auth.countLinkedUsers(provider);
|
||||
},
|
||||
issueUserToken: async (userId, tokenOptions) => {
|
||||
require("users:impersonate");
|
||||
return ctx.auth.issueUserToken(userId, tokenOptions);
|
||||
},
|
||||
policy: {
|
||||
provide: (provider) => {
|
||||
require("auth:policy");
|
||||
return ctx.auth.policy.provide(provider);
|
||||
},
|
||||
},
|
||||
listSecondFactors: () => ctx.auth.listSecondFactors(),
|
||||
},
|
||||
|
||||
users: {
|
||||
getLocale: async (userId) => {
|
||||
require("users:read");
|
||||
return ctx.users.getLocale(userId);
|
||||
},
|
||||
},
|
||||
|
||||
audit: {
|
||||
record: (entry) => ctx.audit.record(entry),
|
||||
subscribe: (handler) => {
|
||||
require("audit:read");
|
||||
return ctx.audit.subscribe(handler);
|
||||
},
|
||||
list: async (listOptions) => {
|
||||
require("audit:read");
|
||||
return ctx.audit.list(listOptions);
|
||||
},
|
||||
exportBatches: (filters, batchSize) => {
|
||||
require("audit:read");
|
||||
return ctx.audit.exportBatches(filters, batchSize);
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
@@ -1798,9 +2203,16 @@ export function createMockCtx(
|
||||
return ctx.desktop.launchExternalClient(request);
|
||||
},
|
||||
available: () => ctx.desktop.available(),
|
||||
local: gateDesktopLocal(ctx.desktop.local, () =>
|
||||
require("desktop:local"),
|
||||
),
|
||||
},
|
||||
|
||||
credentials: {
|
||||
list: async () => {
|
||||
require("credentials:use");
|
||||
return ctx.credentials.list();
|
||||
},
|
||||
listSshKeys: async () => {
|
||||
require("credentials:use");
|
||||
return ctx.credentials.listSshKeys();
|
||||
@@ -1819,6 +2231,14 @@ export function createMockCtx(
|
||||
}
|
||||
return ctx.credentials.resolveHostProtocol(hostId, protocol);
|
||||
},
|
||||
publicKey: async (credentialId) => {
|
||||
require("credentials:use");
|
||||
return ctx.credentials.publicKey(credentialId);
|
||||
},
|
||||
resolveKeyRefs: async (key, passphrase) => {
|
||||
require("credentials:read");
|
||||
return ctx.credentials.resolveKeyRefs(key, passphrase);
|
||||
},
|
||||
registerSecretResolver: (scheme, resolve) => {
|
||||
require("auth:provide");
|
||||
ctx.credentials.registerSecretResolver(scheme, resolve);
|
||||
@@ -1845,6 +2265,13 @@ export function createMockCtx(
|
||||
return ctx.fetch(url, init);
|
||||
},
|
||||
|
||||
network: {
|
||||
tcpProbe: async (host, port, probeOptions) => {
|
||||
require("network:probe");
|
||||
return ctx.network.tcpProbe(host, port, probeOptions);
|
||||
},
|
||||
},
|
||||
|
||||
process: {
|
||||
run: async (file, args, runOptions) => {
|
||||
require("process:spawn");
|
||||
|
||||
@@ -97,6 +97,7 @@ const offline = () =>
|
||||
useHostStatus: () => null,
|
||||
useCurrentUser: () => null,
|
||||
useTheme: () => ({ theme: "dark" }),
|
||||
useShell: () => null,
|
||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||
getApi: () => ({
|
||||
get: offline,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Checks a theme's CSS before core serves it.
|
||||
*
|
||||
* A theme is colors and styles only, so its file may hold nothing but rules
|
||||
* whose every selector starts with the theme's own class (`.nord`,
|
||||
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
|
||||
* Anything that could load from elsewhere or run code is refused: @import,
|
||||
* url() to another host, other at-rules, expression() and javascript:.
|
||||
*/
|
||||
|
||||
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
|
||||
|
||||
function stripComments(css: string): string {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
}
|
||||
|
||||
/** Splits on commas that are not inside parentheses or brackets. */
|
||||
function splitSelectors(prelude: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const ch of prelude) {
|
||||
if (ch === "(" || ch === "[") depth += 1;
|
||||
if (ch === ")" || ch === "]") depth -= 1;
|
||||
if (ch === "," && depth === 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.map((part) => part.trim());
|
||||
}
|
||||
|
||||
function withoutStrings(css: string): string {
|
||||
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Index of the brace closing the one at `open`, skipping strings. */
|
||||
function matchingBrace(css: string, open: number): number {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = open; i < css.length; i += 1) {
|
||||
const ch = css[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i += 1;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") quote = ch;
|
||||
else if (ch === "{") depth += 1;
|
||||
else if (ch === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function checkUrls(css: string, errors: string[]): void {
|
||||
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
|
||||
for (const match of urls) {
|
||||
const target = match[2].trim();
|
||||
if (target.startsWith("data:")) continue;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
|
||||
errors.push(`url(${target}) points outside the plugin`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRules(
|
||||
css: string,
|
||||
scope: RegExp,
|
||||
themeId: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
let index = 0;
|
||||
while (index < css.length) {
|
||||
const rest = css.slice(index);
|
||||
if (rest.trim() === "") return;
|
||||
const open = css.indexOf("{", index);
|
||||
const semicolon = css.indexOf(";", index);
|
||||
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
|
||||
const statement = css.slice(index, semicolon).trim();
|
||||
errors.push(
|
||||
statement.startsWith("@")
|
||||
? `${statement.split(/\s/)[0]} is not allowed in a theme`
|
||||
: `Unexpected "${statement}" outside a rule`,
|
||||
);
|
||||
index = semicolon + 1;
|
||||
continue;
|
||||
}
|
||||
if (open === -1) {
|
||||
errors.push(`Unexpected "${rest.trim()}" at the end`);
|
||||
return;
|
||||
}
|
||||
const close = matchingBrace(css, open);
|
||||
if (close === -1) {
|
||||
errors.push("A rule is missing its closing brace");
|
||||
return;
|
||||
}
|
||||
const prelude = css.slice(index, open).trim();
|
||||
const body = css.slice(open + 1, close);
|
||||
|
||||
if (prelude.startsWith("@")) {
|
||||
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
||||
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
|
||||
checkRules(body, scope, themeId, errors);
|
||||
} else {
|
||||
errors.push(`@${name} is not allowed in a theme`);
|
||||
}
|
||||
} else {
|
||||
for (const selector of splitSelectors(prelude)) {
|
||||
if (!scope.test(selector)) {
|
||||
errors.push(
|
||||
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (withoutStrings(body).includes("{")) {
|
||||
errors.push(`Nested rules are not allowed in "${prelude}"`);
|
||||
}
|
||||
}
|
||||
index = close + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every problem with a theme's CSS, empty when it is safe to serve. */
|
||||
export function validateThemeCss(css: string, themeId: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const clean = stripComments(css);
|
||||
if (/@import\b/i.test(clean))
|
||||
errors.push("@import is not allowed in a theme");
|
||||
if (/expression\s*\(/i.test(clean))
|
||||
errors.push("expression() is not allowed in a theme");
|
||||
if (/javascript:/i.test(clean))
|
||||
errors.push("javascript: is not allowed in a theme");
|
||||
if (/-moz-binding|behavior\s*:/i.test(clean))
|
||||
errors.push("Script bindings are not allowed in a theme");
|
||||
checkUrls(clean, errors);
|
||||
const id = escapeRegExp(themeId);
|
||||
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
|
||||
checkRules(clean, scope, themeId, errors);
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
|
||||
*/
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -30,6 +31,25 @@ function findRepoRoot(startDir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* termix-plugin build turns a .wasm or font import into its bytes. Tests get the same,
|
||||
* so code that imports a module compiles it the same way under vitest.
|
||||
*/
|
||||
export function wasmBinaryPlugin() {
|
||||
return {
|
||||
name: "termix-wasm-binary",
|
||||
enforce: "pre" as const,
|
||||
load(id: string) {
|
||||
const file = id.split("?")[0];
|
||||
if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) {
|
||||
return null;
|
||||
}
|
||||
const base64 = fs.readFileSync(file).toString("base64");
|
||||
return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginVitestOptions {
|
||||
/** Extra aliases merged over the defaults. */
|
||||
alias?: Record<string, string>;
|
||||
@@ -96,15 +116,52 @@ export function pluginVitestConfig(
|
||||
// unexpectedly". Threads don't.
|
||||
const pool = "threads" as const;
|
||||
|
||||
const plugins = [wasmBinaryPlugin()];
|
||||
|
||||
// A local SDK linked into node_modules sits outside the plugin folder, so
|
||||
// Vite must be allowed to read it, and React must come from the plugin.
|
||||
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const server = { fs: { allow: [pluginDir, sdkDir] } };
|
||||
// dedupe alone is not enough: react-dom or the testing library loaded from
|
||||
// the linked SDK's own node_modules pull in a second React, and hooks then
|
||||
// see no renderer.
|
||||
if (!sdkDir.startsWith(pluginDir)) {
|
||||
const pluginRequire = createRequire(path.join(pluginDir, "package.json"));
|
||||
for (const name of [
|
||||
"react",
|
||||
"react-dom",
|
||||
"@testing-library/react",
|
||||
"react-i18next",
|
||||
"i18next",
|
||||
]) {
|
||||
if (alias[name]) continue;
|
||||
try {
|
||||
alias[name] = path.dirname(
|
||||
pluginRequire.resolve(`${name}/package.json`),
|
||||
);
|
||||
} catch {
|
||||
// Not installed in the plugin: leave it to normal resolution.
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolve = {
|
||||
alias,
|
||||
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
|
||||
};
|
||||
|
||||
return {
|
||||
resolve: { alias },
|
||||
plugins,
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
pool,
|
||||
projects: [
|
||||
{
|
||||
resolve: { alias },
|
||||
plugins,
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
name: `${pluginId}:backend`,
|
||||
root: pluginDir,
|
||||
@@ -116,7 +173,9 @@ export function pluginVitestConfig(
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: { alias },
|
||||
plugins,
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
name: `${pluginId}:frontend`,
|
||||
root: pluginDir,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"moduleDetection": "force",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"preserveSymlinks": true,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
ACCENT_PRESET_COLORS (value): { label: string; value: string; }[]
|
||||
APP_VERSION (value): string
|
||||
AccessRecord (type): AccessRecord
|
||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
||||
ActiveSessionInfo (type): ActiveSessionInfo
|
||||
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
AdminSection (type): AdminSection
|
||||
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
||||
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
||||
@@ -14,14 +19,23 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
|
||||
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
ApiKey (type): ApiKey
|
||||
AuditLog (type): AuditLog
|
||||
AuditLogFilters (type): AuditLogFilters
|
||||
AuthEnrollmentSections (value): ({ factorsOnly, }?: { factorsOnly?: boolean; }) => Element
|
||||
AuthOverrideProtocol (type): string
|
||||
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
||||
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
||||
BarSeriesItem (type): BarSeriesItem
|
||||
BarSeriesProps (type): BarSeriesProps
|
||||
BrandingSettings (type): BrandingSettings
|
||||
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
|
||||
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
ButtonProps (type): ButtonProps
|
||||
CORE_HOST_DEFAULTS (value): readonly CoreHostDefault[]
|
||||
CORE_NAMESPACE (value): "core"
|
||||
CREDENTIAL_ROW_FIELD_KEYS (value): (keyof CredentialRowFields)[]
|
||||
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
|
||||
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
@@ -33,6 +47,12 @@ CardMasonry (value): ({ columns, stack, className, children, }: { columns: numbe
|
||||
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
||||
Command (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandEmpty (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandGroup (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandItem (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandList (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandPrimitive (value): ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>> & { List: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>>; Item: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Input: ForwardRefExoticComponent<Omit<Pick<Pick<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof InputHTMLAttributes<HTMLInputElement>> & { ref?: Ref<HTMLInputElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof InputHTMLAttributes<HTMLInputElement>>, "type" | "value" | "onChange"> & { value?: string; onValueChange?: (search: string) => void; } & RefAttributes<HTMLInputElement>>; Group: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Separator: ForwardRefExoticComponent<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { alwaysRender?: boolean; } & RefAttributes<HTMLDivElement>>; Dialog: ForwardRefExoticComponent<DialogProps & Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & { overlayClassName?: string; contentClassName?: string; container?: HTMLElement; } & RefAttributes<HTMLDivElement>>; Empty: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>>; Loading: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { progress?: number; label?: string; } & RefAttributes<HTMLDivElement>>; }
|
||||
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
||||
ConfirmOptions (type): ConfirmOptions
|
||||
ConnectionGate (value): (props: ConnectionGateProps) => Element
|
||||
@@ -45,9 +65,26 @@ ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable,
|
||||
ConnectionStage (type): ConnectionStage
|
||||
ConnectionStatus (type): ConnectionStatus
|
||||
ConnectionUnavailable (type): ConnectionUnavailable
|
||||
CreatedApiKey (type): CreatedApiKey
|
||||
Credential (type): Credential
|
||||
CredentialDensity (type): CredentialDensity
|
||||
CredentialPermissionLevel (type): CredentialPermissionLevel
|
||||
CredentialRowFields (type): CredentialRowFields
|
||||
CredentialSidebarFilterState (type): CredentialSidebarFilterState
|
||||
CredentialSidebarPreferences (type): CredentialSidebarPreferences
|
||||
CredentialSortKey (type): CredentialSortKey
|
||||
CredentialTrayTrigger (type): CredentialTrayTrigger
|
||||
CustomKeybinding (type): CustomKeybinding
|
||||
DASHBOARD_CARDS (value): DashboardCardConfig[]
|
||||
DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||
DEFAULT_FOLDER_ICON (value): "folder"
|
||||
DashboardCardId (type): DashboardCardId
|
||||
DataColumn (type): DataColumn<T>
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
DefaultOverrides (type): DefaultOverrides
|
||||
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
DesktopUpdateMode (type): DesktopUpdateMode
|
||||
DesktopUpdateState (type): DesktopUpdateState
|
||||
Dialog (value): ({ ...props }: DialogProps) => Element
|
||||
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
||||
@@ -60,6 +97,7 @@ DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<
|
||||
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
|
||||
DocsLinkProps (type): DocsLinkProps
|
||||
DocsPage (type): "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry"
|
||||
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
||||
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
@@ -78,10 +116,14 @@ DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttrib
|
||||
EditingWidth (type): EditingWidth
|
||||
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
||||
FOLDER_COLORS (value): string[]
|
||||
FONT_SIZES (value): { id: FontSizeId; label: string; }[]
|
||||
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
||||
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
||||
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
||||
FolderAccessRule (type): FolderAccessRule
|
||||
FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element
|
||||
FontSizeId (type): FontSizeId
|
||||
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
||||
FullScreenAppPhase (type): FullScreenAppPhase
|
||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||
@@ -91,11 +133,36 @@ GridColumns (type): GridColumns
|
||||
GridLayout (type): GridLayout
|
||||
GridSlot (type): GridSlot
|
||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
|
||||
Host (type): Host
|
||||
HostActionDef (type): HostActionDef
|
||||
HostClickBehavior (type): HostClickBehavior
|
||||
HostData (type): HostData
|
||||
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
HostDefaultsChange (type): HostDefaultsChange
|
||||
HostDefaultsContext (value): Context<HostDefaultsContextValue>
|
||||
HostDefaultsContextValue (type): HostDefaultsContextValue
|
||||
HostDefaultsLevel (type): HostDefaultsLevel
|
||||
HostDefaultsTarget (type): HostDefaultsTarget
|
||||
HostDensity (type): HostDensity
|
||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostFolder (type): HostFolder
|
||||
HostGroupKey (type): HostGroupKey
|
||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||
HostLock (type): HostLock
|
||||
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
||||
HostPolicy (type): HostPolicy
|
||||
HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||
HostProtocolDef (type): HostProtocolDef
|
||||
HostProtocols (type): HostProtocols
|
||||
HostRowFields (type): HostRowFields
|
||||
HostSidebarFilterState (type): HostSidebarFilterState
|
||||
HostSidebarPreferences (type): HostSidebarPreferences
|
||||
HostTrayTrigger (type): HostTrayTrigger
|
||||
HttpsSettings (type): HttpsSettings
|
||||
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||
@@ -103,10 +170,13 @@ KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): El
|
||||
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||
KeyCombo (type): KeyCombo
|
||||
KeybindingAction (type): KeybindingAction
|
||||
KeybindingActionDef (type): KeybindingActionDef
|
||||
KeybindingActionType (type): string
|
||||
LANGUAGES (value): readonly { code: string; label: string; }[]
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LinkedAccountInfo (type): LinkedAccountInfo
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
@@ -114,18 +184,29 @@ ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dr
|
||||
ListRowTone (type): ListRowTone
|
||||
LogContext (type): LogContext
|
||||
LogEntry (type): LogEntry
|
||||
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
|
||||
MANAGE_REQUEST_EVENT (value): "manage:request"
|
||||
MAX_PANES (value): 8
|
||||
MFAPromptMode (type): MFAPromptMode
|
||||
ManageMode (type): ManageMode
|
||||
ManageRequest (type): ManageRequest
|
||||
ManagerCardError (type): ManagerCardError
|
||||
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
||||
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
||||
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
|
||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
|
||||
NativeViewProps (type): NativeViewProps
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||
OpenTabRecord (type): OpenTabRecord
|
||||
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||
PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
|
||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PaletteItemDef (type): PaletteItemDef
|
||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||
PanelDensity (type): PanelDensity
|
||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
@@ -134,18 +215,34 @@ PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, t
|
||||
PanelViewMode (type): PanelViewMode
|
||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
||||
PermissionCatalogEntry (type): PermissionCatalogEntry
|
||||
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
||||
PluginIcon (value): ({ name, ...props }: { name?: string; } & LucideProps) => Element
|
||||
PluginSettingsErrors (type): PluginSettingsErrors
|
||||
PluginSettingsField (type): PluginSettingsField
|
||||
PluginSettingsValidationError (value): typeof PluginSettingsValidationError
|
||||
PluginSummary (type): PluginSummary
|
||||
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
|
||||
Popover (value): ({ ...props }: PopoverProps) => Element
|
||||
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ProxyNode (type): ProxyNode
|
||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||
RadialGaugeProps (type): RadialGaugeProps
|
||||
RailGroup (type): RailGroup
|
||||
RailItemDef (type): RailItemDef
|
||||
RecentActivityItem (type): RecentActivityItem
|
||||
ReleaseInfo (type): ReleaseInfo
|
||||
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
||||
ResolvedHostDefault (type): ResolvedHostDefault
|
||||
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
||||
Role (type): Role
|
||||
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
||||
SSHHost (type): Host
|
||||
SSHHostData (type): HostData
|
||||
SSHHostWithStatus (type): SSHHostWithStatus
|
||||
SSH_AUTH_PROTOCOL (value): "ssh"
|
||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
||||
@@ -165,6 +262,9 @@ SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerPr
|
||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
|
||||
SettingsFieldRow (value): ({ pluginId, field, values, setValue, running, error, disabled, defaultKey, }: SettingsFieldProps) => Element
|
||||
SharePermissionLevel (type): SharePermissionLevel
|
||||
ShareTarget (type): ShareTarget
|
||||
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
||||
@@ -173,71 +273,360 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
|
||||
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ShellConfirmCapture (value): () => null
|
||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
SortKey (type): SortKey
|
||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||
SparklineProps (type): SparklineProps
|
||||
SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { activeIsSplit: boolean; canAddPane: boolean; onSplit: (edge: "right" | "bottom") => void; onPreset: (id: SplitPresetId) => void; }) => Element
|
||||
SplitOpenTarget (type): SplitOpenTarget
|
||||
SplitSummary (type): SplitSummary
|
||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||
StatusValue (type): StatusValue
|
||||
SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
TabSplitAction (type): TabSplitAction
|
||||
TabStage (value): ({ className }: { className?: string; }) => Element
|
||||
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
|
||||
TabStripItem (type): TabStripItem
|
||||
TabType (type): TabType
|
||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
||||
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
ThemeId (type): string
|
||||
TlsStatus (type): TlsStatus
|
||||
ToolsTab (type): ToolsTab
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
||||
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
UI_AREA_KEYS (value): UiAreaKey[]
|
||||
UI_FONTS (value): { id: UiFontId; label: string; family: string; }[]
|
||||
UiAreaKey (type): UiAreaKey
|
||||
UiFontId (type): UiFontId
|
||||
UiPreset (type): UiPreset
|
||||
UpdateChannelState (type): UpdateChannelState
|
||||
UptimeInfo (type): UptimeInfo
|
||||
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
||||
UserPreferences (type): UserPreferences
|
||||
UserRole (type): UserRole
|
||||
UserSecondFactorSummary (type): UserSecondFactorSummary
|
||||
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
|
||||
VersionInfo (type): VersionInfo
|
||||
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
|
||||
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||
actionOrder (value): (action: HostActionDef) => number
|
||||
activityTarget (value): (type: string) => ActivityTarget
|
||||
adminCreateUser (value): (username: string, password: string) => Promise<Record<string, unknown>>
|
||||
adminCreateUserCredential (value): (targetUserId: string, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminCreateUserHost (value): (targetUserId: string, hostData: HostData) => Promise<Host>
|
||||
adminDeleteUserCredential (value): (targetUserId: string, credentialId: number) => Promise<Record<string, unknown>>
|
||||
adminDeleteUserHost (value): (targetUserId: string, hostId: number) => Promise<Record<string, unknown>>
|
||||
adminExportUserData (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
adminGetHostPassword (value): (targetUserId: string, hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||
adminGetUserCredentials (value): (targetUserId: string) => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||
adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
|
||||
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
|
||||
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
|
||||
applyAccentColor (value): (colorValue: string) => void
|
||||
applyFontSize (value): (id: FontSizeId) => void
|
||||
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
|
||||
applyUiFont (value): (id: UiFontId) => void
|
||||
assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void
|
||||
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
||||
bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record<string, unknown>) => Promise<{ updated: number; failed: number; errors: string[]; }>
|
||||
canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState
|
||||
canJoinSplit (value): (tab: Tab) => boolean
|
||||
changeAppLanguage (value): (language: string) => Promise<string>
|
||||
changePassword (value): (oldPassword: string, newPassword: string) => Promise<any>
|
||||
clearLocalAdaptivePreferences (value): () => void
|
||||
cn (value): (...inputs: ClassValue[]) => string
|
||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
||||
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||
createRole (value): (roleData: { name: string; displayName: string; description?: string; }) => Promise<{ role: Role; }>
|
||||
createSSHHost (value): (hostData: HostData) => Promise<Host>
|
||||
defaultConnectAction (value): (all: HostActionDef[], host: Host) => HostActionDef
|
||||
defaultCredentialRowFields (value): () => CredentialRowFields
|
||||
defaultCredentialSidebarPreferences (value): () => CredentialSidebarPreferences
|
||||
defaultHostRowFields (value): () => HostRowFields
|
||||
defaultHostSidebarPreferences (value): () => HostSidebarPreferences
|
||||
defaultValuesEqual (value): (a: unknown, b: unknown) => boolean
|
||||
deleteAccount (value): (password: string) => Promise<Record<string, unknown>>
|
||||
deleteAllHostsInFolder (value): (folderName: string) => Promise<{ deletedCount: number; }>
|
||||
deleteApiKey (value): (keyId: string) => Promise<{ success: boolean; }>
|
||||
deleteCredential (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||
deleteOpenTab (value): (instanceId: string) => Promise<void>
|
||||
deleteRole (value): (roleId: number) => Promise<{ success: boolean; }>
|
||||
deleteSSHHost (value): (hostId: number) => Promise<Record<string, unknown>>
|
||||
deleteUser (value): (username: string, successorUserId?: string) => Promise<Record<string, unknown>>
|
||||
desktopUpdates (value): () => DesktopUpdatesBridge
|
||||
docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string
|
||||
duplicateCredential (value): (credentialId: number, data: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
enabledHostProtocols (value): (host: { pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocolDef[]
|
||||
endSplitDrag (value): (commit: boolean) => void
|
||||
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
|
||||
findCoreHostDefault (value): (key: string) => CoreHostDefault
|
||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
|
||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
|
||||
getAuditLogActions (value): () => Promise<{ actions: string[]; }>
|
||||
getAuditLogs (value): (filters?: AuditLogFilters) => Promise<AuditLogResponse>
|
||||
getBasePath (value): () => string
|
||||
getBranding (value): () => Promise<BrandingSettings>
|
||||
getChannelReleases (value): () => Promise<{ localVersion: string; stable: ReleaseInfo; beta: ReleaseInfo; }>
|
||||
getConnectedRemoteApi (value): () => Promise<AxiosInstance>
|
||||
getCredentialAccess (value): (credentialId: number) => Promise<{ access: AccessRecord[]; }>
|
||||
getCredentialDetails (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||
getCredentialSidebarPreferences (value): () => Promise<CredentialSidebarPreferences>
|
||||
getCredentials (value): () => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
|
||||
getDatabaseTransferUrl (value): (operation: "export" | "import", { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions) => string
|
||||
getDefaultConnectionTab (value): (host: Host) => TabType
|
||||
getDeveloperMode (value): () => Promise<DeveloperModeState>
|
||||
getDeviceId (value): () => string
|
||||
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
||||
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
||||
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||
getHostPolicy (value): () => Promise<HostPolicy>
|
||||
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
||||
getHostTags (value): () => Promise<string[]>
|
||||
getHttpsSettings (value): () => Promise<HttpsSettings>
|
||||
getLiveHostStatus (value): (hostId: number) => StatusValue
|
||||
getLogLevel (value): () => Promise<{ level: string; }>
|
||||
getNotificationPrivateEndpoints (value): () => Promise<string[]>
|
||||
getPanel (value): (id: string) => PanelDef
|
||||
getPasswordLoginAllowed (value): () => Promise<{ allowed: boolean; forced?: boolean; }>
|
||||
getPasswordResetAllowed (value): () => Promise<boolean>
|
||||
getPermissionsCatalog (value): () => Promise<{ catalog: PermissionCatalogEntry[]; }>
|
||||
getPluginAdminSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||
getPluginHostSettings (value): (pluginId: string, hostId: number) => Promise<PluginSettingsValues>
|
||||
getPluginUserSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||
getPlugins (value): () => Promise<PluginSummary[]>
|
||||
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
|
||||
getPreferredHostAction (value): (hostId: string, candidates: readonly TabType[], fallback: TabType) => TabType
|
||||
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
|
||||
getRegisteredDashboardCard (value): (id: string) => RegisteredDashboardCard
|
||||
getRegistrationAllowed (value): () => Promise<{ allowed: boolean; }>
|
||||
getRoles (value): () => Promise<{ roles: Role[]; }>
|
||||
getSSHFolders (value): () => Promise<SSHFolder[]>
|
||||
getSSHHosts (value): () => Promise<SSHHostWithStatus[]>
|
||||
getSecondFactorAfterExternalLogin (value): () => Promise<{ enabled: boolean; }>
|
||||
getSessionTimeout (value): () => Promise<{ timeoutHours: number; }>
|
||||
getSessionTimeoutMinutes (value): () => Promise<number>
|
||||
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
|
||||
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
|
||||
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
|
||||
getTabType (value): (id: string) => TabTypeDef
|
||||
getTlsStatus (value): () => Promise<TlsStatus>
|
||||
getUpdateChannel (value): () => Promise<UpdateChannelState>
|
||||
getUptime (value): () => Promise<UptimeInfo>
|
||||
getUserInfo (value): () => Promise<UserInfo>
|
||||
getUserList (value): (options?: UserListOptions) => Promise<{ users: UserInfo[]; total?: number; }>
|
||||
getUserPreferences (value): () => Promise<UserPreferences>
|
||||
getUserRoles (value): (userId: string) => Promise<{ roles: UserRole[]; }>
|
||||
getUserSecondFactors (value): (userId: string) => Promise<UserSecondFactorSummary[]>
|
||||
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
|
||||
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
|
||||
groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: RailItemDef[]; }[]
|
||||
hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean
|
||||
hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[]
|
||||
hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
||||
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||
invalidateHostsAndStatusCaches (value): () => void
|
||||
invalidateServerStatusCache (value): () => void
|
||||
isCoreRailView (value): (id: string) => boolean
|
||||
isElectron (value): () => boolean
|
||||
isFieldShown (value): (field: PluginSettingsField, values: Record<string, unknown>) => boolean
|
||||
isMacPlatform (value): () => boolean
|
||||
isPersistentTabType (value): (type: string) => boolean
|
||||
isPrerelease (value): (version: string) => boolean
|
||||
isSessionTabType (value): (type: string) => boolean
|
||||
isSplitDragging (value): () => boolean
|
||||
isSplitTab (value): (tab: Tab) => tab is SplitTab
|
||||
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
|
||||
linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: string) => Promise<{ success: boolean; message: string; }>
|
||||
linkedServerUrl (value): () => Promise<string>
|
||||
listHostActions (value): () => HostActionDef[]
|
||||
listHostProtocols (value): () => HostProtocolDef[]
|
||||
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||
makeHostTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||
makeUserAdmin (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
mapCredentials (value): (res: unknown) => Credential[]
|
||||
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
|
||||
markTabSurfaceUsed (value): (type: TabType) => void
|
||||
moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[]
|
||||
moveSplitDrag (value): (x: number, y: number) => void
|
||||
normalizeLanguageCode (value): (language?: string) => string
|
||||
notifySyncChanged (value): () => void
|
||||
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||
parseServerTime (value): (ts: string) => Date
|
||||
pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; }
|
||||
pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string
|
||||
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
|
||||
preloadTabSurface (value): (type: TabType) => void
|
||||
previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }>
|
||||
promotableIds (value): () => string[]
|
||||
protocolEnabled (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => boolean
|
||||
protocolPort (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => number
|
||||
quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef[]
|
||||
railItemLabel (value): (id: string, t: (key: string) => string) => string
|
||||
readFromClipboard (value): () => Promise<string>
|
||||
recordHostActionPreference (value): (hostId: string, action: TabType) => void
|
||||
registerLayout (value): (item: RegisteredLayout) => () => void
|
||||
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
|
||||
releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
rem (value): (px: number) => string
|
||||
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reorderFolders (value): (positions: { name: string; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reorderSSHHosts (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reportBetaFeedbackUrl (value): (termixVersion?: string, betaPlugins?: { id: string; version: string; }[]) => string
|
||||
reportCoreIssueUrl (value): (termixVersion?: string) => string
|
||||
requestFromLegacyEvent (value): (name: string, detail?: unknown) => ManageRequest
|
||||
requestManage (value): (request: ManageRequest) => void
|
||||
resetHostToDefaults (value): (hostIds: number[], reset: { all?: boolean; namespaces?: string[]; keys?: string[]; }) => Promise<void>
|
||||
resetRecentActivity (value): () => Promise<{ message: string; }>
|
||||
resetUserSecondFactors (value): (userId: string) => Promise<void>
|
||||
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
|
||||
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
|
||||
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
||||
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||
rightDockableIds (value): () => string[]
|
||||
roleLabel (value): <T extends string | null | undefined>(t: Translator, displayName: T) => string | T
|
||||
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
||||
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
||||
runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallbacks) => void
|
||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
|
||||
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
|
||||
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
|
||||
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
|
||||
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
|
||||
saveHostTags (value): (tags: string[]) => Promise<string[]>
|
||||
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
|
||||
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
|
||||
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
|
||||
shell (value): TabShellCallbacks
|
||||
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||
tabIcon (value): (type: TabType) => Element
|
||||
tabJumpHotkeyKeys (value): () => string[]
|
||||
takePendingManageRequest (value): () => ManageRequest
|
||||
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
|
||||
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
updateHttpsSettings (value): (forceHttps: boolean) => Promise<HttpsSettings>
|
||||
updateLogLevel (value): (level: string) => Promise<void>
|
||||
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||
updatePluginAdminSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updatePluginHostSettings (value): (pluginId: string, hostId: number, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updatePluginUserSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updateRegistrationAllowed (value): (allowed: boolean) => Promise<Record<string, unknown>>
|
||||
updateRole (value): (roleId: number, roleData: { displayName?: string; description?: string; permissions?: string[]; }) => Promise<{ role: Role; }>
|
||||
updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
|
||||
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
|
||||
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
|
||||
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
|
||||
usageBarColor (value): (percent: number) => string
|
||||
usageColor (value): (percent: number) => string
|
||||
useActionSlot (value): (slotId: string, context?: Record<string, unknown>) => SlotContribution[]
|
||||
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
||||
useAppTheme (value): () => ThemeProviderState
|
||||
useAreaPreferences (value): <A extends UiAreaKey>(area: A) => UiAreaPreferences[A]
|
||||
useBranding (value): () => BrandingContextValue
|
||||
useChooseTheme (value): () => (themeId: string) => void
|
||||
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
||||
useConnectionGate (value): () => ConnectionGateController
|
||||
useConnectionLog (value): () => ConnectionLogContextType
|
||||
useDesktopLinked (value): () => boolean
|
||||
useDesktopUpdate (value): () => DesktopUpdateState
|
||||
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
|
||||
useHostActions (value): () => HostActionDef[]
|
||||
useHostBadges (value): () => HostBadgeDef[]
|
||||
useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
||||
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
||||
useHostProtocols (value): () => HostProtocolDef[]
|
||||
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||
useHostStatusIndicators (value): () => HostBadgeDef[]
|
||||
useInstalledPlugins (value): () => PluginSummary[]
|
||||
useIsDefaultsEditor (value): () => boolean
|
||||
useIsMobile (value): () => boolean
|
||||
useKeybindingActions (value): () => KeybindingActionDef[]
|
||||
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
|
||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
|
||||
usePaletteEntries (value): () => PaletteEntryDef[]
|
||||
usePaletteGroups (value): () => PaletteGroupDef[]
|
||||
usePanels (value): () => PanelDef[]
|
||||
usePermissions (value): () => PermissionsState
|
||||
usePluginDocsUrl (value): (pluginId: string, page?: string, anchor?: string) => string
|
||||
usePluginHostSections (value): () => PluginSummary[]
|
||||
usePluginStore (value): () => StoreState
|
||||
useRailItems (value): () => RailItemDef[]
|
||||
useRegisteredDashboardCards (value): () => RegisteredDashboardCard[]
|
||||
useServerStatus (value): () => ServerStatusContextType
|
||||
useServerStatusMeta (value): () => { initialLoadComplete: boolean; }
|
||||
useShellController (value): () => ShellController
|
||||
useSplitTargets (value): () => SplitTargetsSnapshot
|
||||
useSshAuthEditors (value): () => SshAuthEditorDef[]
|
||||
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
|
||||
useStatusColorScheme (value): () => StatusColorScheme
|
||||
useSurfaceKind (value): () => SurfaceKind
|
||||
useTabHandlesVersion (value): () => number
|
||||
useTabs (value): () => TabContextType
|
||||
useTabsSafe (value): () => TabContextType
|
||||
useTheme (value): () => ThemeProviderState
|
||||
useThemeOptions (value): () => ThemeOption[]
|
||||
useUiPreference (value): <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(area: A, key: K) => UiAreaPreferences[A][K]
|
||||
useUiPreferencesContext (value): () => UiPreferencesContextValue
|
||||
waitForDefaultsJob (value): (id: string, intervalMs?: number, maxWaitMs?: number) => Promise<number>
|
||||
withProtocolSettings (value): (pluginSettings: Record<string, Record<string, unknown>>, flags: HostProtocols, list?: HostProtocolDef[]) => Record<string, Record<string, unknown>>
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"only-arches": ["x86_64", "aarch64"],
|
||||
"skip-icons-check": false,
|
||||
"skip-appstream-check": false
|
||||
}
|
||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 21 KiB |