mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc65d87cd | ||
|
|
e166994ec4 | ||
|
|
3a851ad189 | ||
|
|
d2851982a6 | ||
|
|
cc5dce18e2 | ||
|
|
6084ed4d3c | ||
|
|
c3f6db69e9 | ||
|
|
8e2221cb8f | ||
|
|
7324982f47 | ||
|
|
f20d64b4f2 | ||
|
|
aa0b32a142 | ||
|
|
c8a907538e | ||
|
|
dd5573cc10 | ||
|
|
071711dafc | ||
|
|
bc74fba3f5 | ||
|
|
694dc34b19 | ||
|
|
b3b08b4ff2 | ||
|
|
9b0af07607 | ||
|
|
c044dbde87 | ||
|
|
1396b3a70c | ||
|
|
4c9ee5923e | ||
|
|
25e58a6425 | ||
|
|
7d13b2a9fe | ||
|
|
e610aba0e5 | ||
|
|
3941dd4ecc | ||
|
|
872e5eba73 | ||
|
|
8c3f816407 | ||
|
|
13f7aa95a3 | ||
|
|
8e5a50623c | ||
|
|
d791e52d82 | ||
|
|
fb04edc497 |
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
@@ -143,12 +143,12 @@ jobs:
|
||||
- name: Build Windows (All Architectures)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
|
||||
run: npm run build && npx electron-builder --win --x64 --ia32 --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.
|
||||
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
|
||||
# The combined x64+ia32 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
|
||||
run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
|
||||
|
||||
- name: Upload Windows x64 NSIS Installer
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -166,14 +166,6 @@ 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'
|
||||
@@ -190,14 +182,6 @@ 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: |
|
||||
@@ -208,11 +192,6 @@ 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'
|
||||
@@ -240,14 +219,6 @@ 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'
|
||||
|
||||
@@ -6,16 +6,10 @@
|
||||
|
||||
- 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
|
||||
|
||||
@@ -40,13 +34,6 @@
|
||||
- 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
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<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 />
|
||||
@@ -137,7 +135,7 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
|
||||
See the [docs](https://docs.termix.site/install) for install steps on every platform.
|
||||
|
||||
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
|
||||
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -151,10 +149,31 @@ 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
|
||||
|
||||
+1
-1
@@ -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 && \
|
||||
TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||
NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||
|
||||
# Stage 3: Build backend
|
||||
FROM deps AS backend-builder
|
||||
|
||||
+68
-68
@@ -3,20 +3,20 @@
|
||||
{
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
|
||||
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
|
||||
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
|
||||
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
|
||||
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
|
||||
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
|
||||
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -25,14 +25,14 @@
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
||||
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
|
||||
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
|
||||
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
|
||||
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -41,8 +41,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
|
||||
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
|
||||
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -51,20 +51,20 @@
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
|
||||
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
|
||||
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
|
||||
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
|
||||
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
|
||||
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -73,32 +73,32 @@
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
|
||||
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
|
||||
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
|
||||
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
|
||||
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
|
||||
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
|
||||
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
|
||||
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -107,14 +107,14 @@
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
|
||||
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
||||
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
|
||||
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
@@ -123,20 +123,20 @@
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
|
||||
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
|
||||
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
|
||||
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
|
||||
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
|
||||
},
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
|
||||
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
|
||||
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -145,8 +145,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
|
||||
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
|
||||
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -155,8 +155,8 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
|
||||
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
|
||||
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
@@ -166,20 +166,20 @@
|
||||
{
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
|
||||
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
|
||||
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
|
||||
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
||||
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
|
||||
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -188,20 +188,20 @@
|
||||
{
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
|
||||
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
|
||||
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
|
||||
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
||||
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
|
||||
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -210,8 +210,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
|
||||
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
|
||||
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -220,32 +220,32 @@
|
||||
{
|
||||
"id": "vault",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
|
||||
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
|
||||
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
|
||||
},
|
||||
{
|
||||
"id": "wake-on-lan",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
|
||||
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
|
||||
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
|
||||
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
|
||||
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
|
||||
},
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
|
||||
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
|
||||
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
|
||||
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
|
||||
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -254,8 +254,8 @@
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
|
||||
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
|
||||
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
|
||||
@@ -12,9 +12,24 @@ 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,6 +9,12 @@ 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.
|
||||
@@ -30,6 +36,17 @@ services:
|
||||
# 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
|
||||
|
||||
|
||||
@@ -46,11 +46,11 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "ia32", "arm64"]
|
||||
"arch": ["x64", "ia32"]
|
||||
},
|
||||
{
|
||||
"target": "msi",
|
||||
"arch": ["x64", "ia32", "arm64"]
|
||||
"arch": ["x64", "ia32"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.ico",
|
||||
|
||||
@@ -6,16 +6,9 @@
|
||||
|
||||
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 {
|
||||
const parsed = new URL(url);
|
||||
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
|
||||
if (httpProtocol) parsed.protocol = httpProtocol;
|
||||
return parsed.origin;
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -330,11 +330,7 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
||||
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
|
||||
// which keeps every transfer on the backend's plugin routes.
|
||||
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
|
||||
let localBackendPort = 30001;
|
||||
|
||||
function setLocalBackendPort(port) {
|
||||
localBackendPort = port;
|
||||
}
|
||||
const LOCAL_BACKEND = "http://localhost:30001";
|
||||
let registeredApiPath = null;
|
||||
|
||||
function setTransferApiPath(apiPath) {
|
||||
@@ -407,8 +403,7 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
|
||||
}
|
||||
return {
|
||||
url: `${normalizeHttpBase(
|
||||
localBaseUrl ??
|
||||
`http://localhost:${localBackendPort}${resolveApiPath()}`,
|
||||
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
|
||||
"Local backend URL",
|
||||
)}${routePath}`,
|
||||
headers,
|
||||
@@ -1055,7 +1050,6 @@ module.exports = {
|
||||
createLocalFileHandlers,
|
||||
createTargetResolver,
|
||||
setTransferApiPath,
|
||||
setLocalBackendPort,
|
||||
publishDownload,
|
||||
defaultPublishFs,
|
||||
assertWithinRoot,
|
||||
|
||||
+11
-39
@@ -35,10 +35,7 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
||||
const { quitApp } = require("./app-quit.cjs");
|
||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
|
||||
const {
|
||||
registerLocalFileHandlers,
|
||||
setLocalBackendPort,
|
||||
} = require("./local-files.cjs");
|
||||
const { registerLocalFileHandlers } = require("./local-files.cjs");
|
||||
const {
|
||||
EXTERNAL_EDITOR_SETTING,
|
||||
isLaunchableFileName,
|
||||
@@ -442,6 +439,14 @@ function isInsecureModeEnabled() {
|
||||
);
|
||||
}
|
||||
|
||||
function getOrigin(url) {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateNetworkHost(hostname) {
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
@@ -485,7 +490,7 @@ function isInvalidCertificateAllowedForUrl(url) {
|
||||
// allows a self-signed certificate.
|
||||
const linked = linkedServer.getLinkedServer();
|
||||
if (!linked?.allowInvalidCertificate) return false;
|
||||
return linkedServer.isLinkedOrigin(url);
|
||||
return getOrigin(url) === linked.origin;
|
||||
}
|
||||
|
||||
function getTlsVerificationOptions(url) {
|
||||
@@ -1119,35 +1124,6 @@ function reapOrphanedBackendProcess() {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_BACKEND_PORT = 30001;
|
||||
let backendPort = DEFAULT_BACKEND_PORT;
|
||||
|
||||
function canListenOn(port) {
|
||||
return new Promise((resolve) => {
|
||||
const probe = net.createServer();
|
||||
probe.once("error", () => resolve(null));
|
||||
probe.listen(port, "127.0.0.1", () => {
|
||||
const { port: bound } = probe.address();
|
||||
probe.close(() => resolve(bound));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 30001 when it is free, otherwise any free port. Only the desktop app talks
|
||||
// to this backend, so the number itself does not matter.
|
||||
async function findBackendPort() {
|
||||
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
|
||||
if (preferred) return preferred;
|
||||
const fallback = await canListenOn(0);
|
||||
if (fallback) {
|
||||
logToFile(
|
||||
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return DEFAULT_BACKEND_PORT;
|
||||
}
|
||||
|
||||
function startBackendServer() {
|
||||
reapOrphanedBackendProcess();
|
||||
backendFailure = null;
|
||||
@@ -1188,8 +1164,7 @@ function startBackendServer() {
|
||||
DATA_DIR: dataDir,
|
||||
NODE_ENV: "production",
|
||||
ELECTRON_EMBEDDED: "true",
|
||||
PORT: String(backendPort),
|
||||
EMBEDDED_BACKEND_PORT: String(backendPort),
|
||||
PORT: "30001",
|
||||
VERSION: app.getVersion(),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
@@ -1454,7 +1429,6 @@ function createWindow() {
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
additionalArguments: [`--termix-backend-port=${backendPort}`],
|
||||
partition: termixSessionPartition,
|
||||
allowRunningInsecureContent: false,
|
||||
webviewTag: false,
|
||||
@@ -3840,8 +3814,6 @@ app.whenReady().then(async () => {
|
||||
await clearElectronJwtCookiesAtStartup();
|
||||
|
||||
if (!isDev) {
|
||||
backendPort = await findBackendPort();
|
||||
setLocalBackendPort(backendPort);
|
||||
const result = await startBackendServer();
|
||||
logToFile("startBackendServer result:", result);
|
||||
} else {
|
||||
|
||||
@@ -14,16 +14,7 @@ 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) => {
|
||||
|
||||
+10
-6
@@ -71,6 +71,7 @@
|
||||
"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",
|
||||
@@ -107,6 +108,7 @@
|
||||
"internal": true,
|
||||
"description": "Set by the image when nginx serves HTTPS in front of the backend."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "DATABASE_DIALECT",
|
||||
"group": "database",
|
||||
@@ -159,6 +161,7 @@
|
||||
"internal": true,
|
||||
"description": "Where database migrations are read from. Set by the image."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "JWT_SECRET",
|
||||
"group": "secrets",
|
||||
@@ -201,6 +204,7 @@
|
||||
"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",
|
||||
@@ -279,6 +283,7 @@
|
||||
"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",
|
||||
@@ -310,6 +315,7 @@
|
||||
"group": "proxy",
|
||||
"description": "Comma separated hosts that skip the proxy."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "LOG_LEVEL",
|
||||
"group": "logging",
|
||||
@@ -349,6 +355,7 @@
|
||||
"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",
|
||||
@@ -399,6 +406,7 @@
|
||||
"internal": true,
|
||||
"description": "Set by npm run dev."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ENABLE_INSECURE_MODE",
|
||||
"group": "desktop",
|
||||
@@ -427,12 +435,6 @@
|
||||
"internal": true,
|
||||
"description": "Set by the desktop app when it runs its own backend."
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@@ -457,12 +459,14 @@
|
||||
"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",
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.1",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -15058,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.1",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -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 && 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": "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: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",
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.11 | Plugin API 1.3.0
|
||||
SDK 1.0.8 | Plugin API 1.2.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.
|
||||
@@ -90,7 +90,7 @@ 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 pack # termix-plugin pack: write <id>-<version>.tmxplug
|
||||
npm run format # Prettier
|
||||
npm run format:check
|
||||
```
|
||||
@@ -105,7 +105,7 @@ Before you call a change finished:
|
||||
- [ ] `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 strings are in `locales/en.json`
|
||||
- [ ] 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
|
||||
@@ -225,8 +225,6 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -239,13 +237,6 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
|
||||
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
|
||||
@@ -343,27 +334,8 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
|
||||
### 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.
|
||||
`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every
|
||||
Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled.
|
||||
|
||||
## Database
|
||||
|
||||
@@ -493,7 +465,7 @@ 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
|
||||
`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
|
||||
https://docs.termix.site/develop/more-extension-points.
|
||||
|
||||
## Looking native
|
||||
@@ -547,20 +519,6 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
|
||||
`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
|
||||
@@ -725,20 +683,20 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
|
||||
## 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` |
|
||||
| Command | What it does |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/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
|
||||
|
||||
@@ -763,37 +721,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
|
||||
## 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 |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `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 |
|
||||
| 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 | Run background work as another user, 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 |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `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 and roles |
|
||||
| `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 |
|
||||
|
||||
An update that adds a capability waits for an admin to approve it.
|
||||
|
||||
|
||||
@@ -52,43 +52,6 @@ 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)),
|
||||
"..",
|
||||
@@ -253,27 +216,13 @@ export async function build({ cwd }) {
|
||||
// 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 },
|
||||
banner: {
|
||||
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
|
||||
},
|
||||
});
|
||||
|
||||
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.
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -11,9 +11,6 @@ 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.11",
|
||||
"version": "1.0.8",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1464,20 +1464,6 @@ export interface PluginSecondFactor {
|
||||
reset?: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginUserTokenOptions {
|
||||
/** Why the token exists, kept in the audit log and on the session. */
|
||||
purpose: string;
|
||||
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface PluginUserToken {
|
||||
/** Send as `Authorization: Bearer <token>` to core's own API. */
|
||||
token: string;
|
||||
/** ISO time the session ends. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login methods, second factors and SSH auth types. Every register call needs
|
||||
* auth:provide and a matching contributes.auth entry, and is undone on
|
||||
@@ -1539,26 +1525,6 @@ export interface PluginAuth {
|
||||
* delete a provider people still sign in with.
|
||||
*/
|
||||
countLinkedUsers: (provider: string) => Promise<number>;
|
||||
/**
|
||||
* A short-lived session for an existing user, for calling core's own HTTP
|
||||
* API as them from work with no request behind it, like a scheduled task
|
||||
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
|
||||
* users:impersonate, not auth:provide, and every call is audited.
|
||||
*
|
||||
* The session is marked with the plugin and purpose. It cannot change the
|
||||
* user's password, second factors, API keys or linked logins, so it can
|
||||
* never outlive itself.
|
||||
*/
|
||||
issueUserToken: (
|
||||
userId: string,
|
||||
options: PluginUserTokenOptions,
|
||||
) => Promise<PluginUserToken>;
|
||||
}
|
||||
|
||||
/** What a plugin may learn about a user. Needs users:read. */
|
||||
export interface PluginUsers {
|
||||
/** The user's UI language, like "zh_CN", or null when they never chose one. */
|
||||
getLocale: (userId: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface PluginOpenIsolatedWindowRequest {
|
||||
@@ -2047,13 +2013,8 @@ export interface PluginContext {
|
||||
readonly hosts: PluginHosts;
|
||||
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
||||
readonly ssh: PluginSsh;
|
||||
/**
|
||||
* Login methods, second factors and SSH auth types. Needs auth:provide,
|
||||
* except issueUserToken, which needs users:impersonate.
|
||||
*/
|
||||
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
||||
readonly auth: PluginAuth;
|
||||
/** What a plugin may learn about a user. Needs users:read. */
|
||||
readonly users: PluginUsers;
|
||||
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
|
||||
readonly desktop: PluginDesktop;
|
||||
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
|
||||
@@ -2078,9 +2039,8 @@ export interface PluginContext {
|
||||
* no request behind it. Always audited, and every core API inside still
|
||||
* applies that user's RBAC.
|
||||
*
|
||||
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
|
||||
* on services.get or secrets.getShared are the only ways a plugin can act
|
||||
* as a user it names.
|
||||
* Needs users:impersonate. That and a `userId` option on services.get or
|
||||
* secrets.getShared are the only ways a plugin can name a user.
|
||||
*/
|
||||
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
|
||||
|
||||
|
||||
@@ -36,7 +36,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.3.0";
|
||||
export const PLUGIN_API_VERSION = "1.2.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
|
||||
@@ -174,8 +174,6 @@ export interface FakeContextOptions {
|
||||
linkedUsers?: Record<string, number>;
|
||||
/** 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 {
|
||||
@@ -249,8 +247,6 @@ 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 }>;
|
||||
}
|
||||
|
||||
export interface FakePluginContext {
|
||||
@@ -578,7 +574,6 @@ export function createFakeContext(
|
||||
completedLogins: [],
|
||||
revokedSessions: [],
|
||||
loginFailures: new Map(),
|
||||
userTokens: [],
|
||||
};
|
||||
const sshClient = options.sshClient ?? {};
|
||||
let actor = options.actor;
|
||||
@@ -1145,18 +1140,6 @@ 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(),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
users: {
|
||||
getLocale: async (userId) => options.locales?.[userId] ?? null,
|
||||
},
|
||||
|
||||
desktop: {
|
||||
@@ -1459,8 +1442,6 @@ export interface MockContextOptions {
|
||||
linkedUsers?: Record<string, number>;
|
||||
/** See FakeContextOptions. */
|
||||
loginAttemptLimit?: number;
|
||||
/** See FakeContextOptions. */
|
||||
locales?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -1477,8 +1458,7 @@ 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, auth:provide on ctx.auth
|
||||
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
|
||||
* 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
|
||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
export function createMockCtx(
|
||||
@@ -1515,7 +1495,6 @@ export function createMockCtx(
|
||||
baseUrl: options.baseUrl,
|
||||
linkedUsers: options.linkedUsers,
|
||||
loginAttemptLimit: options.loginAttemptLimit,
|
||||
locales: options.locales,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -1807,17 +1786,6 @@ export function createMockCtx(
|
||||
require("auth:provide");
|
||||
return ctx.auth.countLinkedUsers(provider);
|
||||
},
|
||||
issueUserToken: async (userId, tokenOptions) => {
|
||||
require("users:impersonate");
|
||||
return ctx.auth.issueUserToken(userId, tokenOptions);
|
||||
},
|
||||
},
|
||||
|
||||
users: {
|
||||
getLocale: async (userId) => {
|
||||
require("users:read");
|
||||
return ctx.users.getLocale(userId);
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
|
||||
@@ -30,25 +30,6 @@ 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>;
|
||||
@@ -115,10 +96,7 @@ export function pluginVitestConfig(
|
||||
// unexpectedly". Threads don't.
|
||||
const pool = "threads" as const;
|
||||
|
||||
const plugins = [wasmBinaryPlugin()];
|
||||
|
||||
return {
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
test: {
|
||||
globals: true,
|
||||
@@ -126,7 +104,6 @@ export function pluginVitestConfig(
|
||||
pool,
|
||||
projects: [
|
||||
{
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
test: {
|
||||
name: `${pluginId}:backend`,
|
||||
@@ -139,7 +116,6 @@ export function pluginVitestConfig(
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
test: {
|
||||
name: `${pluginId}:frontend`,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
+1
-6
@@ -1,9 +1,4 @@
|
||||
// CACHE_NAME carries the package version so a new release always gets a fresh
|
||||
// cache: the activate handler below deletes every cache whose name differs
|
||||
// from this one, which is what evicts a previous build's entries. Both
|
||||
// placeholders on these lines are stamped by scripts/stamp-sw-build-info.cjs
|
||||
// during `npm run build` - vite copies public/ verbatim and never transforms it.
|
||||
const CACHE_NAME = "termix-static-" + "__TERMIX_CACHE_VERSION__";
|
||||
const CACHE_NAME = "termix-static-v2";
|
||||
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
|
||||
const STATIC_ASSETS = [
|
||||
`${BASE_PATH}/favicon.ico`,
|
||||
|
||||
@@ -42,7 +42,6 @@ describe("serviceEnv", () => {
|
||||
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
|
||||
GUACD_HOST: "127.0.0.1",
|
||||
GUACD_PORT: "4822",
|
||||
REMOTE_DESKTOP_ENGINE: "guacd",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +53,6 @@ describe("serviceEnv", () => {
|
||||
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
|
||||
GUACD_HOST: "termix-dev-guacd",
|
||||
GUACD_PORT: "4822",
|
||||
REMOTE_DESKTOP_ENGINE: "guacd",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-2
@@ -14,8 +14,7 @@
|
||||
* mounted. A plugin change rebuilds it and restarts the container. Core
|
||||
* changes wait for "r", since an image build is slow.
|
||||
*
|
||||
* --db postgres|mysql and --guacd run those in Docker next to it; --guacd also
|
||||
* switches remote desktop from its built-in engine to that guacd.
|
||||
* --db postgres|mysql and --guacd run those in Docker next to it.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
|
||||
@@ -53,7 +53,6 @@ function start() {
|
||||
isDev: false,
|
||||
appRoot: "/app",
|
||||
BACKEND_STDERR_TAIL_LIMIT: 4096,
|
||||
backendPort: 30001,
|
||||
handleBackendRequest,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
|
||||
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
|
||||
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
|
||||
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
|
||||
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
|
||||
`| **AArch64 (ARM64)** | [EXE](${win("termix_windows_arm64_nsis.exe")}) · [MSI](${win("termix_windows_arm64_msi.msi")}) · [Portable](${win("termix_windows_arm64_portable.zip")}) | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
||||
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
||||
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
|
||||
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
|
||||
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
|
||||
|
||||
@@ -85,8 +85,6 @@ export function serviceEnv(options, inDocker) {
|
||||
const guacd = SERVICES.guacd;
|
||||
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
|
||||
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
|
||||
// Remote desktop runs built in unless told to use guacd.
|
||||
env.REMOTE_DESKTOP_ENGINE = "guacd";
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ Options
|
||||
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
|
||||
--no-local Use only the plugins pinned in docker/bundled-plugins.json
|
||||
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
|
||||
--guacd Run guacd in Docker and use it instead of the built-in remote desktop
|
||||
--guacd Run guacd in Docker for remote desktop
|
||||
--fresh Start from empty data (first run, onboarding)
|
||||
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
|
||||
electron, plus -<engine> for postgres or mysql)
|
||||
|
||||
@@ -92,74 +92,6 @@ describe("termix-plugin build", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin build workers and wasm", () => {
|
||||
// The smallest valid module: magic and version, no sections.
|
||||
const EMPTY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]);
|
||||
|
||||
it("inlines a .wasm import as its bytes", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
fs.writeFileSync(path.join(dir, "src", "backend", "mod.wasm"), EMPTY_WASM);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src", "backend", "index.ts"),
|
||||
'import bytes from "./mod.wasm";\nexport const valid = WebAssembly.validate(bytes);\nexport function activate() {}\n',
|
||||
);
|
||||
|
||||
await build({ cwd: dir });
|
||||
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(dir, "dist", "backend.js")).href
|
||||
);
|
||||
expect(mod.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("bundles each worker entry on its own into dist/workers", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ termix: { workers: { engine: "src/worker.ts" } } }),
|
||||
);
|
||||
const dep = path.join(dir, "node_modules", "worker-dep");
|
||||
fs.mkdirSync(dep, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dep, "package.json"),
|
||||
JSON.stringify({ name: "worker-dep", main: "index.js" }),
|
||||
);
|
||||
fs.writeFileSync(path.join(dep, "index.js"), "exports.answer = 42;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src", "worker.ts"),
|
||||
'import { parentPort } from "node:worker_threads";\nimport { answer } from "worker-dep";\nparentPort?.postMessage(answer);\n',
|
||||
);
|
||||
|
||||
await build({ cwd: dir });
|
||||
|
||||
const file = path.join(dir, "dist", "workers", "engine.js");
|
||||
expect(fs.readFileSync(file, "utf8")).not.toContain('from "worker-dep"');
|
||||
const { Worker } = await import("node:worker_threads");
|
||||
const answer = await new Promise((resolve, reject) => {
|
||||
const worker = new Worker(file);
|
||||
worker.once("message", (value) => {
|
||||
void worker.terminate();
|
||||
resolve(value);
|
||||
});
|
||||
worker.once("error", reject);
|
||||
});
|
||||
expect(answer).toBe(42);
|
||||
});
|
||||
|
||||
it("refuses a worker whose entry is missing", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ termix: { workers: { engine: "src/nope.ts" } } }),
|
||||
);
|
||||
|
||||
await expect(build({ cwd: dir })).rejects.toThrow(/entry src\/nope.ts/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin CSS layers", () => {
|
||||
it("renames the Tailwind layers so core's utilities always win", () => {
|
||||
expect(
|
||||
|
||||
@@ -46,8 +46,6 @@ function fixturePlugin(manifest: Record<string, unknown> = {}): string {
|
||||
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
|
||||
writeFile(dir, "README.md", "# Fixture\n");
|
||||
writeFile(dir, "icon.svg", "<svg/>\n");
|
||||
writeFile(dir, "LICENSE", "Apache-2.0\n");
|
||||
writeFile(dir, "NOTICE", "Third-party notices\n");
|
||||
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
|
||||
return dir;
|
||||
}
|
||||
@@ -93,8 +91,6 @@ describe("termix-plugin pack", () => {
|
||||
|
||||
const names = await entries(file);
|
||||
expect(names).toEqual([
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
"README.md",
|
||||
"dist/backend.js",
|
||||
"dist/frontend.js",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/** Stamp the cache version and static-build base path.
|
||||
* Docker keeps the base-path placeholder for entrypoint.sh to fill at startup.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SW_PATH = path.join(ROOT, "dist", "sw.js");
|
||||
|
||||
const VERSION_PLACEHOLDER = "__TERMIX_CACHE_VERSION__";
|
||||
const BASE_PATH_PLACEHOLDER = "__TERMIX_SW_BASE_PATH__";
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, "package.json"), "utf8"),
|
||||
);
|
||||
const cacheVersion = String(pkg.version || "0.0.0").replace(/\./g, "-");
|
||||
|
||||
const rawBasePath = process.env.VITE_BASE_PATH || "./";
|
||||
// "./" is what vite emits for a root deployment; cache URLs stay root-relative.
|
||||
const basePath =
|
||||
rawBasePath === "./"
|
||||
? ""
|
||||
: `/${rawBasePath.replace(/^\/+/, "").replace(/\/+$/, "")}`;
|
||||
|
||||
if (!fs.existsSync(SW_PATH)) {
|
||||
console.error(`[stamp-sw] dist/sw.js not found - run vite build first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let sw = fs.readFileSync(SW_PATH, "utf8");
|
||||
const rel = path.relative(ROOT, SW_PATH);
|
||||
const replacements = [
|
||||
[VERSION_PLACEHOLDER, cacheVersion],
|
||||
...(process.env.TERMIX_RUNTIME_BASE_PATH === "true"
|
||||
? []
|
||||
: [[BASE_PATH_PLACEHOLDER, basePath]]),
|
||||
];
|
||||
|
||||
let changed = false;
|
||||
for (const [placeholder, value] of replacements) {
|
||||
if (sw.includes(placeholder)) {
|
||||
sw = sw.split(placeholder).join(value);
|
||||
changed = true;
|
||||
console.log(
|
||||
`[stamp-sw] ${rel}: ${placeholder} -> ${JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(SW_PATH, sw);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it.each([
|
||||
[false, "", ""],
|
||||
[false, "/termix/", "/termix"],
|
||||
[true, "", "__TERMIX_SW_BASE_PATH__"],
|
||||
])(
|
||||
"stamps service worker paths (runtime=%s, base=%s)",
|
||||
(runtime, base, expected) => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "termix-sw-"));
|
||||
try {
|
||||
mkdirSync(path.join(dir, "scripts"));
|
||||
mkdirSync(path.join(dir, "dist"));
|
||||
writeFileSync(
|
||||
path.join(dir, "scripts/stamp-sw-build-info.cjs"),
|
||||
readFileSync("scripts/stamp-sw-build-info.cjs"),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ version: "26.10.2" }),
|
||||
);
|
||||
writeFileSync(path.join(dir, "dist/sw.js"), readFileSync("public/sw.js"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(dir, "scripts/stamp-sw-build-info.cjs")],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_BASE_PATH: base,
|
||||
TERMIX_RUNTIME_BASE_PATH: String(runtime),
|
||||
},
|
||||
},
|
||||
);
|
||||
const sw = readFileSync(path.join(dir, "dist/sw.js"), "utf8");
|
||||
expect(sw).toContain('"26-10-2"');
|
||||
expect(sw).toContain(`const BASE_PATH = "${expected}";`);
|
||||
if (runtime) {
|
||||
expect(sw.replaceAll("__TERMIX_SW_BASE_PATH__", "/termix")).toContain(
|
||||
'const BASE_PATH = "/termix";',
|
||||
);
|
||||
expect(readFileSync("docker/Dockerfile", "utf8")).toContain(
|
||||
"TERMIX_RUNTIME_BASE_PATH=true",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -38,7 +38,6 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||
import fs from "fs";
|
||||
@@ -1389,9 +1388,6 @@ app.post(
|
||||
}
|
||||
}
|
||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||
for (const hostId of importedHostIds) {
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
@@ -1720,8 +1716,7 @@ app.use(
|
||||
},
|
||||
);
|
||||
|
||||
// The desktop app moves its embedded backend off 30001 when that is taken.
|
||||
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
|
||||
const HTTP_PORT = 30001;
|
||||
|
||||
async function initializeSecurity() {
|
||||
try {
|
||||
|
||||
@@ -140,14 +140,7 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||
* Tells status checks and other pollers about hosts an import wrote, the way
|
||||
* a single host create or update does.
|
||||
*/
|
||||
export function notifyImportedHosts(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
createdHostIds: number[],
|
||||
): void {
|
||||
for (const hostId of createdHostIds) {
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||
}
|
||||
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
|
||||
for (const hostId of hostIds) {
|
||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
||||
}
|
||||
@@ -575,26 +568,26 @@ export function registerHostBulkRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
|
||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
try {
|
||||
const allHosts =
|
||||
await createCurrentHostResolutionRepository().findHostsByUserId(
|
||||
userId,
|
||||
);
|
||||
existingHostMap = new Map();
|
||||
for (const h of allHosts) {
|
||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||
existingHostMap.set(key, { id: h.id as number });
|
||||
if (overwrite) {
|
||||
try {
|
||||
const allHosts =
|
||||
await createCurrentHostResolutionRepository().findHostsByUserId(
|
||||
userId,
|
||||
);
|
||||
existingHostMap = new Map();
|
||||
for (const h of allHosts) {
|
||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||
existingHostMap.set(key, { id: h.id as number });
|
||||
}
|
||||
} catch {
|
||||
existingHostMap = undefined;
|
||||
}
|
||||
} catch {
|
||||
existingHostMap = undefined;
|
||||
}
|
||||
|
||||
const knownAuthTypes = listKnownAuthTypes();
|
||||
const writtenHostIds: number[] = [];
|
||||
const createdHostIds: number[] = [];
|
||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
@@ -631,15 +624,6 @@ export function registerHostBulkRoutes(
|
||||
continue;
|
||||
}
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
if (existing && !overwrite) {
|
||||
// Hosts that jump through this one still resolve to the kept host.
|
||||
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType &&
|
||||
@@ -785,6 +769,8 @@ export function registerHostBulkRoutes(
|
||||
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
||||
}
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
@@ -811,7 +797,6 @@ export function registerHostBulkRoutes(
|
||||
);
|
||||
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
||||
savedHostId = saved.id;
|
||||
createdHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
|
||||
@@ -843,10 +828,10 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds);
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||
success: results.success,
|
||||
updated: results.updated,
|
||||
skipped: results.skipped,
|
||||
@@ -946,22 +931,23 @@ export function registerHostBulkRoutes(
|
||||
|
||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
try {
|
||||
const allHosts =
|
||||
await createCurrentHostResolutionRepository().findHostsByUserId(
|
||||
userId,
|
||||
);
|
||||
existingHostMap = new Map();
|
||||
for (const h of allHosts) {
|
||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||
existingHostMap.set(key, { id: h.id as number });
|
||||
if (overwrite) {
|
||||
try {
|
||||
const allHosts =
|
||||
await createCurrentHostResolutionRepository().findHostsByUserId(
|
||||
userId,
|
||||
);
|
||||
existingHostMap = new Map();
|
||||
for (const h of allHosts) {
|
||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||
existingHostMap.set(key, { id: h.id as number });
|
||||
}
|
||||
} catch {
|
||||
existingHostMap = undefined;
|
||||
}
|
||||
} catch {
|
||||
existingHostMap = undefined;
|
||||
}
|
||||
|
||||
const writtenHostIds: number[] = [];
|
||||
const createdHostIds: number[] = [];
|
||||
for (let i = 0; i < hostsToImport.length; i++) {
|
||||
const hostData = normalizeImportedHost(
|
||||
hostsToImport[i] as Record<string, unknown>,
|
||||
@@ -976,13 +962,6 @@ export function registerHostBulkRoutes(
|
||||
continue;
|
||||
}
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
if (existing && !overwrite) {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId,
|
||||
connectionType: "ssh",
|
||||
@@ -1020,6 +999,8 @@ export function registerHostBulkRoutes(
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
@@ -1042,7 +1023,6 @@ export function registerHostBulkRoutes(
|
||||
sshDataObj,
|
||||
);
|
||||
writtenHostIds.push(saved.id);
|
||||
createdHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1054,10 +1034,10 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds);
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||
success: results.success,
|
||||
updated: results.updated,
|
||||
skipped: results.skipped,
|
||||
|
||||
@@ -88,23 +88,6 @@ export function keepsStoredSshAuth(authType: unknown): boolean {
|
||||
return authType === undefined || authType === null;
|
||||
}
|
||||
|
||||
export function isOptionalAuthType(value: unknown): boolean {
|
||||
return value === undefined || value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth type a new SSH host's secrets point to when the body names none,
|
||||
* as an API client creating a host from a credential does.
|
||||
*/
|
||||
export function impliedSshAuthType(
|
||||
body: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (body.credentialId) return "credential";
|
||||
if (body.key) return "key";
|
||||
if (body.password) return "password";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const FOLDER_PATH_SEPARATOR = " / ";
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,10 +31,8 @@ import {
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
containsOwnerPrivateAuthUpdate,
|
||||
impliedSshAuthType,
|
||||
keepsStoredSshAuth,
|
||||
isNonEmptyString,
|
||||
isOptionalAuthType,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeProtocolEnableFields,
|
||||
@@ -261,9 +259,7 @@ router.post(
|
||||
!isNonEmptyString(ip) ||
|
||||
!isValidPort(port) ||
|
||||
!isOptionalBoolean(shareSshAuth) ||
|
||||
!isOptionalBoolean(enableSsh) ||
|
||||
!isOptionalAuthType(authType) ||
|
||||
!isOptionalAuthType(authMethod)
|
||||
!isOptionalBoolean(enableSsh)
|
||||
) {
|
||||
sshLogger.warn("Invalid SSH data input validation failed", {
|
||||
operation: "host_create",
|
||||
@@ -296,9 +292,7 @@ router.post(
|
||||
const effectiveAuthType =
|
||||
authType ||
|
||||
authMethod ||
|
||||
(effectiveConnectionType !== "ssh"
|
||||
? "password"
|
||||
: impliedSshAuthType(hostData));
|
||||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
|
||||
const effectiveUsername =
|
||||
username || firstProtocolUsername(protocolAuthPatch);
|
||||
const effectiveName =
|
||||
@@ -409,13 +403,6 @@ router.post(
|
||||
body: hostData,
|
||||
}),
|
||||
);
|
||||
// Host defaults may have supplied it.
|
||||
if (!sshDataObj.authType) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"authType is required (password, key, credential, agent or none)",
|
||||
});
|
||||
}
|
||||
const result = await createCurrentHostRepository().createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
@@ -486,7 +473,6 @@ router.post(
|
||||
});
|
||||
|
||||
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
|
||||
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
@@ -841,8 +827,6 @@ router.put(
|
||||
!isValidPort(port) ||
|
||||
!isOptionalBoolean(shareSshAuth) ||
|
||||
!isOptionalBoolean(enableSsh) ||
|
||||
!isOptionalAuthType(authType) ||
|
||||
!isOptionalAuthType(authMethod) ||
|
||||
!hostId
|
||||
) {
|
||||
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
||||
|
||||
@@ -30,7 +30,6 @@ import { hostStatusService } from "../hosts/status/host-status-service.js";
|
||||
import { parseSshOptions } from "../hosts/ssh-options.js";
|
||||
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { pluginEvents, TOPICS } from "./events.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
type AuditFn = (
|
||||
@@ -503,7 +502,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
await writeSettings(created.id, writes);
|
||||
await markSettingsOwn(created.id, writes);
|
||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
|
||||
return (
|
||||
await toRecords([created as unknown as Record<string, unknown>])
|
||||
)[0];
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { getActor, getActorSessionId } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { redactHostSecrets } from "./ctx-hosts.js";
|
||||
import { issueUserToken } from "./ctx-users.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
listSshAuthProviders,
|
||||
@@ -902,7 +901,5 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentUserAuthRepository().countUsersForProvider(provider);
|
||||
},
|
||||
issueUserToken: (userId, options) =>
|
||||
issueUserToken(manifest, userId, options),
|
||||
};
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* ctx.users, and ctx.auth.issueUserToken: what a plugin may learn about a
|
||||
* user, and a short-lived session for calling core's own API as one.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginUserToken,
|
||||
PluginUserTokenOptions,
|
||||
PluginUsers,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
const DEFAULT_TOKEN_TTL_SECONDS = 300;
|
||||
const MAX_TOKEN_TTL_SECONDS = 900;
|
||||
const MAX_PURPOSE_LENGTH = 200;
|
||||
|
||||
export function createPluginUsers(manifest: PluginManifest): PluginUsers {
|
||||
return {
|
||||
getLocale: async (userId) => {
|
||||
await assertCapability(
|
||||
manifest.id,
|
||||
"users:read",
|
||||
manifest.capabilities,
|
||||
"users_get_locale",
|
||||
);
|
||||
const { createCurrentUserPreferenceRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
return preferences?.language ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function issueUserToken(
|
||||
manifest: PluginManifest,
|
||||
userId: string,
|
||||
options: PluginUserTokenOptions,
|
||||
): Promise<PluginUserToken> {
|
||||
await assertCapability(
|
||||
manifest.id,
|
||||
"users:impersonate",
|
||||
manifest.capabilities,
|
||||
"user_token_issued",
|
||||
);
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
const purpose = String(options?.purpose ?? "")
|
||||
.trim()
|
||||
.slice(0, MAX_PURPOSE_LENGTH);
|
||||
const ttlSeconds = Math.min(
|
||||
options?.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS,
|
||||
MAX_TOKEN_TTL_SECONDS,
|
||||
);
|
||||
const audit = (success: boolean, errorMessage?: string) =>
|
||||
logAudit({
|
||||
userId: getActor() ?? null,
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: "plugin_user_token_issued",
|
||||
resourceType: "user",
|
||||
resourceId: userId,
|
||||
details: JSON.stringify({ pluginId: manifest.id, purpose, ttlSeconds }),
|
||||
success,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!purpose) {
|
||||
throw new Error("ctx.auth.issueUserToken needs a purpose");
|
||||
}
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
||||
throw new Error("ttlSeconds must be a positive whole number");
|
||||
}
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const user = userId
|
||||
? await createCurrentUserRepository().findById(userId)
|
||||
: null;
|
||||
if (!user) throw new Error(`User "${userId}" does not exist`);
|
||||
|
||||
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||
const issued = await AuthManager.getInstance().issuePluginSession(user.id, {
|
||||
pluginId: manifest.id,
|
||||
purpose,
|
||||
ttlSeconds,
|
||||
});
|
||||
await audit(true);
|
||||
return issued;
|
||||
} catch (error) {
|
||||
await audit(false, error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,6 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
||||
import { createPluginProcess } from "./ctx-process.js";
|
||||
import { createPluginSystem } from "./ctx-system.js";
|
||||
import { createPluginPlugins } from "./ctx-plugins.js";
|
||||
import { createPluginUsers } from "./ctx-users.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
@@ -958,7 +957,6 @@ export function createPluginContext(
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
plugins: createPluginPlugins(manifest),
|
||||
users: createPluginUsers(manifest),
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
|
||||
@@ -26,8 +26,6 @@ export const TOPICS = {
|
||||
hostStatus: "host.status",
|
||||
/** A host's key was accepted again after it changed. */
|
||||
hostKeyUpdated: "host.key.updated",
|
||||
/** A host was added and anything inventorying hosts should pick it up. */
|
||||
hostCreated: "host.created",
|
||||
/** A host's connection details changed and pollers should re-read them. */
|
||||
hostUpdated: "host.updated",
|
||||
/** A host was deleted and any poller holding it should drop it. */
|
||||
|
||||
@@ -499,21 +499,17 @@ export class SyncEngine {
|
||||
);
|
||||
all.push(...result.changes);
|
||||
next = result.nextCursor;
|
||||
this.learnTypes(result);
|
||||
this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type));
|
||||
this.state.serverTypes = result.entityTypes.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
readOnly: entry.readOnly,
|
||||
}));
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
return { changes: all, cursor: next };
|
||||
}
|
||||
|
||||
private learnTypes(page: ChangesPage): void {
|
||||
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
|
||||
this.state.serverTypes = page.entityTypes.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
readOnly: entry.readOnly,
|
||||
}));
|
||||
}
|
||||
|
||||
private async pull(link: SyncLink): Promise<void> {
|
||||
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
|
||||
const known = new Set(link.knownTypes);
|
||||
@@ -532,14 +528,7 @@ export class SyncEngine {
|
||||
/** Pulls every row of types this device has not pulled before. */
|
||||
private async backfill(link: SyncLink): Promise<void> {
|
||||
if (!this.serverTypes) {
|
||||
// Only the entity types are wanted, so a one-row page is enough.
|
||||
this.learnTypes(
|
||||
await remoteJson<ChangesPage>(
|
||||
link,
|
||||
`/sync/v2/changes?cursor=${link.cursor}&limit=1`,
|
||||
{ timeoutMs: 120_000 },
|
||||
),
|
||||
);
|
||||
await this.fetchChanges(link, Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
const fresh = listEntities()
|
||||
.map((entity) => entity.type)
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import { singletonSyncId } from "./wire.js";
|
||||
import { parseDefaultOverrides } from "../../types/host-defaults.js";
|
||||
import {
|
||||
@@ -175,7 +174,7 @@ export function registerCoreSyncEntities(): void {
|
||||
)
|
||||
: {},
|
||||
}),
|
||||
afterWrite: async ({ id, userId, wire, created, resolveId }) => {
|
||||
afterWrite: async ({ id, userId, wire, resolveId }) => {
|
||||
if (id === null) return;
|
||||
// The server owns host defaults: it keeps what the host set itself
|
||||
// and resolves the rest. A desktop takes the server's values as sent.
|
||||
@@ -196,9 +195,6 @@ export function registerCoreSyncEntities(): void {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().resyncHost(id);
|
||||
if (created) {
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId });
|
||||
}
|
||||
},
|
||||
remove: async (row, userId) => {
|
||||
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
||||
|
||||
@@ -140,9 +140,6 @@ export async function listRecordErrors(userId: string): Promise<SyncRecord[]> {
|
||||
return rows.filter((row) => !!row.error);
|
||||
}
|
||||
|
||||
/** The largest seq the column holds: a 32-bit integer on Postgres and MySQL. */
|
||||
const MAX_SEQ = 2_147_483_647;
|
||||
|
||||
export async function listChanges(
|
||||
userId: string,
|
||||
cursor: number,
|
||||
@@ -151,8 +148,7 @@ export async function listChanges(
|
||||
): Promise<SyncRecord[]> {
|
||||
const conditions = [
|
||||
eq(syncRecords.userId, userId),
|
||||
// Postgres rejects a bound value the column cannot hold.
|
||||
gt(syncRecords.seq, Math.min(cursor, MAX_SEQ)),
|
||||
gt(syncRecords.seq, cursor),
|
||||
];
|
||||
if (types && types.length > 0) {
|
||||
conditions.push(inArray(syncRecords.entityType, types));
|
||||
|
||||
@@ -37,17 +37,6 @@ function userOf(req: Request): string {
|
||||
return (req as AuthenticatedRequest).userId;
|
||||
}
|
||||
|
||||
/** A non-negative integer query value, the fallback when absent, null when malformed. */
|
||||
function queryInt(value: unknown, fallback: number): number | null {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
return typeof value === "string" &&
|
||||
/^\d+$/.test(value) &&
|
||||
Number.isSafeInteger(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/info:
|
||||
@@ -235,8 +224,6 @@ router.post(
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A page of changes, the next cursor and whether more remain.
|
||||
* 400:
|
||||
* description: The cursor or limit is not a non-negative integer.
|
||||
* 500:
|
||||
* description: Failed to read changes.
|
||||
*/
|
||||
@@ -245,12 +232,11 @@ router.get(
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const cursor = queryInt(req.query.cursor, 0);
|
||||
const pageSize = queryInt(req.query.limit, MAX_PAGE);
|
||||
if (cursor === null || pageSize === null) {
|
||||
return res.status(400).json({ error: "Invalid cursor or limit" });
|
||||
}
|
||||
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
|
||||
const cursor = Math.max(0, Number(req.query.cursor) || 0);
|
||||
const limit = Math.min(
|
||||
MAX_PAGE,
|
||||
Math.max(1, Number(req.query.limit) || MAX_PAGE),
|
||||
);
|
||||
const types =
|
||||
typeof req.query.types === "string" && req.query.types
|
||||
? req.query.types.split(",").filter(Boolean)
|
||||
|
||||
@@ -48,7 +48,6 @@ function exportFile(): string {
|
||||
|
||||
async function invoke(path: string, user: unknown = { isExternal: false }) {
|
||||
const writes: string[] = [];
|
||||
const events: Array<[string, unknown]> = [];
|
||||
const hosts: Array<Record<string, unknown>> = [];
|
||||
const importHostProtocolLogins = vi.fn();
|
||||
const handler = runInNewContext(
|
||||
@@ -99,11 +98,6 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
|
||||
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
|
||||
DatabaseSaveTrigger: { forceSave: async () => {} },
|
||||
getErrorMessage: (error: Error) => error.message,
|
||||
pluginEvents: {
|
||||
emit: (topic: string, payload: unknown) =>
|
||||
events.push([topic, payload]),
|
||||
},
|
||||
TOPICS: { hostCreated: "host.created" },
|
||||
},
|
||||
);
|
||||
const res = { json: vi.fn(), status: vi.fn() };
|
||||
@@ -112,7 +106,7 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
|
||||
{ userId: "user-1", file: { path, originalname: "export.db" } },
|
||||
res,
|
||||
);
|
||||
return { res, writes, hosts, events, importHostProtocolLogins };
|
||||
return { res, writes, hosts, importHostProtocolLogins };
|
||||
}
|
||||
|
||||
it("points imported hosts at the credentials imported with them", async () => {
|
||||
@@ -128,17 +122,6 @@ it("points imported hosts at the credentials imported with them", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes host.created once for each imported host", async () => {
|
||||
const { events } = await invoke(exportFile());
|
||||
|
||||
expect(events).toEqual(
|
||||
[501, 502, 503, 504].map((hostId) => [
|
||||
"host.created",
|
||||
{ hostId, userId: "user-1" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the uploaded file when the user is gone", async () => {
|
||||
const path = exportFile();
|
||||
const { res } = await invoke(path, null);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
context: null as DatabaseContext | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentRepositoryContext: () => state.context,
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { triggerSave: vi.fn() },
|
||||
}));
|
||||
|
||||
const { listChanges } = await import("../../../sync/records.js");
|
||||
|
||||
// Runs against Postgres and MySQL too in the database-dialects CI job, where
|
||||
// sync_records.seq is a 32-bit integer.
|
||||
describe("listChanges", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
state.context = null;
|
||||
});
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
state.context = await adapter.connect();
|
||||
await adapter.run(
|
||||
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user-1', '')`,
|
||||
);
|
||||
await adapter.run(
|
||||
sql`INSERT INTO sync_records (user_id, entity_type, sync_id, revision, seq) VALUES ('user-1', 'hosts', 'a', 1, 1), ('user-1', 'hosts', 'b', 1, 2)`,
|
||||
);
|
||||
}
|
||||
|
||||
it("lists records after the cursor in seq order", async () => {
|
||||
await seed();
|
||||
const records = await listChanges("user-1", 1, 10);
|
||||
expect(records.map((record) => record.syncId)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("answers no records for a cursor past what seq can hold", async () => {
|
||||
await seed();
|
||||
expect(await listChanges("user-1", Number.MAX_SAFE_INTEGER, 10)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => {
|
||||
seen.push(payload);
|
||||
});
|
||||
try {
|
||||
notifyImportedHosts("user-1", [4, 7], []);
|
||||
notifyImportedHosts("user-1", [4, 7]);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* The create and update host routes against a real SQLite database. A body
|
||||
* that leaves out authType must never reach the NOT NULL auth_type column.
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import request from "supertest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
ctx: null as null | { drizzle: unknown },
|
||||
}));
|
||||
|
||||
const USER = "user-1";
|
||||
const pass = (_req: Request, _res: Response, next: NextFunction) => next();
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => state.ctx!.drizzle,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
batched: <T>(work: T) => work,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({
|
||||
getEncryptionKey: async () => Buffer.alloc(32, 7),
|
||||
getJWTSecret: async () => "secret",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: () => Buffer.alloc(32, 3),
|
||||
validateUserAccess: () => Buffer.alloc(32, 3),
|
||||
encryptRecord: (_table: string, record: unknown) => record,
|
||||
decryptRecord: (_table: string, record: unknown) => record,
|
||||
decryptRecords: (_table: string, records: unknown) => records,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: Request, _res: Response, next: NextFunction) => {
|
||||
(req as Request & { userId: string }).userId = USER;
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware: () => pass,
|
||||
createAdminMiddleware: () => pass,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
requirePermission: () => pass,
|
||||
canAccessHost: async () => ({ hasAccess: true, isOwner: true }),
|
||||
hasPermission: async () => true,
|
||||
isAdmin: async () => true,
|
||||
getUserPermissions: async () => [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async () => {},
|
||||
getAuditUsername: async () => USER,
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const { TestSqliteDatabase } = await import("../repositories/test-support.js");
|
||||
const { hosts, sshCredentials } =
|
||||
await import("../../../database/db/schema.js");
|
||||
const { saveLevel } = await import("../../../hosts/defaults/service.js");
|
||||
const { default: hostRouter } =
|
||||
await import("../../../database/routes/host.js");
|
||||
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/host", hostRouter);
|
||||
|
||||
let db: InstanceType<typeof TestSqliteDatabase>;
|
||||
let ctx: DatabaseContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new TestSqliteDatabase("sqlite");
|
||||
ctx = await db.connect();
|
||||
state.ctx = ctx;
|
||||
await ctx.drizzle.run(
|
||||
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${USER}, ${USER}, '', 1)`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
async function addCredential(): Promise<number> {
|
||||
const [row] = await ctx.drizzle
|
||||
.insert(sshCredentials)
|
||||
.values({
|
||||
userId: USER,
|
||||
name: "ops",
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
})
|
||||
.returning({ id: sshCredentials.id });
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function storedAuthType(id: number) {
|
||||
const [row] = await ctx.drizzle
|
||||
.select({ authType: hosts.authType })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, id));
|
||||
return row?.authType;
|
||||
}
|
||||
|
||||
const base = { ip: "10.0.0.5", port: 22, username: "root" };
|
||||
|
||||
describe("host create without authType", () => {
|
||||
it("infers credential auth from a credentialId", async () => {
|
||||
const credentialId = await addCredential();
|
||||
const res = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, credentialId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedAuthType(res.body.id)).toBe("credential");
|
||||
});
|
||||
|
||||
it("infers password auth from a password", async () => {
|
||||
const res = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, password: "pw" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedAuthType(res.body.id)).toBe("password");
|
||||
});
|
||||
|
||||
it("rejects a body that says nothing about authentication", async () => {
|
||||
const res = await request(app).post("/host/db/host").send(base);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/authType is required/);
|
||||
});
|
||||
|
||||
it("takes the auth type from the user's host defaults", async () => {
|
||||
const credentialId = await addCredential();
|
||||
await saveLevel(
|
||||
{ level: "user", userId: USER },
|
||||
{
|
||||
set: new Map([["core.auth", { authType: "credential", credentialId }]]),
|
||||
unset: new Set(),
|
||||
errors: {},
|
||||
},
|
||||
USER,
|
||||
);
|
||||
const res = await request(app).post("/host/db/host").send(base);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedAuthType(res.body.id)).toBe("credential");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host update authType", () => {
|
||||
it("keeps the stored auth type when the update leaves it out", async () => {
|
||||
const created = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, authType: "password", password: "pw" });
|
||||
const res = await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, name: "renamed" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedAuthType(created.body.id)).toBe("password");
|
||||
});
|
||||
|
||||
it("rejects an authType that is not a string", async () => {
|
||||
const created = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, authType: "password", password: "pw" });
|
||||
const res = await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, authType: { bad: true } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.created", () => {
|
||||
it("is published once when a host is created, and not on update", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
try {
|
||||
const created = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, authType: "password", password: "pw" });
|
||||
expect(created.status).toBe(200);
|
||||
await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, name: "renamed" });
|
||||
expect(seen).toEqual([{ hostId: created.body.id, userId: USER }]);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,14 +37,11 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}));
|
||||
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
|
||||
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
|
||||
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
|
||||
|
||||
let handler: RequestHandler;
|
||||
let sshConfigHandler: RequestHandler;
|
||||
const router = {
|
||||
post: (path: string, ...handlers: RequestHandler[]) => {
|
||||
if (path === "/bulk-import") handler = handlers.at(-1)!;
|
||||
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
|
||||
},
|
||||
put: () => {},
|
||||
patch: () => {},
|
||||
@@ -69,13 +66,12 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.create.mockResolvedValue({ id: 71 });
|
||||
mocks.update.mockResolvedValue({ id: 19 });
|
||||
mocks.list.mockResolvedValue([]);
|
||||
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"carries plugin host settings through export selection and import (overwrite=%s)",
|
||||
async (overwrite) => {
|
||||
if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||
const payload = buildExportPayload(
|
||||
{ hosts: [host] },
|
||||
null,
|
||||
@@ -172,60 +168,6 @@ it.each([20, "20"])(
|
||||
},
|
||||
);
|
||||
|
||||
it("skips hosts that already exist when not overwriting", async () => {
|
||||
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||
const res = await importHosts([host]);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: 0, updated: 0, skipped: 1, failed: 0 }),
|
||||
);
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
expect(mocks.applyPluginSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a skipped jump host reference to its existing destination ID", async () => {
|
||||
mocks.list.mockResolvedValue([{ ...host, ip: "192.0.2.2", id: 801 }]);
|
||||
const res = await importHosts([
|
||||
{ ...host, exportId: 10, jumpHosts: [{ hostId: 20 }] },
|
||||
{ ...host, ip: "192.0.2.2", exportId: 20 },
|
||||
]);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: 1, skipped: 1, failed: 0 }),
|
||||
);
|
||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(mocks.create.mock.calls[0][1].jumpHosts)).toEqual([
|
||||
{ hostId: 801 },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, { success: 0, updated: 0, skipped: 1 }],
|
||||
[true, { success: 0, updated: 1, skipped: 0 }],
|
||||
])(
|
||||
"SSH config import with an existing host (overwrite=%s)",
|
||||
async (overwrite, counts) => {
|
||||
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() };
|
||||
await sshConfigHandler(
|
||||
{
|
||||
userId: "user-1",
|
||||
body: {
|
||||
content: "Host web\n HostName 192.0.2.1\n User alice\n Port 22\n",
|
||||
overwrite,
|
||||
},
|
||||
} as unknown as Request,
|
||||
res as unknown as Response,
|
||||
vi.fn(),
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ...counts, failed: 0 }),
|
||||
);
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(mocks.update).toHaveBeenCalledTimes(overwrite ? 1 : 0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[{ ...host, jumpHosts: [{ hostId: 20 }] }],
|
||||
[{ ...host, jumpHosts: [{ hostId: "20" }] }],
|
||||
@@ -260,53 +202,3 @@ it("does not save dependent hosts when importing their jump host fails", async (
|
||||
);
|
||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function collectCreated() {
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
return { seen, off };
|
||||
}
|
||||
|
||||
it("publishes host.created once for each host a JSON import creates", async () => {
|
||||
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||
mocks.create.mockResolvedValueOnce({ id: 501 });
|
||||
const { seen, off } = collectCreated();
|
||||
try {
|
||||
await importHosts([host, { ...host, ip: "192.0.2.2" }], true);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
// The overwritten host already existed, so only the new one is announced.
|
||||
expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]);
|
||||
});
|
||||
|
||||
it("publishes host.created once for each host an SSH config import creates", async () => {
|
||||
mocks.create
|
||||
.mockResolvedValueOnce({ id: 601 })
|
||||
.mockResolvedValueOnce({ id: 602 });
|
||||
const { seen, off } = collectCreated();
|
||||
try {
|
||||
await sshConfigHandler(
|
||||
{
|
||||
userId: "user-1",
|
||||
body: {
|
||||
content:
|
||||
"Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n",
|
||||
},
|
||||
} as unknown as Request,
|
||||
{
|
||||
json: vi.fn(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response,
|
||||
vi.fn(),
|
||||
);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
{ hostId: 601, userId: "user-1" },
|
||||
{ hostId: 602, userId: "user-1" },
|
||||
]);
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js"
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
containsOwnerPrivateAuthUpdate,
|
||||
impliedSshAuthType,
|
||||
isNonEmptyString,
|
||||
keepsStoredSshAuth,
|
||||
isOptionalBoolean,
|
||||
@@ -516,16 +515,3 @@ describe("keepsStoredSshAuth", () => {
|
||||
expect(keepsStoredSshAuth("none")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("impliedSshAuthType", () => {
|
||||
it("reads the auth type from the secret a create body carries", () => {
|
||||
expect(impliedSshAuthType({ credentialId: 2, password: "pw" })).toBe(
|
||||
"credential",
|
||||
);
|
||||
expect(impliedSshAuthType({ key: "-----BEGIN", password: "pw" })).toBe(
|
||||
"key",
|
||||
);
|
||||
expect(impliedSshAuthType({ password: "pw" })).toBe("password");
|
||||
expect(impliedSshAuthType({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import vm from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const linkedServer = require("../../../../electron/linked-server.cjs") as {
|
||||
setLinkedServer: (config: unknown) => void;
|
||||
};
|
||||
|
||||
const source = fs.readFileSync(
|
||||
new URL("../../../../electron/main.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const tls = source.slice(
|
||||
source.indexOf("const INSECURE_MODE_VALUES"),
|
||||
source.indexOf("function httpFetch("),
|
||||
);
|
||||
|
||||
const ORIGIN = "https://termix.example";
|
||||
const TERMINAL = "wss://termix.example/plugin-ws/ssh-terminal/terminal";
|
||||
|
||||
function load(link: Record<string, unknown> | null) {
|
||||
linkedServer.setLinkedServer(link);
|
||||
const context = vm.createContext({
|
||||
URL,
|
||||
process: { env: {} },
|
||||
linkedServer,
|
||||
});
|
||||
vm.runInContext(tls, context);
|
||||
return context as unknown as {
|
||||
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
|
||||
getWebSocketOptions: (
|
||||
url: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("linked server certificate exception", () => {
|
||||
it("covers WebSockets to the linked server", () => {
|
||||
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
|
||||
expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true);
|
||||
expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({
|
||||
headers: {},
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ws://termix.example/relay",
|
||||
"wss://termix.example:8443/relay",
|
||||
"wss://elsewhere.example/relay",
|
||||
])("keeps verifying %s", (url) => {
|
||||
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
|
||||
expect(main.isInvalidCertificateAllowedForUrl(url)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps verifying when the link does not allow it", () => {
|
||||
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
|
||||
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,48 +39,6 @@ describe("linked server in Electron main", () => {
|
||||
).toEqual({ Accept: "x" });
|
||||
});
|
||||
|
||||
it("treats WebSocket upgrades to the linked server as the linked origin", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN });
|
||||
expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true);
|
||||
expect(
|
||||
linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"),
|
||||
).toBe(true);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true);
|
||||
expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("not a url")).toBe(false);
|
||||
|
||||
linked.setLinkedServer({ origin: "http://termix.example:8080" });
|
||||
expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds proxy headers and basic auth to the terminal WebSocket", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
expect(
|
||||
linked.applyLinkHeaders(
|
||||
"wss://termix.example/plugin-ws/ssh-terminal/terminal",
|
||||
{ Upgrade: "websocket" },
|
||||
),
|
||||
).toEqual({
|
||||
Upgrade: "websocket",
|
||||
"CF-Access-Client-Id": "id",
|
||||
Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`,
|
||||
});
|
||||
expect(
|
||||
linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"),
|
||||
).toEqual({ username: "gate", password: "pw" });
|
||||
expect(
|
||||
linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }),
|
||||
).toEqual({ Upgrade: "websocket" });
|
||||
});
|
||||
|
||||
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
|
||||
@@ -207,7 +207,6 @@ vi.mock("../../hosts/delete-host.js", () => ({
|
||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
@@ -437,29 +436,6 @@ describe("ctx.hosts", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes host.created for a host it creates", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
try {
|
||||
await hosts.create({
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
|
||||
});
|
||||
|
||||
it("refuses a field the SDK does not take instead of dropping it", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* ctx.auth.issueUserToken mints a short-lived session core's own auth
|
||||
* middleware accepts as the named user, under users:impersonate, audited and
|
||||
* kept off account security routes. ctx.users.getLocale reads the user's UI
|
||||
* language under users:read.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
grants: [] as string[],
|
||||
users: new Map<string, { id: string; username: string; isAdmin: boolean }>(),
|
||||
sessions: new Map<string, Record<string, unknown>>(),
|
||||
languages: new Map<string, string | null>(),
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
db: {},
|
||||
getDb: () => ({}),
|
||||
saveMemoryDatabaseToFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
state.grants.map((capability) => ({ pluginId, capability })),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentSessionRepository: () => ({
|
||||
create: async (session: Record<string, unknown>) => {
|
||||
state.sessions.set(session.id as string, session);
|
||||
return session;
|
||||
},
|
||||
findById: async (id: string) => state.sessions.get(id) ?? null,
|
||||
touch: async () => {},
|
||||
revoke: async (id: string) => state.sessions.delete(id),
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
}),
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async (userId: string) =>
|
||||
state.languages.has(userId)
|
||||
? { userId, language: state.languages.get(userId) }
|
||||
: null,
|
||||
}),
|
||||
createCurrentApiKeyRepository: () => ({}),
|
||||
createCurrentTrustedDeviceRepository: () => ({}),
|
||||
getCurrentSettingValue: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({
|
||||
getJWTSecret: async () => "a".repeat(64),
|
||||
getEncryptionKey: async () => crypto.randomBytes(32),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/user-keys.js", () => ({
|
||||
UserKeyManager: {
|
||||
getInstance: () => ({
|
||||
hasUserDEK: () => true,
|
||||
tryGetUserDEK: () => null,
|
||||
invalidate: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
|
||||
adoptRecoveredDEK: vi.fn(async () => {}),
|
||||
migratePasswordUserAtLogin: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const { createPluginContext, createPluginHandle } =
|
||||
await import("../../plugins/ctx.js");
|
||||
const { invalidatePluginPermissionCache } =
|
||||
await import("../../plugins/permissions.js");
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const { registerSecondFactor, resetAuthRegistryForTests } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticate = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
function contextFor(capabilities: string[], grants = capabilities) {
|
||||
state.grants = grants;
|
||||
const manifest = {
|
||||
id: "ai",
|
||||
name: "AI",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
return createPluginContext(manifest, createPluginHandle("ai", {} as never));
|
||||
}
|
||||
|
||||
type Middleware = typeof authenticate;
|
||||
|
||||
async function call(middleware: Middleware, token: string, url: string) {
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
method: "POST",
|
||||
originalUrl: url,
|
||||
url,
|
||||
} as unknown as Parameters<Middleware>[0] & { userId?: string };
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return res;
|
||||
},
|
||||
clearCookie() {
|
||||
return res;
|
||||
},
|
||||
};
|
||||
let passed = false;
|
||||
await middleware(req, res as never, () => {
|
||||
passed = true;
|
||||
});
|
||||
return { passed, status: res.statusCode, body: res.body, userId: req.userId };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.users = new Map([
|
||||
["u1", { id: "u1", username: "alice", isAdmin: false }],
|
||||
["admin", { id: "admin", username: "root", isAdmin: true }],
|
||||
]);
|
||||
state.sessions.clear();
|
||||
state.languages.clear();
|
||||
state.audits = [];
|
||||
invalidatePluginPermissionCache();
|
||||
resetAuthRegistryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ctx.auth.issueUserToken", () => {
|
||||
it("mints a session core's auth middleware accepts as that user", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "nightly report",
|
||||
});
|
||||
const result = await call(authenticate, token, "/host/db/host");
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.userId).toBe("u1");
|
||||
const session = [...state.sessions.values()][0];
|
||||
expect(session).toMatchObject({
|
||||
userId: "u1",
|
||||
deviceType: "plugin",
|
||||
deviceInfo: "ai: nightly report",
|
||||
expiresAt,
|
||||
});
|
||||
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||
expect(payload.exp - payload.iat).toBe(300);
|
||||
});
|
||||
|
||||
it("keeps the user's own permissions", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const user = await ctx.auth.issueUserToken("u1", { purpose: "check" });
|
||||
const admin = await ctx.auth.issueUserToken("admin", { purpose: "check" });
|
||||
|
||||
const refused = await call(requireAdmin, user.token, "/users/list");
|
||||
expect(refused.passed).toBe(false);
|
||||
expect(refused.status).toBe(403);
|
||||
expect((await call(requireAdmin, admin.token, "/users/list")).passed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("caps the lifetime at 900 seconds", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "long task",
|
||||
ttlSeconds: 86_400,
|
||||
});
|
||||
|
||||
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||
expect(payload.exp - payload.iat).toBe(900);
|
||||
expect(new Date(expiresAt).getTime() - Date.now()).toBeLessThanOrEqual(
|
||||
900_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops working when the session expires", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const { token } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "short",
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
vi.useFakeTimers({ now: Date.now() + 61_000 });
|
||||
const result = await call(authenticate, token, "/host/db/host");
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("audits every issue with the plugin, the user and the purpose", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
await ctx.auth.issueUserToken("u1", { purpose: "nightly report" });
|
||||
|
||||
const entry = state.audits.find(
|
||||
(audit) => audit.action === "plugin_user_token_issued",
|
||||
);
|
||||
expect(entry).toMatchObject({
|
||||
username: "plugin:ai",
|
||||
resourceType: "user",
|
||||
resourceId: "u1",
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.parse(entry!.details as string)).toEqual({
|
||||
pluginId: "ai",
|
||||
purpose: "nightly report",
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("is refused without a granted users:impersonate", async () => {
|
||||
for (const ctx of [
|
||||
contextFor([]),
|
||||
contextFor(["users:impersonate"], []),
|
||||
contextFor(["users:read"]),
|
||||
]) {
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("u1", { purpose: "x" }),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
}
|
||||
expect(state.sessions.size).toBe(0);
|
||||
expect(state.audits.every((audit) => audit.success === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("is refused for a user that does not exist, or without a purpose", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("ghost", { purpose: "x" }),
|
||||
).rejects.toThrow(/does not exist/);
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("u1", { purpose: " " }),
|
||||
).rejects.toThrow(/purpose/);
|
||||
expect(state.sessions.size).toBe(0);
|
||||
expect(
|
||||
state.audits.filter(
|
||||
(audit) =>
|
||||
audit.action === "plugin_user_token_issued" && !audit.success,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("cannot change how the user signs in or mint lasting credentials", async () => {
|
||||
registerSecondFactor({ id: "totp", pluginId: "totp" } as never);
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const { token } = await ctx.auth.issueUserToken("admin", {
|
||||
purpose: "ops",
|
||||
});
|
||||
|
||||
for (const url of [
|
||||
"/users/change-password",
|
||||
"/users/link-external-to-password",
|
||||
"/users/admin/reset-password",
|
||||
"/users/admin/u1/second-factors",
|
||||
"/plugin-api/totp/setup",
|
||||
]) {
|
||||
const result = await call(authenticate, token, url);
|
||||
expect(result.passed, url).toBe(false);
|
||||
expect(result.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||
}
|
||||
const apiKeys = await call(requireAdmin, token, "/users/api-keys");
|
||||
expect(apiKeys.passed).toBe(false);
|
||||
expect(apiKeys.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||
expect(
|
||||
(await call(authenticate, token, "/plugin-api/other/run")).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a normal login session free to use those routes", async () => {
|
||||
const token = await authManager.generateJWTToken("u1", {
|
||||
deviceType: "web",
|
||||
deviceInfo: "Chrome",
|
||||
});
|
||||
|
||||
expect(
|
||||
(await call(authenticate, token, "/users/change-password")).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.users.getLocale", () => {
|
||||
it("returns the stored language, or null", async () => {
|
||||
state.languages.set("u1", "zh_CN");
|
||||
state.languages.set("admin", null);
|
||||
const ctx = contextFor(["users:read"]);
|
||||
|
||||
await expect(ctx.users.getLocale("u1")).resolves.toBe("zh_CN");
|
||||
await expect(ctx.users.getLocale("admin")).resolves.toBeNull();
|
||||
await expect(ctx.users.getLocale("nobody")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("is refused without a granted users:read", async () => {
|
||||
state.languages.set("u1", "zh_CN");
|
||||
|
||||
await expect(
|
||||
contextFor(["users:read"], []).users.getLocale("u1"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(
|
||||
contextFor(["users:impersonate"]).users.getLocale("u1"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
});
|
||||
@@ -75,8 +75,7 @@ const UNGATED: Record<string, string> = {
|
||||
"schedule.every": "the plugin's own timers",
|
||||
"schedule.after": "the plugin's own timers",
|
||||
"audit.record": "writes under the runtime's actor, never a plugin value",
|
||||
asUser:
|
||||
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
|
||||
asUser: "always audited as plugin_as_user",
|
||||
currentActor: "reads the actor, cannot set it",
|
||||
"desktop.available": "a yes/no about the environment",
|
||||
"http.baseUrl": "reads the request's public origin",
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { listChanges } = vi.hoisted(() => ({ listChanges: vi.fn() }));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } }));
|
||||
vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() }));
|
||||
vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
|
||||
vi.mock("../../../plugins/sync-registry.js", () => ({
|
||||
getEntity: vi.fn(),
|
||||
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
vi.mock("../../../sync/entities.js", () => ({
|
||||
registerCoreSyncEntities: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../sync/records.js", () => ({
|
||||
listChanges,
|
||||
recordKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../sync/server/feed.js", () => ({
|
||||
withUserLock: (_userId: string, fn: () => unknown) => fn(),
|
||||
reconcileUser: async () => ({ wire: new Map() }),
|
||||
}));
|
||||
vi.mock("../../../sync/server/push.js", () => ({}));
|
||||
vi.mock("../../../database/routes/branding-settings.js", () => ({}));
|
||||
|
||||
import router from "../../../sync/server/routes.js";
|
||||
|
||||
const route = router.stack.find(
|
||||
(layer) => layer.route?.path === "/v2/changes",
|
||||
)!.route!;
|
||||
const handler = route.stack[route.stack.length - 1].handle;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listChanges.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
async function changes(query: Record<string, string>) {
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
await handler({ userId: "user-1", query } as never, res as never, vi.fn());
|
||||
return res;
|
||||
}
|
||||
|
||||
it("answers an empty page with the entity types past the newest change", async () => {
|
||||
const cursor = String(Number.MAX_SAFE_INTEGER);
|
||||
const res = await changes({ cursor, limit: "1000" });
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
changes: [],
|
||||
nextCursor: Number.MAX_SAFE_INTEGER,
|
||||
hasMore: false,
|
||||
entityTypes: [{ type: "hosts", owner: "core", order: 10, readOnly: false }],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a missing cursor and limit", async () => {
|
||||
await changes({});
|
||||
expect(listChanges).toHaveBeenCalledWith("user-1", 0, 1001, undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ cursor: "abc" },
|
||||
{ cursor: "-1" },
|
||||
{ cursor: "1.5" },
|
||||
{ cursor: "Infinity" },
|
||||
{ limit: "1.5" },
|
||||
{ limit: "-5" },
|
||||
])("rejects a malformed query %o", async (query) => {
|
||||
const res = await changes(query);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(listChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -15,7 +15,6 @@ const state = vi.hoisted(() => ({
|
||||
server: null as null | { drizzle: unknown },
|
||||
allow: true,
|
||||
sharedHostIds: new Set<number>(),
|
||||
paths: [] as string[],
|
||||
}));
|
||||
|
||||
const SERVER_USER = "srv-user";
|
||||
@@ -124,7 +123,6 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => {
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown } = {},
|
||||
) => {
|
||||
state.paths.push(path);
|
||||
const previous = state.current;
|
||||
state.current = state.server;
|
||||
try {
|
||||
@@ -163,7 +161,6 @@ const { createResolvers, writeWireRow } = await import("../../sync/store.js");
|
||||
const { getEntity } = await import("../../plugins/sync-registry.js");
|
||||
const { FieldCrypto } = await import("../../utils/field-crypto.js");
|
||||
const { syncLogger } = await import("../../utils/logger.js");
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
@@ -252,7 +249,6 @@ beforeEach(async () => {
|
||||
resetSeqAllocator();
|
||||
state.allow = true;
|
||||
state.sharedHostIds.clear();
|
||||
state.paths = [];
|
||||
server = await side(SERVER_USER);
|
||||
state.server = server.ctx;
|
||||
desktops = [await side("desk-a"), await side("desk-b")];
|
||||
@@ -321,60 +317,6 @@ describe("linking a desktop", () => {
|
||||
expect(host.ip).toBe("new");
|
||||
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("never asks for a cursor past what the server's seq column holds", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "srv" }));
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const cursors = state.paths
|
||||
.filter((path) => path.startsWith("/sync/v2/changes?"))
|
||||
.map((path) =>
|
||||
Number(new URLSearchParams(path.split("?")[1]).get("cursor")),
|
||||
);
|
||||
expect(cursors.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...cursors)).toBeLessThanOrEqual(2_147_483_647);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a synced host", () => {
|
||||
it("publishes host.created when it is written for the first time only", async () => {
|
||||
const [desk] = desktops;
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
const wire = {
|
||||
syncId: "host-new",
|
||||
name: "new",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
};
|
||||
try {
|
||||
await on(desk, async () => {
|
||||
const resolvers = createResolvers(desk.userId);
|
||||
const first = await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
wire,
|
||||
resolvers,
|
||||
);
|
||||
await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
{ ...wire, name: "renamed" },
|
||||
resolvers,
|
||||
);
|
||||
expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]);
|
||||
});
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("after linking", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const createMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -60,101 +60,6 @@ describe("logAudit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("logAudit plugin routine coalescing", () => {
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
let run = 0;
|
||||
let details = "";
|
||||
|
||||
const routine = (overrides: Record<string, unknown> = {}) =>
|
||||
logAudit({
|
||||
userId: null,
|
||||
username: "plugin:automations",
|
||||
action: "plugin_as_user",
|
||||
resourceType: "plugin",
|
||||
resourceId: "automations",
|
||||
details,
|
||||
success: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createMock.mockResolvedValue(undefined);
|
||||
vi.useFakeTimers();
|
||||
// The coalescing memory is module state: a fresh key per test keeps them
|
||||
// independent.
|
||||
details = `automations ran background work as user-${++run}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("writes one row for identical successful plugin_as_user repeats in the window", async () => {
|
||||
await routine();
|
||||
vi.advanceTimersByTime(WINDOW_MS - 1);
|
||||
await routine();
|
||||
await routine();
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes one row for identical successful plugin service calls in the window", async () => {
|
||||
const service = {
|
||||
action: "plugin_service_host_metrics_open",
|
||||
resourceId: "host-metrics",
|
||||
};
|
||||
await routine(service);
|
||||
await routine(service);
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes again once the window has passed", async () => {
|
||||
await routine();
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
await routine();
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("always writes failures", async () => {
|
||||
await routine();
|
||||
await routine({ success: false, errorMessage: "boom" });
|
||||
await routine({ success: false, errorMessage: "boom" });
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("never coalesces non-plugin or non-routine actions", async () => {
|
||||
const login = {
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
action: "login",
|
||||
resourceType: "user",
|
||||
};
|
||||
await routine(login);
|
||||
await routine(login);
|
||||
const secretRead = { action: "plugin_secret_shared_read" };
|
||||
await routine(secretRead);
|
||||
await routine(secretRead);
|
||||
const notPlugin = { username: "alice" };
|
||||
await routine(notPlugin);
|
||||
await routine(notPlugin);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("does not coalesce entries with different details or resourceId", async () => {
|
||||
await routine();
|
||||
await routine({ details: `${details} (other)` });
|
||||
await routine({ resourceId: "other-plugin" });
|
||||
await routine({ userId: "user-2" });
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequestMeta", () => {
|
||||
it("uses the proxy-validated Express IP", () => {
|
||||
const req = {
|
||||
|
||||
@@ -35,49 +35,7 @@ export interface AuditLogParams {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// Plugin routine calls (a headless viewer's heartbeat through ctx.asUser and a
|
||||
// service) repeat identically every few seconds; written each time they push
|
||||
// real security entries out of the capped log. Only successful plugin_as_user
|
||||
// and plugin_service_* lines are coalesced; failures and everything else are
|
||||
// always written.
|
||||
const ROUTINE_COALESCE_MS = 10 * 60 * 1000;
|
||||
const ROUTINE_COALESCE_MAX_KEYS = 5000;
|
||||
const routineLastWritten = new Map<string, number>();
|
||||
|
||||
function isCoalescedRoutine(params: AuditLogParams): boolean {
|
||||
if (
|
||||
!params.success ||
|
||||
!params.username.startsWith("plugin:") ||
|
||||
!(
|
||||
params.action === "plugin_as_user" ||
|
||||
params.action.startsWith("plugin_service_")
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const key = JSON.stringify([
|
||||
params.userId,
|
||||
params.username,
|
||||
params.action,
|
||||
params.resourceType,
|
||||
params.resourceId,
|
||||
params.details,
|
||||
]);
|
||||
const now = Date.now();
|
||||
const last = routineLastWritten.get(key);
|
||||
if (last !== undefined && now - last < ROUTINE_COALESCE_MS) return true;
|
||||
if (routineLastWritten.size >= ROUTINE_COALESCE_MAX_KEYS) {
|
||||
for (const [k, at] of routineLastWritten) {
|
||||
if (now - at >= ROUTINE_COALESCE_MS) routineLastWritten.delete(k);
|
||||
}
|
||||
}
|
||||
routineLastWritten.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function logAudit(params: AuditLogParams): Promise<void> {
|
||||
if (isCoalescedRoutine(params)) return;
|
||||
|
||||
// Local storage is the source of truth and runs first; forwarding is a copy
|
||||
// and must never delay or fail the audited operation.
|
||||
void forwardAuditEntry(params).catch(() => {});
|
||||
|
||||
@@ -15,7 +15,6 @@ import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { inArray, isNotNull } from "drizzle-orm";
|
||||
import type { ExternalSessionRef } from "../auth/types.js";
|
||||
import { listSecondFactors } from "../auth/registry.js";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sessions } from "../database/db/schema.js";
|
||||
@@ -45,11 +44,6 @@ interface JWTPayload {
|
||||
pendingTOTP?: boolean;
|
||||
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
|
||||
purpose?: "ws";
|
||||
/**
|
||||
* A plugin minted the session. Read from the session row on verify, never
|
||||
* from the token, so a refreshed token stays marked.
|
||||
*/
|
||||
pluginSession?: boolean;
|
||||
dataKeyWrap?: WrappedDataKey;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
@@ -109,26 +103,6 @@ const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
|
||||
|
||||
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
|
||||
|
||||
/** sessions.device_type of a session a plugin minted with ctx.auth.issueUserToken. */
|
||||
export const PLUGIN_SESSION_DEVICE_TYPE = "plugin";
|
||||
|
||||
// Routes that change how a user signs in or mint a credential that outlives
|
||||
// the session. A plugin-minted session is refused on them, and on every
|
||||
// route of a plugin that provides a second factor.
|
||||
const PLUGIN_SESSION_BLOCKLIST = [
|
||||
/^\/users\/(change-password|api-keys|link-external-to-password|unlink-external-from-password|admin\/reset-password)(\/|$)/,
|
||||
/^\/users\/admin\/[^/]+\/second-factors(\/|$)/,
|
||||
];
|
||||
|
||||
/** Whether a session a plugin minted may call this path. */
|
||||
export function allowsPluginSession(path: string): boolean {
|
||||
if (PLUGIN_SESSION_BLOCKLIST.some((pattern) => pattern.test(path))) {
|
||||
return false;
|
||||
}
|
||||
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
|
||||
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
|
||||
}
|
||||
|
||||
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
||||
|
||||
/** Set by the plugin runtime: whether a running plugin opted in. */
|
||||
@@ -433,38 +407,6 @@ class AuthManager {
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session a plugin mints for a user, to call core's API as them. It is
|
||||
* an ordinary session row, so it verifies, expires and shows up like a
|
||||
* login, marked with the plugin and why.
|
||||
*/
|
||||
async issuePluginSession(
|
||||
userId: string,
|
||||
options: { pluginId: string; purpose: string; ttlSeconds: number },
|
||||
): Promise<{ token: string; expiresAt: string }> {
|
||||
const sessionId = nanoid();
|
||||
const token = jwt.sign(
|
||||
{ userId, sessionId } satisfies JWTPayload,
|
||||
await this.systemCrypto.getJWTSecret(),
|
||||
{ expiresIn: options.ttlSeconds },
|
||||
);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(
|
||||
now.getTime() + options.ttlSeconds * 1000,
|
||||
).toISOString();
|
||||
await createCurrentSessionRepository().create({
|
||||
id: sessionId,
|
||||
userId,
|
||||
jwtToken: token,
|
||||
deviceType: PLUGIN_SESSION_DEVICE_TYPE,
|
||||
deviceInfo: `${options.pluginId}: ${options.purpose}`,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt,
|
||||
lastActiveAt: now.toISOString(),
|
||||
});
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async verifyJWTToken(
|
||||
token: string,
|
||||
options: { allowSocketTicket?: boolean } = {},
|
||||
@@ -498,8 +440,6 @@ class AuthManager {
|
||||
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
payload.pluginSession =
|
||||
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
|
||||
|
||||
await this.migrateDataKeyFromPayload(payload);
|
||||
} catch (dbError) {
|
||||
@@ -847,8 +787,6 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refusePluginSession(req, res, payload)) return;
|
||||
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRepository = createCurrentSessionRepository();
|
||||
@@ -1015,21 +953,6 @@ class AuthManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sends the 403 and returns true when a plugin session may not use this route. */
|
||||
private refusePluginSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
payload: JWTPayload,
|
||||
): boolean {
|
||||
const path = (req.originalUrl || req.url || "").split("?")[0];
|
||||
if (!payload.pluginSession || allowsPluginSession(path)) return false;
|
||||
res.status(403).json({
|
||||
error: "A session a plugin issued cannot use this route",
|
||||
code: "PLUGIN_SESSION_NOT_ALLOWED",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
createDataAccessMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
@@ -1084,8 +1007,6 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refusePluginSession(req, res, payload)) return;
|
||||
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(
|
||||
payload.userId,
|
||||
|
||||
+2
-27
@@ -6,8 +6,6 @@ import "./ui/index.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./ui/i18n/i18n";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
|
||||
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
|
||||
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
|
||||
import { Toaster } from "@/components/sonner";
|
||||
import {
|
||||
@@ -86,15 +84,6 @@ function FullscreenApp() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-out browser gets the normal login for a full-screen link and comes
|
||||
* back to it afterwards. The desktop app and the embedded sign-in frames
|
||||
* hand a login to their own shell, so they keep the bare view.
|
||||
*/
|
||||
function signsInForViewLink(): boolean {
|
||||
return !isElectron() && !isEmbeddedAuthFrame();
|
||||
}
|
||||
|
||||
function FullscreenAppGate() {
|
||||
const { t } = useTranslation();
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -120,16 +109,7 @@ function FullscreenAppGate() {
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
if (signsInForViewLink()) {
|
||||
rememberViewLink();
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
}
|
||||
setAuthFailed(true);
|
||||
if (!cancelled) setAuthFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -138,7 +118,7 @@ function FullscreenAppGate() {
|
||||
}, []);
|
||||
|
||||
if (authFailed) {
|
||||
return signsInForViewLink() ? <App /> : <FullscreenApp />;
|
||||
return <FullscreenApp />;
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
@@ -201,7 +181,6 @@ function App() {
|
||||
// Non-fatal: WebSocket connections will fall back to cookie auth
|
||||
}
|
||||
}
|
||||
if (resumeViewLink()) return;
|
||||
setPhase("loading-app");
|
||||
await Promise.all([
|
||||
startPluginRuntime()
|
||||
@@ -283,10 +262,6 @@ function App() {
|
||||
function handleLogin(u: string) {
|
||||
loggingOutRef.current = false;
|
||||
clearDesktopManualLogout();
|
||||
if (resumeViewLink()) {
|
||||
setPhase("loading-app");
|
||||
return;
|
||||
}
|
||||
setAuthUsername(u);
|
||||
setPhase("loading-app");
|
||||
void (async () => {
|
||||
|
||||
+14
-9
@@ -125,7 +125,6 @@ import type {
|
||||
} from "@/types/ui-types";
|
||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
|
||||
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
@@ -830,11 +829,20 @@ export function AppShell({
|
||||
}
|
||||
}
|
||||
|
||||
// Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
|
||||
const paneDirection = getPaneNavDirection(e);
|
||||
if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
// Alt+Arrow moves between panes of the active split
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
||||
const direction = (
|
||||
{
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
} as Record<string, NavDirection>
|
||||
)[e.code];
|
||||
if (direction && splitActionsRef.current?.navigate(direction)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
|
||||
@@ -3126,9 +3134,6 @@ export function AppShell({
|
||||
{renderSidebarPanels(rightRailView, false)}
|
||||
</DockPanel>
|
||||
)}
|
||||
|
||||
{/* Plugin panels pinned to the right edge, sized by the plugin */}
|
||||
<ComponentSlot slotId="shell.rightEdge" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,10 +20,11 @@ interface OriginResolvableHost {
|
||||
* the desktop-wide default.
|
||||
*
|
||||
* `defaultRemote` makes a host left on Default resolve to "remote" instead of
|
||||
* following the desktop-wide setting. Remote desktop passes it when this
|
||||
* desktop is set to use an external guacd, so a host is not moved onto a
|
||||
* guacd that isn't there (see Termix-SSH/Support#1240). With the built-in
|
||||
* engine it runs locally like SSH.
|
||||
* following the desktop-wide setting. Remote desktop passes it: it needs a
|
||||
* guacd, which the desktop does not ship, so originating it here only works
|
||||
* once the user has pointed Termix at one of their own. Making that opt-in
|
||||
* per host keeps an upgrade from moving working connections onto a guacd
|
||||
* that isn't there -- see Termix-SSH/Support#1240.
|
||||
*/
|
||||
export async function resolveConnectionOrigin(
|
||||
host: OriginResolvableHost,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getBasePath } from "./base-path";
|
||||
import { localBackendPort } from "./electron";
|
||||
|
||||
type BrowserLocation = Pick<
|
||||
Location,
|
||||
@@ -17,8 +16,7 @@ export function getDatabaseTransferUrl(
|
||||
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
|
||||
): string {
|
||||
if (electron) {
|
||||
const serverUrl =
|
||||
configuredServerUrl || `http://localhost:${localBackendPort()}`;
|
||||
const serverUrl = configuredServerUrl || "http://localhost:30001";
|
||||
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ type ElectronWindow = Window &
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: {
|
||||
isElectron?: boolean;
|
||||
backendPort?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,20 +16,3 @@ export function isElectron(): boolean {
|
||||
|
||||
return hasISElectron || hasElectronAPI || isElectronProp;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCAL_BACKEND_PORT = 30001;
|
||||
|
||||
/**
|
||||
* The port of the desktop app's embedded backend. It moves off 30001 when
|
||||
* another program holds that port.
|
||||
*/
|
||||
export function localBackendPort(): number {
|
||||
if (typeof window === "undefined") return DEFAULT_LOCAL_BACKEND_PORT;
|
||||
const port = (window as ElectronWindow).electronAPI?.backendPort;
|
||||
return typeof port === "number" &&
|
||||
Number.isInteger(port) &&
|
||||
port > 0 &&
|
||||
port < 65536
|
||||
? port
|
||||
: DEFAULT_LOCAL_BACKEND_PORT;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { isMacPlatform } from "./tab-jump-hotkey";
|
||||
|
||||
export type PaneNavDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
const ARROWS: Record<string, PaneNavDirection> = {
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
};
|
||||
|
||||
/**
|
||||
* The split pane to move to for this key, if any. Cmd+Option+Arrow on macOS,
|
||||
* where Option+Arrow moves the shell cursor by word, Alt+Arrow elsewhere.
|
||||
*/
|
||||
export function getPaneNavDirection(e: KeyboardEvent): PaneNavDirection | null {
|
||||
const direction = ARROWS[e.code];
|
||||
if (!direction || e.ctrlKey || e.shiftKey || !e.altKey) return null;
|
||||
if (e.metaKey !== isMacPlatform()) return null;
|
||||
return direction;
|
||||
}
|
||||
|
||||
export function paneNavHotkeyLabel(): string {
|
||||
return isMacPlatform() ? "Cmd+Option+Arrows" : "Alt+Arrows";
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
*
|
||||
@@ -21,25 +19,3 @@ export function pluginKey(
|
||||
if (!key) return key;
|
||||
return key.includes(":") ? key : `${pluginId}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's name in the UI language: "plugin.name" from its own locales,
|
||||
* else the manifest's. A plugin whose strings are not loaded, like one only
|
||||
* listed in a registry, shows the manifest name.
|
||||
*/
|
||||
export function pluginName(
|
||||
t: TFunction,
|
||||
plugin: { id: string; name: string },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
|
||||
}
|
||||
|
||||
/** Same as pluginName, for "plugin.description". */
|
||||
export function pluginDescription(
|
||||
t: TFunction,
|
||||
plugin: { id: string; description?: string | null },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.description"), {
|
||||
defaultValue: plugin.description ?? "",
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron, localBackendPort } from "@/lib/electron";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
import {
|
||||
@@ -165,7 +165,7 @@ export async function pluginWsUrl(
|
||||
if (isElectron()) {
|
||||
return buildOriginWsUrl({
|
||||
origin: options.origin ?? "local",
|
||||
localPort: localBackendPort(),
|
||||
localPort: BACKEND_PORT,
|
||||
localPath: route,
|
||||
remotePath: route,
|
||||
});
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Brings a signed-out full-screen `?view=` link back after sign-in. The link
|
||||
* waits in sessionStorage, which survives a redirect login in the same tab,
|
||||
* so no return path is ever sent to the server.
|
||||
*/
|
||||
|
||||
const PENDING_VIEW_LINK_KEY = "termix-pending-view-link";
|
||||
|
||||
/** The value as a same-origin path with a `view` parameter, or null. */
|
||||
export function toViewLinkPath(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, window.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
if (!url.searchParams.get("view")) return null;
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
export function rememberViewLink(): void {
|
||||
const { pathname, search, hash } = window.location;
|
||||
const path = toViewLinkPath(`${pathname}${search}${hash}`);
|
||||
if (!path) return;
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_VIEW_LINK_KEY, path);
|
||||
} catch {
|
||||
// Without storage the user just lands on the app after signing in.
|
||||
}
|
||||
}
|
||||
|
||||
/** The remembered link, cleared whether or not it is still valid. */
|
||||
export function consumeViewLink(): string | null {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(PENDING_VIEW_LINK_KEY);
|
||||
sessionStorage.removeItem(PENDING_VIEW_LINK_KEY);
|
||||
return toViewLinkPath(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the remembered link, if there is one. The navigation waits a tick so
|
||||
* history updates the login callback makes right after sign-in cannot cancel
|
||||
* it.
|
||||
*/
|
||||
export function resumeViewLink(): boolean {
|
||||
const path = consumeViewLink();
|
||||
if (!path) return false;
|
||||
setTimeout(() => window.location.replace(path), 0);
|
||||
return true;
|
||||
}
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Klik om uit/in te vou",
|
||||
"openOnDoubleClick": "Maak oop met dubbelklik",
|
||||
"openOnDoubleClickDesc": "Vereis 'n dubbelklik om 'n gasheerbediener-sessie oop te maak in plaas van 'n enkele klik",
|
||||
"closeOnOpen": "Versteek sybalk na oopmaak",
|
||||
"closeOnOpenDesc": "Maak die sybalk toe nadat jy 'n gasheer daaruit oopgemaak het",
|
||||
"showFolderPaths": "Wys gidsroetes",
|
||||
"showFolderPathsDesc": "Wys die ouergidsroete bo ingeslote subgidsname. Subgidse bly ingespring in beide gevalle.",
|
||||
"rowDetailsTitle": "Rybesonderhede",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Aanvaar Nuwe Sleutel & Gaan Voort"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Die Termix-bedienaar reageer nie",
|
||||
"databaseConnection": "Kon nie met die databasis verbind nie",
|
||||
"embeddedServerFailed": "Termix se plaaslike bediener kon nie begin nie",
|
||||
"unknownError": "Onbekende fout",
|
||||
"syncedHostMissing": "Die gesinkroniseerde gasheer bestaan nie op die gekoppelde bediener nie",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Jy het nog nie 'n Termix-rekening nie. Vra 'n admin om Eksterne rekenings outomaties skep aan te skakel.",
|
||||
"userNotAllowed": "Jou rekening is nie gemagtig om te registreer nie. Kontak asseblief 'n administrateur.",
|
||||
"databaseConnectionFailed": "Dit is dalk nog besig om te begin, of dit het gestop. Hierdie bladsy gaan voortdurend probeer en sal outomaties hervat sodra die bedienaar weer beskikbaar is.",
|
||||
"databaseConnectionFailed": "Kon nie met die databasisbediener verbind nie",
|
||||
"embeddedServerPortInUse": "Poort {{port}} word reeds deur 'n ander program gebruik. Dit is gewoonlik 'n Termix Docker-houer of 'n oorblywende Termix-proses van 'n vorige sessie. Maak dit toe en herbegin dan Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Een van die poorte wat Termix benodig, word reeds deur 'n ander program gebruik. Maak wat dit gebruik toe en herbegin dan Termix.",
|
||||
"embeddedServerOldDatabase": "U plaaslike data is van Termix 2.8 of ouer en moet eers deur 2.9 gaan. Installeer Termix 2.9.1, open dit een keer sodat dit u data kan verskuif, en dateer dan weer na hierdie weergawe op.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "Hou die linkersybalke-toepingsbalk altyd uitgebrei in plaas daarvan om dit slegs by hover uit te brei",
|
||||
"expandAppRailOnHover": "Brei toepingsbalk uit by hover",
|
||||
"expandAppRailOnHoverDesc": "Laat die linkersybalke-toepingsbalk uitbrei wanneer die aanwyser daaroor beweeg",
|
||||
"appRailHoverPushesContent": "Druk inhoud uit wanneer dit uitbrei",
|
||||
"appRailHoverPushesContentDesc": "Maak plek vir die uitgebreide spoor in plaas daarvan om dit oor die bladsy oop te maak. Oop sessies, soos afgeleë bureblad, word elke keer herskaal",
|
||||
"showPinAppRailButton": "Wys speld-toepingsbalk-knoppie",
|
||||
"showPinAppRailButtonDesc": "Wys 'n knoppie in die toepingsbalk om dit oop te speld. Standaard af.",
|
||||
"settingsNavigation": "Navigasie",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Buro-sinkronisering"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Aktiveer {{name}}?",
|
||||
"installTitle": "Installeer {{name}}",
|
||||
"updateTitle": "Dateer {{name}} op",
|
||||
"agreeUpdate": "Stem saam en dateer op",
|
||||
|
||||
@@ -668,8 +668,6 @@
|
||||
"actionsClick": "انقر للتوسيع/الطي",
|
||||
"openOnDoubleClick": "الفتح عند النقر المزدوج",
|
||||
"openOnDoubleClickDesc": "تطلب نقرًا مزدوجًا لفتح جلسة الجهاز بدلاً من نقر واحد",
|
||||
"closeOnOpen": "إخفاء الشريط الجانبي بعد الفتح",
|
||||
"closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه",
|
||||
"showFolderPaths": "إظهار مسارات المجلدات",
|
||||
"showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.",
|
||||
"rowDetailsTitle": "تفاصيل الصف",
|
||||
@@ -1155,7 +1153,7 @@
|
||||
"acceptNewKey": "قبول المفتاح الجديد ومتابعة"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "خادم Termix لا يستجيب",
|
||||
"databaseConnection": "تعذر الاتصال بقاعدة البيانات",
|
||||
"embeddedServerFailed": "تعذر بدء الخادم المحلي لـ Termix",
|
||||
"unknownError": "خطأ غير معروف",
|
||||
"syncedHostMissing": "المضيف المتزامن غير موجود على الخادم المرتبط",
|
||||
@@ -1182,7 +1180,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "ليس لديك حساب Termix بعد. اطلب من المسؤول تفعيل إنشاء الحسابات الخارجية تلقائيًا.",
|
||||
"userNotAllowed": "حسابك غير مصرح له بالتسجيل. يرجى الاتصال بمسؤول النظام.",
|
||||
"databaseConnectionFailed": "قد لا يزال قيد التشغيل أو توقف. تستمر هذه الصفحة في المحاولة وستعمل تلقائياً بمجرد عودة الخادم.",
|
||||
"databaseConnectionFailed": "فشل في الاتصال بخادم قاعدة البيانات",
|
||||
"embeddedServerPortInUse": "المنفذ {{port}} مستخدم بالفعل بواسطة برنامج آخر. عادةً ما يكون هذا حاوية Docker لـ Termix أو عملية متبقية من Termix من جلسة سابقة. أغلقها، ثم أعد تشغيل Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "أحد المنافذ التي يحتاجها Termix مستخدم بالفعل بواسطة برنامج آخر. أغلق ما يستخدمه، ثم أعد تشغيل Termix.",
|
||||
"embeddedServerOldDatabase": "بياناتك المحلية من إصدار Termix 2.8 أو أقدم، ويجب أولاً تحديثها عبر الإصدار 2.9. قم بتثبيت Termix 2.9.1، وافتحه مرة واحدة للسماح بنقل بياناتك، ثم قم بالتحديث إلى هذا الإصدار مرة أخرى.",
|
||||
@@ -1766,8 +1764,6 @@
|
||||
"pinAppRailDesc": "أبقِ شريط التطبيقات الجانبي الأيسر موسعًا دائمًا بدلاً من توسيعه عند التمرير فوقه",
|
||||
"expandAppRailOnHover": "توسيع شريط التطبيقات عند التمرير",
|
||||
"expandAppRailOnHoverDesc": "اسمح لشريط التطبيقات الجانبي الأيسر بالتوسع عند تحريك المؤشر فوقه",
|
||||
"appRailHoverPushesContent": "دفع المحتوى عند التوسيع",
|
||||
"appRailHoverPushesContentDesc": "توفير مساحة للشريط الموسع بدلاً من فتحه فوق الصفحة. سيتم تغيير حجم الجلسات المفتوحة، مثل سطح المكتب عن بُعد، في كل مرة",
|
||||
"showPinAppRailButton": "إظهار زر تثبيت شريط التطبيقات",
|
||||
"showPinAppRailButtonDesc": "اعرض زرًا في شريط التطبيقات لتثبيته مفتوحًا. معطّل افتراضيًا.",
|
||||
"settingsNavigation": "التنقل",
|
||||
@@ -2111,7 +2107,6 @@
|
||||
"syncEntities_other": "مزامنة سطح المكتب"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "تفعيل {{name}}؟",
|
||||
"installTitle": "تثبيت {{name}}",
|
||||
"updateTitle": "تحديث {{name}}",
|
||||
"agreeUpdate": "موافقة وتحديث",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Кликване за разгъване/свиване",
|
||||
"openOnDoubleClick": "Отваряне при двойно кликване",
|
||||
"openOnDoubleClickDesc": "Изискване на двойно кликване за отваряне на сесия на хост вместо едно кликване",
|
||||
"closeOnOpen": "Скрий страничната лента след отваряне",
|
||||
"closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея",
|
||||
"showFolderPaths": "Показване на пътищата до папките",
|
||||
"showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.",
|
||||
"rowDetailsTitle": "Детайли на реда",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Приеми новия ключ и продължи"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Сървърът на Termix не отговаря",
|
||||
"databaseConnection": "Неуспешно свързване с базата данни",
|
||||
"embeddedServerFailed": "Локалният сървър на Termix не може да се стартира",
|
||||
"unknownError": "Неизвестна грешка",
|
||||
"syncedHostMissing": "Синхронизианият хост не съществува на свързания сървър",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Все още нямате Termix акаунт. Попитайте администратор да активира автоматичното създаване на външни акаунти.",
|
||||
"userNotAllowed": "Вашият акаунт не е упълномощен за регистрация. Моля, свържете се с администратор.",
|
||||
"databaseConnectionFailed": "Възможно е все още да се стартира или да е спрял. Тази страница продължава да опитва и ще се възстанови автоматично, когато сървърът е отново наличен.",
|
||||
"databaseConnectionFailed": "Неуспешно свързване със сървъра на базата данни",
|
||||
"embeddedServerPortInUse": "Порт {{port}} вече се използва от друга програма. Обикновено това е Termix Docker контейнер или остатъчен Termix процес от предишна сесия. Затворете го и рестартирайте Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Един от портовете, нужни на Termix, вече се използва от друга програма. Затворете програмата, която го използва, и рестартирайте Termix.",
|
||||
"embeddedServerOldDatabase": "Вашите локални данни са от Termix 2.8 или по-стара версия и трябва първо да преминат през версия 2.9. Инсталирайте Termix 2.9.1, отворете го веднъж, за да може да прехвърли данните ви, след което актуализирайте отново до тази версия.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "Държане на лявата лента с приложения винаги разгъната, вместо да се разгъва при посочване",
|
||||
"expandAppRailOnHover": "Разгъване на лентата с приложения при посочване",
|
||||
"expandAppRailOnHoverDesc": "Разрешаване на разгъване на лявата лента с приложения, когато курсорът се премести върху нея",
|
||||
"appRailHoverPushesContent": "Избутвай съдържанието при разгъване",
|
||||
"appRailHoverPushesContentDesc": "Остави място за разгънатата лента вместо да я отваряш върху страницата. Отворените сесии, като отдалечен плот, ще се преоразмеряват всеки път",
|
||||
"showPinAppRailButton": "Показване на бутона за закачане на лентата с приложения",
|
||||
"showPinAppRailButtonDesc": "Показване на бутон в лентата с приложения за нейното закачване. Изключено по подразбиране.",
|
||||
"settingsNavigation": "Навигация",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Синхронизиране на работния плот"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Активиране на {{name}}?",
|
||||
"installTitle": "Инсталиране на {{name}}",
|
||||
"updateTitle": "Актуализиране на {{name}}",
|
||||
"agreeUpdate": "Съгласявам се и актуализирам",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "প্রসারিত/সংকুচিত করতে ক্লিক করুন",
|
||||
"openOnDoubleClick": "ডাবল ক্লিকে খুলুন",
|
||||
"openOnDoubleClickDesc": "একক ক্লিকের পরিবর্তে হোস্ট সেশন খুলতে ডাবল ক্লিক প্রয়োজন করুন",
|
||||
"closeOnOpen": "খোলা পর সাইডবার লুকান",
|
||||
"closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন",
|
||||
"showFolderPaths": "ফোল্ডার পথ দেখান",
|
||||
"showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।",
|
||||
"rowDetailsTitle": "সারির বিবরণ",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "নতুন কী গ্রহণ করুন ও চালিয়ে যান"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix সার্ভার কোনো প্রতিক্রিয়া দিচ্ছে না",
|
||||
"databaseConnection": "ডেটাবেসের সাথে সংযোগ স্থাপন করা যায়নি",
|
||||
"embeddedServerFailed": "টার্মিক্সের স্থানীয় সার্ভার শুরু করতে পারেনি",
|
||||
"unknownError": "অজানা ত্রুটি",
|
||||
"syncedHostMissing": "সিঙ্ক করা হোস্টটি লিঙ্ক করা সার্ভারে বিদ্যমান নেই",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "আপনার এখনো কোনো Termix অ্যাকাউন্ট নেই। একজন প্রশাসককে 'Auto-create external accounts' চালু করতে বলুন।",
|
||||
"userNotAllowed": "রেজিস্ট্রেশনের জন্য আপনার অ্যাকাউন্ট অনুমোদিত নয়। অনুগ্রহ করে একজন প্রশাসকের সাথে যোগাযোগ করুন।",
|
||||
"databaseConnectionFailed": "এটি এখনও শুরু হতে পারে, অথবা থেমে গেছে। এই পৃষ্ঠাটি বারবার চেষ্টা করে এবং সার্ভার ফিরে আসলে স্বয়ংক্রিয়ভাবে চালু হয়।",
|
||||
"databaseConnectionFailed": "ডেটাবেস সার্ভারে সংযোগ করতে ব্যর্থ হয়েছে",
|
||||
"embeddedServerPortInUse": "{{port}} পোর্টটি ইতিমধ্যে অন্য একটি প্রোগ্রাম দ্বারা ব্যবহৃত হচ্ছে। এটি সাধারণত একটি Termix Docker কন্টেইনার বা আগের সেশনের একটি অবশিষ্ট Termix প্রক্রিয়া হয়। এটি বন্ধ করুন, তারপর Termix পুনরায় চালু করুন।",
|
||||
"embeddedServerPortInUseUnknownPort": "Termix-এর প্রয়োজনীয় পোর্টগুলোর একটি ইতিমধ্যে অন্য একটি প্রোগ্রাম দ্বারা ব্যবহৃত হচ্ছে। যা তা ব্যবহার করছে তা বন্ধ করুন, তারপর Termix পুনরায় চালু করুন।",
|
||||
"embeddedServerOldDatabase": "আপনার স্থানীয় ডেটাটি Termix 2.8 বা তার পূর্ববর্তী সংস্করণের এবং এটি প্রথমে 2.9 সংস্করণের মধ্য দিয়ে যেতে হবে। Termix 2.9.1 ইনস্টল করুন, এটি একবার ওপেন করুন যাতে এটি আপনার ডেটা স্থানান্তর করতে পারে, তারপর আবার এই সংস্করণে আপডেট করুন।",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "হোভার করলে প্রসারিত হওয়ার পরিবর্তে বাম সাইডবারের অ্যাপ রেল সবসময় প্রসারিত রাখুন",
|
||||
"expandAppRailOnHover": "হোভার করলে অ্যাপ রেল প্রসারিত করুন",
|
||||
"expandAppRailOnHoverDesc": "পয়েন্টার এর উপর দিয়ে চলাচল করলে বাম সাইডবারের অ্যাপ রেল প্রসারিত হতে দিন",
|
||||
"appRailHoverPushesContent": "বিস্তারিত হলে কন্টেন্ট সরান",
|
||||
"appRailHoverPushesContentDesc": "পৃষ্ঠার উপরে না খুলে বিস্তৃত রেলের জন্য জায়গা তৈরি করুন। রিমোট ডেস্কটপের মতো খোলা সেশনগুলো প্রতিবার আকার পরিবর্তন করে",
|
||||
"showPinAppRailButton": "অ্যাপ রেল পিন বাটন দেখান",
|
||||
"showPinAppRailButtonDesc": "এটি খোলা রাখতে অ্যাপ রেলের মধ্যে একটি বাটন দেখান। ডিফল্টভাবে বন্ধ থাকে।",
|
||||
"settingsNavigation": "নেভিগেশন",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "ডেস্কটপ সিঙ্ক"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "{{name}} সক্রিয় করবেন?",
|
||||
"installTitle": "{{name}} ইনস্টল করুন",
|
||||
"updateTitle": "{{name}} আপডেট করুন",
|
||||
"agreeUpdate": "সম্মতি দিন এবং আপডেট করুন",
|
||||
|
||||
@@ -650,8 +650,6 @@
|
||||
"actionsClick": "Fes clic per expandir/reduir",
|
||||
"openOnDoubleClick": "Obre en fer doble clic",
|
||||
"openOnDoubleClickDesc": "Requereix un doble clic per obrir una sessió d'amfitrió en lloc d'un sol clic",
|
||||
"closeOnOpen": "Amaga la barra lateral en obrir",
|
||||
"closeOnOpenDesc": "Tanca la barra lateral després d'obrir un amfitrió des d'ella",
|
||||
"showFolderPaths": "Mostra les rutes de les carpetes",
|
||||
"showFolderPathsDesc": "Mostra la ruta de la carpeta principal sobre els noms de les subcarpetes anidades. Les subcarpetes es mantenen amb sagnat en qualsevol cas.",
|
||||
"rowDetailsTitle": "Detalls de la fila",
|
||||
@@ -1113,7 +1111,7 @@
|
||||
"acceptNewKey": "Accepta la clau nova i continua"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "El servidor de Termix no respon",
|
||||
"databaseConnection": "No s'ha pogut connectar a la base de dades",
|
||||
"embeddedServerFailed": "El servidor local de Termix no ha pogut iniciar-se",
|
||||
"unknownError": "Error desconegut",
|
||||
"syncedHostMissing": "L'amfitrió sincronitzat no existeix al servidor enllaçat",
|
||||
@@ -1140,7 +1138,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Encara no tens un compte de Termix. Demana a un administrador que activi la creació automàtica de comptes externs.",
|
||||
"userNotAllowed": "El teu compte no està autoritzat per registrar-se. Si us plau, contacta amb un administrador.",
|
||||
"databaseConnectionFailed": "Potser encara s'està iniciant o s'ha aturat. Aquesta pàgina continuarà intentant-ho i es recuperarà automàticament quan el servidor torni a estar disponible.",
|
||||
"databaseConnectionFailed": "No s'ha pogut connectar al servidor de bases de dades",
|
||||
"embeddedServerPortInUse": "El port {{port}} ja està en ús per un altre programa. Normalment és un contenidor Docker de Termix o un procés de Termix d'una sessió anterior. Tanca'l i reinicia Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Un dels ports que necessita Termix ja està en ús per un altre programa. Tanca el que l'està utilitzant i reinicia Termix.",
|
||||
"embeddedServerOldDatabase": "Les dades locals són de Termix 2.8 o anterior i han de passar primer per la versió 2.9. Instal·la Termix 2.9.1, obre-la un cop perquè pugui moure les dades i, a continuació, actualitza a aquesta versió.",
|
||||
@@ -1697,8 +1695,6 @@
|
||||
"pinAppRailDesc": "Mantén la barra d'aplicacions de la barra lateral esquerra sempre ampliada en lloc d'ampliar-la en passar el cursor per sobre",
|
||||
"expandAppRailOnHover": "Amplia la barra d'aplicacions en passar el cursor",
|
||||
"expandAppRailOnHoverDesc": "Permet que la barra d'aplicacions de la barra lateral esquerra s'ampliï quan el cursor passi per sobre",
|
||||
"appRailHoverPushesContent": "Desplaça el contingut en expandir-se",
|
||||
"appRailHoverPushesContentDesc": "Deixa espai per a la barra lateral expandida en lloc d'obrir-la sobre la pàgina. Les sessions obertes, com l'escriptori remot, es redimensionen cada vegada.",
|
||||
"showPinAppRailButton": "Mostra el botó per fixar la barra d'aplicacions",
|
||||
"showPinAppRailButtonDesc": "Mostra un botó a la barra d'aplicacions per fixar-la oberta. Desactivat per defecte.",
|
||||
"settingsNavigation": "Navegació",
|
||||
@@ -2006,7 +2002,6 @@
|
||||
"syncEntities_other": "Sincronització d'escriptori"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Vols habilitar {{name}}?",
|
||||
"installTitle": "Instal·lar {{name}}",
|
||||
"updateTitle": "Actualitzar {{name}}",
|
||||
"agreeUpdate": "Acceptar i actualitzar",
|
||||
|
||||
@@ -656,8 +656,6 @@
|
||||
"actionsClick": "Kliknutím rozbalit/sbalit",
|
||||
"openOnDoubleClick": "Otevřít dvojklikem",
|
||||
"openOnDoubleClickDesc": "Požadovat dvojklik pro otevření relace hosta místo jednokliku",
|
||||
"closeOnOpen": "Skrýt postranní panel po otevření",
|
||||
"closeOnOpenDesc": "Zavřít postranní panel po otevření hostitele z něj",
|
||||
"showFolderPaths": "Zobrazit cesty složek",
|
||||
"showFolderPathsDesc": "Zobrazit cestu nadřazené složky nad jmény vnořených podsložek. Podsložky zůstanou v obou případech odsazené.",
|
||||
"rowDetailsTitle": "Detaily řádku",
|
||||
@@ -1127,7 +1125,7 @@
|
||||
"acceptNewKey": "Přijmout nový klíč a pokračovat"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Server Termix neodpovídá",
|
||||
"databaseConnection": "Nelze se připojit k databázi",
|
||||
"embeddedServerFailed": "Lokální server Termix se nepodařilo spustit",
|
||||
"unknownError": "Neznámá chyba",
|
||||
"syncedHostMissing": "Synchronizovaný hostitel neexistuje na propojeném serveru",
|
||||
@@ -1154,7 +1152,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Zatím nemáte účet Termix. Požádejte administrátora, aby zapnul možnost Automatické vytváření externích účtů.",
|
||||
"userNotAllowed": "Váš účet není oprávněn k registraci. Kontaktujte prosím administrátora.",
|
||||
"databaseConnectionFailed": "Server se možná stále spouští, nebo byl zastaven. Tato stránka se bude neustále pokoušet o připojení a automaticky se obnoví, jakmile bude server znovu dostupný.",
|
||||
"databaseConnectionFailed": "Nepodařilo se připojit k databázovému serveru",
|
||||
"embeddedServerPortInUse": "Port {{port}} je již používán jiným programem. Obvykle se jedná o Docker kontejner Termix nebo zbytkový proces Termix z předchozí relace. Zavřete jej a poté restartujte Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Jeden z portů, které Termix potřebuje, je již používán jiným programem. Zavřete aplikaci, která jej používá, a poté restartujte Termix.",
|
||||
"embeddedServerOldDatabase": "Vaše místní data pocházejí z Termix 2.8 nebo starší verze a musí být nejprve převedena verzí 2.9. Nainstalujte Termix 2.9.1, otevřete jej jednou, aby mohl přesunout vaše data, a poté aktualizujte znovu na tuto verzi.",
|
||||
@@ -1720,8 +1718,6 @@
|
||||
"pinAppRailDesc": "Nechat levý postranní panel aplikací vždy rozbalený místo rozbalení při najetí myší",
|
||||
"expandAppRailOnHover": "Rozbalit panel aplikací při najetí myší",
|
||||
"expandAppRailOnHoverDesc": "Povolit rozbalení levého postranního panelu aplikací při najetí ukazatelem",
|
||||
"appRailHoverPushesContent": "Posunout obsah při rozbalení",
|
||||
"appRailHoverPushesContentDesc": "Uvolnit místo pro rozbalený panel místo překrytí stránky. Otevřené relace, jako je vzdálená plocha, se při každém rozbalení přizpůsobí velikosti.",
|
||||
"showPinAppRailButton": "Zobrazit tlačítko pro připnutí panelu aplikací",
|
||||
"showPinAppRailButtonDesc": "Zobrazí tlačítko v panelu aplikací pro jeho připnutí. Ve výchozím nastavení vypnuto.",
|
||||
"settingsNavigation": "Navigace",
|
||||
@@ -2041,7 +2037,6 @@
|
||||
"syncEntities_other": "Synchronizace plochy"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Povolit {{name}}?",
|
||||
"installTitle": "Nainstalovat {{name}}",
|
||||
"updateTitle": "Aktualizovat {{name}}",
|
||||
"agreeUpdate": "Souhlasit a aktualizovat",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Klik for at udvide/skjule",
|
||||
"openOnDoubleClick": "Åbn ved dobbeltklik",
|
||||
"openOnDoubleClickDesc": "Kræv et dobbeltklik for at åbne en værtssession i stedet for et enkelt klik",
|
||||
"closeOnOpen": "Skjul sidebjælken efter åbning",
|
||||
"closeOnOpenDesc": "Luk sidebjælken, når du åbner en vært fra den",
|
||||
"showFolderPaths": "Vis mappesstier",
|
||||
"showFolderPathsDesc": "Vis overordnede mappesstier over navne på indlejrede undermapper. Undermapper forbliver indrykket uanset hvad.",
|
||||
"rowDetailsTitle": "Radedetaljer",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Accepter ny nøgle & fortsæt"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix-serveren svarer ikke",
|
||||
"databaseConnection": "Kunne ikke oprette forbindelse til databasen",
|
||||
"embeddedServerFailed": "Termix' lokale server kunne ikke starte",
|
||||
"unknownError": "Ukendt fejl",
|
||||
"syncedHostMissing": "Den synkroniserede vært findes ikke på den linkede server",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Du har ikke en Termix-konto endnu. Bed en administrator om at slå 'Auto-create external accounts' til.",
|
||||
"userNotAllowed": "Din konto er ikke autoriseret til at registrere. Kontakt venligst en administrator.",
|
||||
"databaseConnectionFailed": "Den starter muligvis stadig, eller den er stoppet. Denne side fortsætter med at prøve og genoptager automatisk, når serveren er tilbage.",
|
||||
"databaseConnectionFailed": "Kunne ikke oprette forbindelse til databasens server",
|
||||
"embeddedServerPortInUse": "Port {{port}} er allerede i brug af et andet program. Dette er typisk en Termix Docker-container eller en efterladt Termix-proces fra en tidligere session. Luk den, og genstart derefter Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "En af de porte, som Termix har brug for, er allerede i brug af et andet program. Luk det, der bruger den, og genstart derefter Termix.",
|
||||
"embeddedServerOldDatabase": "Dine lokale data stammer fra Termix 2.8 eller ældre og skal først migreres gennem version 2.9. Installer Termix 2.9.1, åbn den én gang, så den kan flytte dine data, og opdater derefter til denne version igen.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "Hold venstre app-stolpe altid udvidet i stedet for at udvide den ved hover",
|
||||
"expandAppRailOnHover": "Udvid app-stolpe ved hover",
|
||||
"expandAppRailOnHoverDesc": "Tillad, at venstre app-stolpe udvides, når markøren bevæger sig over den",
|
||||
"appRailHoverPushesContent": "Skub indholdet, når den udvides",
|
||||
"appRailHoverPushesContentDesc": "Gør plads til den udvidede sidebjælke i stedet for at åbne den over siden. Åbne sessioner, som f.eks. fjernskrivebord, ændrer størrelse hver gang",
|
||||
"showPinAppRailButton": "Vis knap til fastgørelse af app-stolpe",
|
||||
"showPinAppRailButtonDesc": "Vis en knap i app-stolpen til at fastgøre den åben. Slukket som standard.",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Skrivebords-synkronisering"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Aktiver {{name}}?",
|
||||
"installTitle": "Installer {{name}}",
|
||||
"updateTitle": "Opdater {{name}}",
|
||||
"agreeUpdate": "Accepter og opdater",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Zum Erweitern/Zuklappen klicken",
|
||||
"openOnDoubleClick": "Beim Doppelklick öffnen",
|
||||
"openOnDoubleClickDesc": "Erfordert einen Doppelklick zum Öffnen einer Host-Sitzung statt eines einzelnen Klicks",
|
||||
"closeOnOpen": "Seitenleiste nach dem Öffnen ausblenden",
|
||||
"closeOnOpenDesc": "Schließe die Seitenleiste, nachdem du einen Host daraus geöffnet hast",
|
||||
"showFolderPaths": "Verzeichnispfade anzeigen",
|
||||
"showFolderPathsDesc": "Zeigt den Pfad des übergeordneten Verzeichnisses über verschachtelten Unterverzeichnisnamen an. Unterverzeichnisse bleiben in jedem Fall eingerückt.",
|
||||
"rowDetailsTitle": "Zeilen-Details",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Neuen Schlüssel akzeptieren & Fortfahren"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Der Termix-Server antwortet nicht",
|
||||
"databaseConnection": "Verbindung zur Datenbank fehlgeschlagen",
|
||||
"embeddedServerFailed": "Der lokale Server von Termix konnte nicht gestartet werden",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"syncedHostMissing": "Der synchronisierte Host existiert nicht auf dem verknüpften Server",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Sie haben noch kein Termix-Konto. Bitten Sie einen Administrator, die automatische Erstellung externer Konten zu aktivieren.",
|
||||
"userNotAllowed": "Ihr Konto ist nicht zur Registrierung berechtigt. Bitte wenden Sie sich an einen Administrator.",
|
||||
"databaseConnectionFailed": "Er startet möglicherweise noch oder wurde gestoppt. Diese Seite versucht es weiterhin und geht automatisch weiter, sobald der Server wieder verfügbar ist.",
|
||||
"databaseConnectionFailed": "Verbindung zum Datenbankserver fehlgeschlagen",
|
||||
"embeddedServerPortInUse": "Der Port {{port}} wird bereits von einem anderen Programm verwendet. Dies ist in der Regel ein Termix-Docker-Container oder ein verbliebener Termix-Prozess aus einer früheren Sitzung. Schließen Sie dieses Programm und starten Sie Termix neu.",
|
||||
"embeddedServerPortInUseUnknownPort": "Einer der von Termix benötigten Ports wird bereits von einem anderen Programm verwendet. Schließen Sie das entsprechende Programm und starten Sie Termix neu.",
|
||||
"embeddedServerOldDatabase": "Ihre lokalen Daten stammen von Termix 2.8 oder älter und müssen zuerst durch Version 2.9 migriert werden. Installieren Sie Termix 2.9.1, öffnen Sie es einmal, damit Ihre Daten verschoben werden können, und aktualisieren Sie anschließend erneut auf diese Version.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "Halten Sie die linke App-Leiste der Seitenleiste immer erweitert, anstatt sie beim Hovern zu erweitern",
|
||||
"expandAppRailOnHover": "App-Leiste beim Hovern erweitern",
|
||||
"expandAppRailOnHoverDesc": "Erlauben Sie der linken App-Leiste der Seitenleiste, sich zu erweitern, wenn der Mauszeiger darüber bewegt wird",
|
||||
"appRailHoverPushesContent": "Inhalt verschieben, wenn sie sich ausdehnt",
|
||||
"appRailHoverPushesContentDesc": "Platz für die ausgeklappte Leiste schaffen, anstatt sie über die Seite zu legen. Offene Sitzungen, wie Remote Desktop, werden jedes Mal neu angepasst",
|
||||
"showPinAppRailButton": "Schaltfläche zum Anheften der App-Leiste anzeigen",
|
||||
"showPinAppRailButtonDesc": "Zeigt eine Schaltfläche in der App-Leiste zum Anheften an. Standardmäßig deaktiviert.",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Desktop-Synchronisierung"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "{{name}} aktivieren?",
|
||||
"installTitle": "{{name}} installieren",
|
||||
"updateTitle": "{{name}} aktualisieren",
|
||||
"agreeUpdate": "Zustimmen und aktualisieren",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Κλικ για ανάπτυξη/σύμπτυξη",
|
||||
"openOnDoubleClick": "Άνοιγμα με διπλό κλικ",
|
||||
"openOnDoubleClickDesc": "Απαιτείται διπλό κλικ για να ανοίξει μια συνεδρία host αντί για μονό κλικ",
|
||||
"closeOnOpen": "Απόκρυψη πλευρικής γραμμής μετά το άνοιγμα",
|
||||
"closeOnOpenDesc": "Κλείσιμο της πλευρικής γραμμής μετά το άνοιγμα ενός host από αυτήν",
|
||||
"showFolderPaths": "Εμφάνιση διαδρομών φακέλων",
|
||||
"showFolderPathsDesc": "Εμφάνιση της διαδρομής του γονικού φακέλου πάνω από τα ονόματα των υποφακέλων. Οι υποφάκελοι παραμένουν εσοχή σε κάθε περίπτωση.",
|
||||
"rowDetailsTitle": "Λεπτομέρειες γραμμής",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Αποδοχή νέου κλειδιού & Συνέχεια"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Ο διακομιστής Termix δεν ανταποκρίνεται",
|
||||
"databaseConnection": "Αδυναμία σύνδεσης με τη βάση δεδομένων",
|
||||
"embeddedServerFailed": "Ο τοπικός διακομιστής του Termix δεν μπόρεσε να ξεκινήσει",
|
||||
"unknownError": "Άγνωστο σφάλμα",
|
||||
"syncedHostMissing": "Ο συγχρονισμένος διακομιστής δεν υπάρχει στον συνδεδεμένο διακομιστή",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Δεν έχετε ακόμη λογαριασμό Termix. Ζητήστε από έναν διαχειριστή να ενεργοποιήσει τη δημιουργία εξωτερικών λογαριασμών αυτόματα.",
|
||||
"userNotAllowed": "Ο λογαριασμός σας δεν είναι εξουσιοδοτημένος για εγγραφή. Παρακαλώ επικοινωνήστε με έναν διαχειριστή.",
|
||||
"databaseConnectionFailed": "Ίσως να ξεκινά ακόμη ή να έχει σταματήσει. Αυτή η σελίδα συνεχίζει να προσπαθεί και θα επανέλθει αυτόματα μόλις ο διακομιστής είναι ξανά διαθέσιμος.",
|
||||
"databaseConnectionFailed": "Αποτυχία σύνδεσης με τον διακομιστή βάσης δεδομένων",
|
||||
"embeddedServerPortInUse": "Η θύρα {{port}} χρησιμοποιείται ήδη από ένα άλλο πρόγραμμα. Συνήθως πρόκειται για ένα Docker container του Termix ή μια διαδικασία του Termix από προηγούμενη συνεδρία. Κλείστε το και επανεκκινήστε το Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Μία από τις θύρες που χρειάζεται το Termix χρησιμοποιείται ήδη από ένα άλλο πρόγραμμα. Κλείστε ό,τι τη χρησιμοποιεί και επανεκκινήστε το Termix.",
|
||||
"embeddedServerOldDatabase": "Τα τοπικά σας δεδομένα προέρχονται από την έκδοση Termix 2.8 ή παλαιότερη και πρέπει πρώτα να περάσουν από την έκδοση 2.9. Εγκαταστήστε την Termix 2.9.1, ανοίξτε την μία φορά ώστε να μετακινηθούν τα δεδομένα σας και στη συνέχεια ενημερώστε ξανά σε αυτή την έκδοση.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "Διατήρηση της αριστερής ράγας εφαρμογών πάντα αναπτυγμένη αντί να αναπτύσσεται με hover",
|
||||
"expandAppRailOnHover": "Ανάπτυξη Ράγας Εφαρμογών με Hover",
|
||||
"expandAppRailOnHoverDesc": "Επιτρέψτε στην αριστερή ράγα εφαρμογών να αναπτύσσεται όταν ο δείκτης περνά από πάνω της",
|
||||
"appRailHoverPushesContent": "Μετακίνηση περιεχομένου κατά την επέκταση",
|
||||
"appRailHoverPushesContentDesc": "Δημιουργία χώρου για την επεκταμένη γραμμή αντί να ανοίγει πάνω από τη σελίδα. Οι ανοιχτές συνεδρίες, όπως το απομακρυσμένο γραφείο, αλλάζουν μέγεθος κάθε φορά",
|
||||
"showPinAppRailButton": "Εμφάνιση Κουμπιού Καρφίωματος Ράγας Εφαρμογών",
|
||||
"showPinAppRailButtonDesc": "Εμφάνιση κουμπιού στη ράγα εφαρμογών για να την κρατά ανοιχτή. Ανενεργό από προεπιλογή.",
|
||||
"settingsNavigation": "Πλοήγηση",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Συγχρονισμός επιφάνειας εργασίας"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Ενεργοποίηση του {{name}};",
|
||||
"installTitle": "Εγκατάσταση {{name}}",
|
||||
"updateTitle": "Ενημέρωση {{name}}",
|
||||
"agreeUpdate": "Συμφωνώ και ενημερώνω",
|
||||
|
||||
@@ -650,8 +650,6 @@
|
||||
"actionsClick": "Hacer clic para expandir/contraer",
|
||||
"openOnDoubleClick": "Abrir al hacer doble clic",
|
||||
"openOnDoubleClickDesc": "Requiere un doble clic para abrir una sesión de host en lugar de un solo clic",
|
||||
"closeOnOpen": "Ocultar la barra lateral al abrir",
|
||||
"closeOnOpenDesc": "Cerrar la barra lateral después de abrir un host desde ella",
|
||||
"showFolderPaths": "Mostrar rutas de carpetas",
|
||||
"showFolderPathsDesc": "Muestra la ruta de la carpeta principal encima de los nombres de las subcarpetas anidadas. Las subcarpetas siguen estando sangradas de todos modos.",
|
||||
"rowDetailsTitle": "Detalles de la fila",
|
||||
@@ -1113,7 +1111,7 @@
|
||||
"acceptNewKey": "Aceptar nueva clave y continuar"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "El servidor de Termix no responde",
|
||||
"databaseConnection": "No se pudo conectar a la base de datos",
|
||||
"embeddedServerFailed": "El servidor local de Termix no pudo iniciarse",
|
||||
"unknownError": "Error desconocido",
|
||||
"syncedHostMissing": "El host sincronizado no existe en el servidor vinculado",
|
||||
@@ -1140,7 +1138,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Aún no tienes una cuenta de Termix. Pide a un administrador que active la opción «Crear cuentas externas automáticamente».",
|
||||
"userNotAllowed": "Tu cuenta no está autorizada para registrarse. Póngase en contacto con un administrador.",
|
||||
"databaseConnectionFailed": "Es posible que aún se esté iniciando o que se haya detenido. Esta página seguirá intentando reconectar y continuará automáticamente cuando el servidor vuelva a estar disponible.",
|
||||
"databaseConnectionFailed": "Error al conectarse al servidor de base de datos",
|
||||
"embeddedServerPortInUse": "El puerto {{port}} ya está en uso por otro programa. Por lo general, se trata de un contenedor Docker de Termix o de un proceso residual de Termix de una sesión anterior. Ciérrelo y reinicie Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Uno de los puertos que necesita Termix ya está en uso por otro programa. Cierre lo que lo esté usando y reinicie Termix.",
|
||||
"embeddedServerOldDatabase": "Tus datos locales son de Termix 2.8 o una versión anterior y deben pasar primero por la versión 2.9. Instala Termix 2.9.1, ábrelo una vez para que pueda migrar tus datos y, a continuación, actualiza a esta versión.",
|
||||
@@ -1697,8 +1695,6 @@
|
||||
"pinAppRailDesc": "Mantén la barra lateral de aplicaciones izquierda siempre expandida en lugar de expandirla al pasar el cursor",
|
||||
"expandAppRailOnHover": "Expandir barra lateral al pasar el cursor",
|
||||
"expandAppRailOnHoverDesc": "Permite que la barra lateral de aplicaciones izquierda se expanda cuando el cursor pasa sobre ella",
|
||||
"appRailHoverPushesContent": "Desplazar el contenido al expandirse",
|
||||
"appRailHoverPushesContentDesc": "Dejar espacio para la barra expandida en lugar de superponerla sobre la página. Las sesiones abiertas, como el escritorio remoto, se redimensionarán cada vez.",
|
||||
"showPinAppRailButton": "Mostrar botón para fijar barra lateral",
|
||||
"showPinAppRailButtonDesc": "Muestra un botón en la barra lateral para fijarla abierta. Desactivado por defecto.",
|
||||
"settingsNavigation": "Navegación",
|
||||
@@ -2006,7 +2002,6 @@
|
||||
"syncEntities_other": "Sincronización de escritorio"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "¿Habilitar {{name}}?",
|
||||
"installTitle": "Instalar {{name}}",
|
||||
"updateTitle": "Actualizar {{name}}",
|
||||
"agreeUpdate": "Aceptar y actualizar",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Klõpsa avamiseks/kokku kerimiseks",
|
||||
"openOnDoubleClick": "Ava topeltklõpsuga",
|
||||
"openOnDoubleClickDesc": "Nõua hosti sessiooni avamiseks topeltklõpsu ühe klõpsu asemel",
|
||||
"closeOnOpen": "Peida külgriba pärast avamist",
|
||||
"closeOnOpenDesc": "Sulge külgriba pärast hosti sellest avamist",
|
||||
"showFolderPaths": "Kuva kausta teed",
|
||||
"showFolderPathsDesc": "Kuva vanemkausta tee sisestatud alamkaustade nimede kohal. Alamkaustad on igal juhul sisse tõstetud.",
|
||||
"rowDetailsTitle": "Rea detailid",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Nõustu uue võtmega ja jätkamine"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termixi server ei vasta",
|
||||
"databaseConnection": "Andmebaasiga ei õnnestunud ühendust võtta",
|
||||
"embeddedServerFailed": "Termixi kohalik server ei suutnud käivituda",
|
||||
"unknownError": "Tundmatu viga",
|
||||
"syncedHostMissing": "Sünkroonitud hosti ei eksisteeri linkitud serveris",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Sul puudub Termixi konto. Palu administraatoril lubada väliskontode automaatne loomine.",
|
||||
"userNotAllowed": "Sinu kontol pole registreerumiseks õigusi. Palun võta ühendust administraatoriga.",
|
||||
"databaseConnectionFailed": "Server võib veel käivituda või olla peatunud. See leht proovib pidevalt uuesti ja jätkab tööd automaatselt, kui server on tagasi.",
|
||||
"databaseConnectionFailed": "Andmebaasi serveriga ühendumine ebaõnnestus",
|
||||
"embeddedServerPortInUse": "Porti {{port}} kasutab juba teine programm. Tavaliselt on tegemist Termixi Docker-i konteineriga või eelmisest seansist allesjäänud Termixi protsessiga. Sulge see ja käivita Termix uuesti.",
|
||||
"embeddedServerPortInUseUnknownPort": "Üht Termixi jaoks vajalikust portidest kasutab juba teine programm. Sulge see programm ja käivita Termix uuesti.",
|
||||
"embeddedServerOldDatabase": "Teie kohalikud andmed pärinevad Termixi versioonist 2.8 või vanemast ja tuleb esmalt läbida versioon 2.9. Paigaldage Termix 2.9.1, avage see üks kord, et andmed saaksid ümber paigutada, ja uuendage seejärel uuesti sellele versioonile.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "Hoia vasakut külgriba rakenduse rööpi alati lahtina, mitte ainult hiire kohal hõljutades",
|
||||
"expandAppRailOnHover": "Laienda rakenduse rööpi hiire kohal hõljutades",
|
||||
"expandAppRailOnHoverDesc": "Luba vasaku külgriba rakenduse rööbi laienemine, kui hiirekursor selle kohale liigub",
|
||||
"appRailHoverPushesContent": "Lükka sisu, kui see laieneb",
|
||||
"appRailHoverPushesContentDesc": "Tee ruumi laienenud ribale selle asemel, et see avaneks lehe peale. Avatud sessioonid, nagu kauglaud, muudavad oma suurust iga kord",
|
||||
"showPinAppRailButton": "Kuva rakenduse rööbi kinnitamise nupp",
|
||||
"showPinAppRailButtonDesc": "Kuva rakenduse rööbis nupp selle avatuks kinnitamiseks. Vaikimisi väljas.",
|
||||
"settingsNavigation": "Navigatsioon",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Töölaua sünkroonimine"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Kas lubada {{name}}?",
|
||||
"installTitle": "Paigalda {{name}}",
|
||||
"updateTitle": "Uuenda {{name}}",
|
||||
"agreeUpdate": "Nõustu ja uuenda",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Laajenna/tiivistä napsauttamalla",
|
||||
"openOnDoubleClick": "Avaa kaksoisnapsauttamalla",
|
||||
"openOnDoubleClickDesc": "Vaadi kaksoisnapsautus isäntäistunnon avaamiseen yksittäisen napsautuksen sijaan",
|
||||
"closeOnOpen": "Piilota sivupalkki avaamisen jälkeen",
|
||||
"closeOnOpenDesc": "Sulje sivupalkki, kun avaat isäntäsen palvelimen siitä",
|
||||
"showFolderPaths": "Näytä kansioiden polut",
|
||||
"showFolderPathsDesc": "Näytä vanhemman kansion polku sisäkkäisten alikansioiden nimien yläpuolella. Alikansiot pysyvät sisennettyinä joka tapauksessa.",
|
||||
"rowDetailsTitle": "Rivin tiedot",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Hyväksy uusi avain ja jatka"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix-palvelin ei vastaa",
|
||||
"databaseConnection": "Yhteys tietokantaan epäonnistui",
|
||||
"embeddedServerFailed": "Termixin paikallinen palvelin ei voinut käynnistyä",
|
||||
"unknownError": "Tuntematon virhe",
|
||||
"syncedHostMissing": "Synkronoitu palvelin ei ole olemassa linkitettyä palvelinta",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Sinulla ei vielä ole Termix-tiliä. Pyydä järjestelmänvalvojaa ottamaan käyttöön ulkoisten tilien automaattinen luonti.",
|
||||
"userNotAllowed": "Tiliäsi ei ole valtuutettu rekisteröitymään. Ota yhteyttä järjestelmänvalvojaan.",
|
||||
"databaseConnectionFailed": "Se saattaa olla vielä käynnistymässä tai se on pysähtynyt. Tämä sivu yrittää uudelleen ja jatkaa automaattisesti, kun palvelin on takaisin.",
|
||||
"databaseConnectionFailed": "Yhteys tietokantapalvelimeen epäonnistui",
|
||||
"embeddedServerPortInUse": "Porttia {{port}} käyttää jo toinen ohjelma. Kyseessä on yleensä Termixin Docker-kontti tai edellisestä istunnosta jäänyt Termix-prosessi. Sulje se ja käynnistä Termix uudelleen.",
|
||||
"embeddedServerPortInUseUnknownPort": "Termixin tarvitsema portti on jo toisen ohjelman käytössä. Sulje porttia käyttävä ohjelma ja käynnistä Termix uudelleen.",
|
||||
"embeddedServerOldDatabase": "Paikalliset tietosi ovat peräisin Termix 2.8 tai vanhemmasta versiosta, ja niiden on käyty ensin versiota 2.9. Asenna Termix 2.9.1, avaa se kerran, jotta se voi siirtää tietosi, ja päivitä sitten uudelleen tähän versioon.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "Pidä vasen sovellusraide aina laajennettuna sen sijaan, että se laajenee hiiren vierityksellä",
|
||||
"expandAppRailOnHover": "Laajenna sovellusraide hiiren vierityksellä",
|
||||
"expandAppRailOnHoverDesc": "Salli vasemman sovellusraiteen laajentuminen, kun osoitin liikkuu sen päällä",
|
||||
"appRailHoverPushesContent": "Työnnä sisältöä, kun se laajenee",
|
||||
"appRailHoverPushesContentDesc": "Varaa tilaa laajennetulle palkille sen sijaan, että se avautuisi sivun päälle. Avoinna olevat istunnot, kuten etätyöpöytä, muuttavat kokoaan joka kerta",
|
||||
"showPinAppRailButton": "Näytä sovellusraiteen kiinnitys-painike",
|
||||
"showPinAppRailButtonDesc": "Näytä painike sovellusraiteessa sen kiinnittämiseksi auki. Oletuksena pois päältä.",
|
||||
"settingsNavigation": "Navigointi",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Työpöydän synkronointi"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Haluatko ottaa {{name}} käyttöön?",
|
||||
"installTitle": "Asenna {{name}}",
|
||||
"updateTitle": "Päivitä {{name}}",
|
||||
"agreeUpdate": "Hyväksy ja päivitä",
|
||||
|
||||
@@ -650,8 +650,6 @@
|
||||
"actionsClick": "Cliquer pour développer/réduire",
|
||||
"openOnDoubleClick": "Ouvrir au double-clic",
|
||||
"openOnDoubleClickDesc": "Exiger un double-clic pour ouvrir une session d'hôte au lieu d'un clic simple",
|
||||
"closeOnOpen": "Masquer la barre latérale après l'ouverture",
|
||||
"closeOnOpenDesc": "Fermer la barre latérale après avoir ouvert un hôte depuis celle-ci",
|
||||
"showFolderPaths": "Afficher les chemins des dossiers",
|
||||
"showFolderPathsDesc": "Afficher le chemin du dossier parent au-dessus des noms des sous-dossiers imbriqués. Les sous-dossiers restent indentés dans les deux cas.",
|
||||
"rowDetailsTitle": "Détails de la ligne",
|
||||
@@ -1113,7 +1111,7 @@
|
||||
"acceptNewKey": "Accepter la nouvelle clé et continuer"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Le serveur Termix ne répond pas",
|
||||
"databaseConnection": "Impossible de se connecter à la base de données",
|
||||
"embeddedServerFailed": "Le serveur local de Termix n'a pas pu démarrer",
|
||||
"unknownError": "Erreur inconnue",
|
||||
"syncedHostMissing": "L'hôte synchronisé n'existe pas sur le serveur lié",
|
||||
@@ -1140,7 +1138,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Vous n'avez pas encore de compte Termix. Demandez à un administrateur d'activer la création automatique de comptes externes.",
|
||||
"userNotAllowed": "Votre compte n'est pas autorisé à s'inscrire. Veuillez contacter un administrateur.",
|
||||
"databaseConnectionFailed": "Il est peut-être encore en cours de démarrage, ou il s'est arrêté. Cette page continue d'essayer et reprend automatiquement une fois le serveur de retour.",
|
||||
"databaseConnectionFailed": "Échec de la connexion au serveur de base de données",
|
||||
"embeddedServerPortInUse": "Le port {{port}} est déjà utilisé par un autre programme. Il s'agit généralement d'un conteneur Docker Termix ou d'un processus Termix résidant d'une session précédente. Fermez-le, puis redémarrez Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "L'un des ports requis par Termix est déjà utilisé par un autre programme. Fermez l'application qui l'utilise, puis redémarrez Termix.",
|
||||
"embeddedServerOldDatabase": "Vos données locales proviennent de Termix 2.8 ou d'une version antérieure et doivent d'abord être migrées vers la version 2.9. Installez Termix 2.9.1, ouvrez-le une fois pour permettre la migration de vos données, puis mettez à jour vers cette version.",
|
||||
@@ -1697,8 +1695,6 @@
|
||||
"pinAppRailDesc": "Garder la barre d'applications de la barre latérale gauche toujours déployée au lieu de la déployer au survol",
|
||||
"expandAppRailOnHover": "Déployer la barre d'applications au survol",
|
||||
"expandAppRailOnHoverDesc": "Permettre à la barre d'applications de la barre latérale gauche de se déployer lorsque le pointeur passe dessus",
|
||||
"appRailHoverPushesContent": "Déplacer le contenu lors de l'expansion",
|
||||
"appRailHoverPushesContentDesc": "Faire de la place pour la barre latérale élargie au lieu de l'afficher par-dessus la page. Les sessions ouvertes, comme le bureau à distance, sont redimensionnées à chaque fois.",
|
||||
"showPinAppRailButton": "Afficher le bouton d'épinglage de la barre d'applications",
|
||||
"showPinAppRailButtonDesc": "Affiche un bouton dans la barre d'applications pour l'épingler ouverte. Désactivé par défaut.",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -2006,7 +2002,6 @@
|
||||
"syncEntities_other": "Synchronisation du bureau"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Activer {{name}} ?",
|
||||
"installTitle": "Installer {{name}}",
|
||||
"updateTitle": "Mettre à jour {{name}}",
|
||||
"agreeUpdate": "Accepter et mettre à jour",
|
||||
|
||||
@@ -650,8 +650,6 @@
|
||||
"actionsClick": "לחץ להרחבה/כיווץ",
|
||||
"openOnDoubleClick": "פתח בלחיצה כפולה",
|
||||
"openOnDoubleClickDesc": "דרוש לחיצה כפולה כדי לפתוח פעילות מארח במקום לחיצה בודדת",
|
||||
"closeOnOpen": "הסתר סרגל צד לאחר פתיחה",
|
||||
"closeOnOpenDesc": "סגור את סרגל הצד לאחר פתיחת מארח ממנו",
|
||||
"showFolderPaths": "הצג נתיבי תיקייה",
|
||||
"showFolderPathsDesc": "הצג את נתיב התיקייה האם מעל שמות תתי-תיקיות מקוננות. תתי-תיקיות נשארות מושרשות בכל מקרה.",
|
||||
"rowDetailsTitle": "פרטי שורה",
|
||||
@@ -1113,7 +1111,7 @@
|
||||
"acceptNewKey": "קבל מפתח חדש והמשך"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "שרת Termix אינו מגיב",
|
||||
"databaseConnection": "לא ניתן להתחבר למסד הנתונים",
|
||||
"embeddedServerFailed": "שרת Termix המקומי לא הצליח להתחיל",
|
||||
"unknownError": "שגיאה לא ידועה",
|
||||
"syncedHostMissing": "המארח המסונכרן אינו קיים בשרת המקושר",
|
||||
@@ -1140,7 +1138,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "אין לך עדיין חשבון ב-Termix. בקש ממנהל מערכת להפעיל את האפשרות 'צור חשבונות חיצוניים אוטומטית'.",
|
||||
"userNotAllowed": "החשבון שלך אינו מורשה להרשמה. אנא פנה למנהל המערכת.",
|
||||
"databaseConnectionFailed": "ייתכן שהוא עדיין מתחיל להפעיל, או שהופסק. דף זה ממשיך לנסות וממשיך באופן אוטומטי ברגע שהשרת חוזר.",
|
||||
"databaseConnectionFailed": "החיבור לשרת המסד נתונים נכשל",
|
||||
"embeddedServerPortInUse": "הפורט {{port}} כבר בשימוש על ידי תוכנית אחרת. לרוב מדובר במיכל Docker של Termix או בתהליך Termix שנותר מהפעלה קודמת. סגור אותו, ואז הפעל מחדש את Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "אחד מהפורטים ש-Termix זקוק להם כבר בשימוש על ידי תוכנית אחרת. סגור את מה שמשתמש בו, ואז הפעל מחדש את Termix.",
|
||||
"embeddedServerOldDatabase": "הנתונים המקומיים שלך הם מ-Termix 2.8 או קודם יותר, ועליהם לעבור דרך גרסה 2.9 תחילה. התקן את Termix 2.9.1, פתח אותו פעם אחת כדי שיוכל להעביר את הנתונים שלך, ואז עדכן שוב לגרסה זו.",
|
||||
@@ -1697,8 +1695,6 @@
|
||||
"pinAppRailDesc": "שמור על רצועת היישומים בצד שמאל תמיד מורחבת במקום להרחיב רק בעת מעבר עכבר",
|
||||
"expandAppRailOnHover": "הרחב רצועת יישומים בעת מעבר עכבר",
|
||||
"expandAppRailOnHoverDesc": "אפשר להרחיב את רצועת היישומים בצד שמאל כאשר הסמן עובר מעליה",
|
||||
"appRailHoverPushesContent": "דחוף תוכן בעת הרחבה",
|
||||
"appRailHoverPushesContentDesc": "השאר מקום לסרגל המורחב במקום לפתוח אותו מעל הדף. סשנים פתוחים, כמו שולחן עבודה מרוחק, משנים גודל בכל פעם",
|
||||
"showPinAppRailButton": "הצג כפתור עיגון רצועת יישומים",
|
||||
"showPinAppRailButtonDesc": "הצג כפתור ברצועת היישומים כדי לעגן אותה פתוחה. כבוי כברירת מחדל.",
|
||||
"settingsNavigation": "ניווט",
|
||||
@@ -2006,7 +2002,6 @@
|
||||
"syncEntities_other": "סנכרון שולחן עבודה"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "להפעיל את {{name}}?",
|
||||
"installTitle": "התקן את {{name}}",
|
||||
"updateTitle": "עדכן את {{name}}",
|
||||
"agreeUpdate": "סכים ומעדכן",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "विस्तारित/संक्षिप्त करने के लिए क्लिक करें",
|
||||
"openOnDoubleClick": "डबल क्लिक पर खोलें",
|
||||
"openOnDoubleClickDesc": "एकल क्लिक के बजाय होस्ट सत्र खोलने के लिए डबल क्लिक की आवश्यकता करें",
|
||||
"closeOnOpen": "खोलने के बाद साइडबार छुपाएं",
|
||||
"closeOnOpenDesc": "इससे होस्ट खोलने के बाद साइडबार बंद करें",
|
||||
"showFolderPaths": "फ़ोल्डर पथ दिखाएं",
|
||||
"showFolderPathsDesc": "नेस्टेड सबफ़ोल्डर नामों के ऊपर माता-पिता फ़ोल्डर का पथ दिखाएं। सबफ़ोल्डर दोनों स्थिति में अंतर्निहित रहते हैं।",
|
||||
"rowDetailsTitle": "पंक्ति विवरण",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "नई कुंजी स्वीकार करें और जारी रखें"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix सर्वर प्रतिक्रिया नहीं दे रहा है",
|
||||
"databaseConnection": "डेटाबेस से कनेक्ट नहीं हो सका",
|
||||
"embeddedServerFailed": "Termix का स्थानीय सर्वर शुरू नहीं हो सका",
|
||||
"unknownError": "अज्ञात त्रुटि",
|
||||
"syncedHostMissing": "सिंक किया गया होस्ट लिंक किए गए सर्वर पर मौजूद नहीं है",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "आपके पास अभी तक कोई Termix खाता नहीं है। किसी प्रशासक से बाहरी खातों को स्वतः बनाने (Auto-create external accounts) को चालू करने के लिए कहें।",
|
||||
"userNotAllowed": "आपका खाता पंजीकरण के लिए अधिकृत नहीं है। कृपया किसी प्रशासक से संपर्क करें।",
|
||||
"databaseConnectionFailed": "यह अभी शुरू हो रहा हो सकता है, या रुक गया हो सकता है। यह पेज लगातार कोशिश करता रहता है और सर्वर वापस आने पर अपने आप जारी रखता है।",
|
||||
"databaseConnectionFailed": "डेटाबेस सर्वर से कनेक्ट करने में विफल",
|
||||
"embeddedServerPortInUse": "पोर्ट {{port}} पहले से ही किसी अन्य प्रोग्राम द्वारा उपयोग में है। यह आमतौर पर Termix Docker कंटेनर या पिछले सत्र से बचा हुआ Termix प्रक्रिया होती है। इसे बंद करें, फिर Termix को पुनः आरंभ करें।",
|
||||
"embeddedServerPortInUseUnknownPort": "Termix को आवश्यक एक पोर्ट पहले से ही किसी अन्य प्रोग्राम द्वारा उपयोग में है। जो भी इसे उपयोग कर रहा है उसे बंद करें, फिर Termix को पुनः आरंभ करें।",
|
||||
"embeddedServerOldDatabase": "आपका स्थानीय डेटा Termix 2.8 या उससे पुरानी संस्करण का है और इसे पहले 2.9 से होकर गुजरना होगा। Termix 2.9.1 इंस्टॉल करें, इसे एक बार खोलें ताकि यह आपके डेटा को स्थानांतरित कर सके, फिर वापस इस संस्करण पर अपडेट करें।",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "होवर करने पर विस्तार करने के बजाय बाएं साइडबार ऐप रेल को हमेशा विस्तारित रखें",
|
||||
"expandAppRailOnHover": "होवर पर ऐप रेल विस्तारित करें",
|
||||
"expandAppRailOnHoverDesc": "सूचक के ऊपर जाने पर बाएं साइडबार ऐप रेल को विस्तारित होने की अनुमति दें",
|
||||
"appRailHoverPushesContent": "विस्तारित होने पर सामग्री को धकेलें",
|
||||
"appRailHoverPushesContentDesc": "पेज के ऊपर इसे खोलने के बजाय विस्तारित रेल के लिए जगह बनाएं। रिमोट डेस्कटॉप जैसे खुले सत्र हर बार आकार बदलते हैं",
|
||||
"showPinAppRailButton": "ऐप रेल पिन बटन दिखाएं",
|
||||
"showPinAppRailButtonDesc": "ऐप रेल में इसे खुला रखने के लिए एक बटन दिखाएं। डिफ़ॉल्ट रूप से बंद।",
|
||||
"settingsNavigation": "नेविगेशन",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "डेस्कटॉप सिंक"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "{{name}} सक्षम करें?",
|
||||
"installTitle": "{{name}} इंस्टॉल करें",
|
||||
"updateTitle": "{{name}} अपडेट करें",
|
||||
"agreeUpdate": "सहमत हों और अपडेट करें",
|
||||
|
||||
@@ -650,8 +650,6 @@
|
||||
"actionsClick": "Klik za proširenje/sažimanje",
|
||||
"openOnDoubleClick": "Otvori dvostrukim klikom",
|
||||
"openOnDoubleClickDesc": "Zahtijevajte dvostruki klik za otvaranje sesije hosta umjesto jednokratnog klika",
|
||||
"closeOnOpen": "Sakrij bočnu traku nakon otvaranja",
|
||||
"closeOnOpenDesc": "Zatvori bočnu traku nakon otvaranja hosta iz nje",
|
||||
"showFolderPaths": "Prikaži putanje mapa",
|
||||
"showFolderPathsDesc": "Prikažite putanju nadređene mape iznad naziva ugniježđenih podmapa. Podmape ostaju uvučene u svakom slučaju.",
|
||||
"rowDetailsTitle": "Detalji retka",
|
||||
@@ -1113,7 +1111,7 @@
|
||||
"acceptNewKey": "Prihvati novi ključ i nastavi"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix poslužitelj ne odgovara",
|
||||
"databaseConnection": "Nije moguće povezati se s bazom podataka",
|
||||
"embeddedServerFailed": "Lokalni Termix poslužitelj se nije mogao pokrenuti",
|
||||
"unknownError": "Nepoznata greška",
|
||||
"syncedHostMissing": "Sinkronizirani domaćin ne postoji na povezanom poslužitelju",
|
||||
@@ -1140,7 +1138,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Još nemate Termix račun. Zatražite od administratora da uključi opciju Automatsko stvaranje vanjskih računa.",
|
||||
"userNotAllowed": "Vaš račun nije ovlašten za registraciju. Kontaktirajte administratora.",
|
||||
"databaseConnectionFailed": "Moguće je da se još pokreće ili je zaustavljen. Ova stranica nastavlja pokušavati i automatski se nastavlja raditi čim poslužitelj ponovno bude dostupan.",
|
||||
"databaseConnectionFailed": "Povezivanje s poslužiteljem baze podataka nije uspjelo",
|
||||
"embeddedServerPortInUse": "Port {{port}} već koristi drugi program. To je obično Termix Docker kontejner ili preostali Termix proces iz prethodne sesije. Zatvorite ga, a zatim ponovno pokrenite Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Jedan od portova koji Termixu trebaju već koristi drugi program. Zatvorite program koji ga koristi, a zatim ponovno pokrenite Termix.",
|
||||
"embeddedServerOldDatabase": "Vaši lokalni podaci potječu iz Termixa 2.8 ili starije verzije i moraju prvo proći kroz verziju 2.9. Instalirajte Termix 2.9.1, otvorite ga jednom kako bi mogao premjestiti vaše podatke, a zatim ponovno ažurirajte na ovu verziju.",
|
||||
@@ -1697,8 +1695,6 @@
|
||||
"pinAppRailDesc": "Zadrži lijevu bočnu traku aplikacija uvijek proširenu umjesto proširivanja pri prelasku mišem",
|
||||
"expandAppRailOnHover": "Proširi traku aplikacija pri prelasku mišem",
|
||||
"expandAppRailOnHoverDesc": "Dopusti proširivanje lijeve bočne trake aplikacija kada se pokazivač miša nalazi iznad nje",
|
||||
"appRailHoverPushesContent": "Pomakni sadržaj pri proširenju",
|
||||
"appRailHoverPushesContentDesc": "Oslobodi prostor za proširenu traku umjesto da se otvara preko stranice. Otvorene sesije, poput udaljenog radnog stola, svaki put se prilagođavaju veličini.",
|
||||
"showPinAppRailButton": "Prikaži gumb za prikvačivanje trake aplikacija",
|
||||
"showPinAppRailButtonDesc": "Prikaži gumb u traci aplikacija za njezino prikvačivanje. Isključeno prema zadanim postavkama.",
|
||||
"settingsNavigation": "Navigacija",
|
||||
@@ -2006,7 +2002,6 @@
|
||||
"syncEntities_other": "Sinkronizacija radne površine"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Omogućiti {{name}}?",
|
||||
"installTitle": "Instaliraj {{name}}",
|
||||
"updateTitle": "Ažuriraj {{name}}",
|
||||
"agreeUpdate": "Prihvati i ažuriraj",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Kattintás a kibontáshoz/összecsukáshoz",
|
||||
"openOnDoubleClick": "Megnyitás duplakattintásra",
|
||||
"openOnDoubleClickDesc": "Duplakattintás igényelése egy kiszolgáló munkamenetének megnyitásához az egységes kattintás helyett",
|
||||
"closeOnOpen": "Oldalsáv elrejtése megnyitás után",
|
||||
"closeOnOpenDesc": "Az oldalsáv bezárása egy gazdagép megnyitása után",
|
||||
"showFolderPaths": "Mappa-utak megjelenítése",
|
||||
"showFolderPathsDesc": "A szülőmappa-út megjelenítése az egymásba ágyazott almappanevek felett. Az almappák mindkét esetben behúzásban maradnak.",
|
||||
"rowDetailsTitle": "Sor részletei",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Új kulcs elfogadása és folytatás"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "A Termix szerver nem válaszol",
|
||||
"databaseConnection": "Nem sikerült csatlakozni az adatbázishoz",
|
||||
"embeddedServerFailed": "A Termix helyi szervert nem sikerült elindítani",
|
||||
"unknownError": "Ismeretlen hiba",
|
||||
"syncedHostMissing": "A szinkronizált gazdagép nem létezik a csatolt szerveren",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Még nincs Termix-fiókja. Kérjen meg egy rendszergazdát, hogy engedélyezze a külső fiókok automatikus létrehozását.",
|
||||
"userNotAllowed": "A fiókja nem jogosult a regisztrációra. Kérjük, lépjen kapcsolatba egy rendszergazdával.",
|
||||
"databaseConnectionFailed": "Lehet, hogy még indul, vagy leállt. Ez az oldal folyamatosan próbálkozik, és automatikusan folytatja a munkát, amikor a szerver újra elérhető.",
|
||||
"databaseConnectionFailed": "Nem sikerült csatlakozni az adatbázis-szervert",
|
||||
"embeddedServerPortInUse": "A {{port}} portot már egy másik program használja. Ez általában egy Termix Docker-konténer vagy egy előző munkamenetből maradt Termix-folyamat. Zárja be, majd indítsa újra a Termixet.",
|
||||
"embeddedServerPortInUseUnknownPort": "A Termix által szükséges portok egyikét már egy másik program használja. Zárja be azt, ami használja, majd indítsa újra a Termixet.",
|
||||
"embeddedServerOldDatabase": "A helyi adatai a Termix 2.8 vagy régebbi verzióból származnak, és először át kell futniuk a 2.9-es verzió migrációján. Telepítse a Termix 2.9.1 verzióját, nyissa meg egyszer, hogy az adatok átkerüljenek, majd frissítsen újra erre a verzióra.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "A bal oldali alkalmazásvázlat állandóan kibontott állapotban marad, nem csak rámutatáskor",
|
||||
"expandAppRailOnHover": "Alkalmazásvázlat kibontása rámutatáskor",
|
||||
"expandAppRailOnHoverDesc": "A bal oldali alkalmazásvázlat kibontásának engedélyezése, amikor a mutató rákerül",
|
||||
"appRailHoverPushesContent": "Tartalom eltolása kibontáskor",
|
||||
"appRailHoverPushesContentDesc": "Helyet biztosít a kibontott sávnak, ahelyett, hogy a lap fölé nyitná. A nyitott munkamenetek (például távoli asztal) minden alkalommal átméreteződnek.",
|
||||
"showPinAppRailButton": "Alkalmazásvázlat rögzítés gomb megjelenítése",
|
||||
"showPinAppRailButtonDesc": "Gomb megjelenítése az alkalmazásvázlatban a rögzítéshez. Alapértelmezetten ki van kapcsolva.",
|
||||
"settingsNavigation": "Navigáció",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Asztali szinkronizálás"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Engedélyezi a(z) {{name}} bekapcsolását?",
|
||||
"installTitle": "A(z) {{name}} telepítése",
|
||||
"updateTitle": "A(z) {{name}} frissítése",
|
||||
"agreeUpdate": "Elfogadás és frissítés",
|
||||
|
||||
@@ -638,8 +638,6 @@
|
||||
"actionsClick": "Klik untuk memperluas/menciutkan",
|
||||
"openOnDoubleClick": "Buka saat diklik dua kali",
|
||||
"openOnDoubleClickDesc": "Memerlukan klik dua kali untuk membuka sesi host, bukan klik tunggal",
|
||||
"closeOnOpen": "Sembunyikan bilah sisi setelah membuka",
|
||||
"closeOnOpenDesc": "Tutup bilah sisi setelah Anda membuka host darinya",
|
||||
"showFolderPaths": "Tampilkan jalur folder",
|
||||
"showFolderPathsDesc": "Tampilkan jalur folder induk di atas nama subfolder bersarang. Subfolder tetap menjorok ke dalam dalam kedua kasus.",
|
||||
"rowDetailsTitle": "Detail baris",
|
||||
@@ -1085,7 +1083,7 @@
|
||||
"acceptNewKey": "Terima Kunci Baru & Lanjutkan"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Server Termix tidak merespons",
|
||||
"databaseConnection": "Tidak dapat terhubung ke database",
|
||||
"embeddedServerFailed": "Server lokal Termix tidak dapat dimulai",
|
||||
"unknownError": "Kesalahan tidak diketahui",
|
||||
"syncedHostMissing": "Host yang disinkronkan tidak ada di server yang terhubung",
|
||||
@@ -1112,7 +1110,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Anda belum memiliki akun Termix. Mintalah admin untuk mengaktifkan pembuatan otomatis akun eksternal.",
|
||||
"userNotAllowed": "Akun Anda tidak diizinkan untuk mendaftar. Hubungi administrator.",
|
||||
"databaseConnectionFailed": "Server mungkin masih sedang dimulai atau telah berhenti. Halaman ini akan terus mencoba dan kembali normal secara otomatis setelah server aktif kembali.",
|
||||
"databaseConnectionFailed": "Gagal terhubung ke server database",
|
||||
"embeddedServerPortInUse": "Port {{port}} sudah digunakan oleh program lain. Biasanya ini adalah kontainer Docker Termix atau proses Termix yang tersisa dari sesi sebelumnya. Tutup program tersebut, lalu mulai ulang Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Salah satu port yang dibutuhkan Termix sudah digunakan oleh program lain. Tutup program yang menggunakannya, lalu mulai ulang Termix.",
|
||||
"embeddedServerOldDatabase": "Data lokal Anda berasal dari Termix 2.8 atau lebih lama dan harus diproses melalui versi 2.9 terlebih dahulu. Instal Termix 2.9.1, buka sekali agar data Anda dapat dipindahkan, lalu perbarui ke versi ini lagi.",
|
||||
@@ -1651,8 +1649,6 @@
|
||||
"pinAppRailDesc": "Jaga agar rel aplikasi di sidebar kiri tetap terbuka, bukan hanya saat kursor diarahkan",
|
||||
"expandAppRailOnHover": "Perluas Rel Aplikasi saat Kursor Diarahkan",
|
||||
"expandAppRailOnHoverDesc": "Izinkan rel aplikasi di sidebar kiri untuk diperluas saat kursor diarahkan",
|
||||
"appRailHoverPushesContent": "Geser konten saat diperluas",
|
||||
"appRailHoverPushesContentDesc": "Buat ruang untuk rel yang diperluas alih-alih menampilkannya di atas halaman. Sesi terbuka, seperti desktop jarak jauh, akan menyesuaikan ukuran setiap kali",
|
||||
"showPinAppRailButton": "Tampilkan Tombol Sematkan Rel Aplikasi",
|
||||
"showPinAppRailButtonDesc": "Tampilkan tombol di rel aplikasi untuk menyematkannya agar tetap terbuka. Nonaktif secara default.",
|
||||
"settingsNavigation": "Navigasi",
|
||||
@@ -1936,7 +1932,6 @@
|
||||
"syncEntities_other": "Sinkronisasi desktop"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Aktifkan {{name}}?",
|
||||
"installTitle": "Pasang {{name}}",
|
||||
"updateTitle": "Perbarui {{name}}",
|
||||
"agreeUpdate": "Setuju dan perbarui",
|
||||
|
||||
@@ -650,8 +650,6 @@
|
||||
"actionsClick": "Clic per espandere/comprimere",
|
||||
"openOnDoubleClick": "Apri al doppio clic",
|
||||
"openOnDoubleClickDesc": "Richiedi un doppio clic per aprire una sessione host invece di un singolo clic",
|
||||
"closeOnOpen": "Nascondi la barra laterale dopo l'apertura",
|
||||
"closeOnOpenDesc": "Chiudi la barra laterale dopo aver aperto un host da essa",
|
||||
"showFolderPaths": "Mostra percorsi delle cartelle",
|
||||
"showFolderPathsDesc": "Mostra il percorso della cartella genitore sopra i nomi delle sottocartelle annidate. Le sottocartelle rimangono indentate in ogni caso.",
|
||||
"rowDetailsTitle": "Dettagli riga",
|
||||
@@ -1113,7 +1111,7 @@
|
||||
"acceptNewKey": "Accept New Key & Continue"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Il server Termix non risponde",
|
||||
"databaseConnection": "Could not connect to the database",
|
||||
"embeddedServerFailed": "Termix's local server could not start",
|
||||
"unknownError": "Unknown error",
|
||||
"syncedHostMissing": "The synced host does not exist on the linked server",
|
||||
@@ -1140,7 +1138,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Non hai ancora un account Termix. Chiedi a un amministratore di attivare la creazione automatica degli account esterni.",
|
||||
"userNotAllowed": "Il tuo account non è autorizzato alla registrazione. Contatta un amministratore.",
|
||||
"databaseConnectionFailed": "Potrebbe ancora essere in fase di avvio o essersi arrestato. Questa pagina continua a provare e riprenderà automaticamente una volta che il server sarà di nuovo disponibile.",
|
||||
"databaseConnectionFailed": "Impossibile connettersi al server del database",
|
||||
"embeddedServerPortInUse": "La porta {{port}} è già in uso da un altro programma. Di solito si tratta di un container Docker di Termix o di un processo residuo di Termix da una sessione precedente. Chiudilo, quindi riavvia Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Una delle porte necessarie a Termix è già in uso da un altro programma. Chiudi il programma che la sta utilizzando, quindi riavvia Termix.",
|
||||
"embeddedServerOldDatabase": "I tuoi dati locali provengono da Termix 2.8 o versioni precedenti e devono essere migrati tramite la versione 2.9. Installa Termix 2.9.1, aprilo una volta per consentire la migrazione dei dati, quindi aggiorna nuovamente a questa versione.",
|
||||
@@ -1697,8 +1695,6 @@
|
||||
"pinAppRailDesc": "Mantieni la barra app laterale sinistra sempre espansa invece di espanderla al passaggio del mouse",
|
||||
"expandAppRailOnHover": "Espandi barra app al passaggio del mouse",
|
||||
"expandAppRailOnHoverDesc": "Consenti alla barra app laterale sinistra di espandersi quando il puntatore vi passa sopra",
|
||||
"appRailHoverPushesContent": "Sposta il contenuto quando si espande",
|
||||
"appRailHoverPushesContentDesc": "Fai spazio alla barra laterale espansa invece di sovrapporla alla pagina. Le sessioni aperte, come il desktop remoto, verranno ridimensionate ogni volta.",
|
||||
"showPinAppRailButton": "Mostra pulsante per fissare barra app",
|
||||
"showPinAppRailButtonDesc": "Mostra un pulsante nella barra app per fissarla aperta. Disattivato di default.",
|
||||
"settingsNavigation": "Navigazione",
|
||||
@@ -2006,7 +2002,6 @@
|
||||
"syncEntities_other": "Sincronizzazione desktop"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Abilitare {{name}}?",
|
||||
"installTitle": "Installa {{name}}",
|
||||
"updateTitle": "Aggiorna {{name}}",
|
||||
"agreeUpdate": "Accetta e aggiorna",
|
||||
|
||||
@@ -638,8 +638,6 @@
|
||||
"actionsClick": "クリックで展開/折りたたみ",
|
||||
"openOnDoubleClick": "ダブルクリックで開く",
|
||||
"openOnDoubleClickDesc": "シングルクリックの代わりに、ホストセッションを開くためにダブルクリックを必要とします",
|
||||
"closeOnOpen": "開いた後にサイドバーを隠す",
|
||||
"closeOnOpenDesc": "サイドバーからホストを開いた後、サイドバーを閉じます",
|
||||
"showFolderPaths": "フォルダパスを表示",
|
||||
"showFolderPathsDesc": "ネストされたサブフォルダ名の上部に親フォルダのパスを表示します。サブフォルダはどちらの場合もインデントされます。",
|
||||
"rowDetailsTitle": "行の詳細",
|
||||
@@ -1085,7 +1083,7 @@
|
||||
"acceptNewKey": "新しいキーを承諾して続行"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix サーバーが応答していません",
|
||||
"databaseConnection": "データベースに接続できませんでした",
|
||||
"embeddedServerFailed": "Termix のローカルサーバーが起動できませんでした",
|
||||
"unknownError": "不明なエラー",
|
||||
"syncedHostMissing": "同期されたホストはリンクされたサーバー上に存在しません",
|
||||
@@ -1112,7 +1110,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "まだTermixアカウントをお持ちではありません。管理者に「外部アカウントの自動作成」を有効化するように依頼してください。",
|
||||
"userNotAllowed": "あなたのアカウントは登録が許可されていません。管理者にご連絡ください。",
|
||||
"databaseConnectionFailed": "起動中か、停止している可能性があります。サーバーが回復すると、このページは自動的に再接続を試み続けます。",
|
||||
"databaseConnectionFailed": "データベースサーバーへの接続に失敗しました",
|
||||
"embeddedServerPortInUse": "ポート {{port}} はすでに別のプログラムで使用されています。これは通常、TermixのDockerコンテナや、以前のセッションから残ったTermixプロセスです。それを閉じてから、Termixを再起動してください。",
|
||||
"embeddedServerPortInUseUnknownPort": "Termixが必要とするポートの1つが、すでに別のプログラムで使用されています。それを使用中のものを閉じてから、Termixを再起動してください。",
|
||||
"embeddedServerOldDatabase": "ローカルデータは Termix 2.8 以前のものであり、まず 2.9 を経由する必要があります。Termix 2.9.1 をインストールし、一度起動してデータを移行してから、再度このバージョンにアップデートしてください。",
|
||||
@@ -1651,8 +1649,6 @@
|
||||
"pinAppRailDesc": "ホバー時に展開する代わりに、左サイドバーのアプリレールを常に展開した状態に保ちます",
|
||||
"expandAppRailOnHover": "ホバー時にアプリレールを展開",
|
||||
"expandAppRailOnHoverDesc": "ポインタが左サイドバーのアプリレール上に移動したときに展開できるようにします",
|
||||
"appRailHoverPushesContent": "展開時にコンテンツを押し出す",
|
||||
"appRailHoverPushesContentDesc": "ページの上に重ねるのではなく、展開されたレール分のスペースを確保します。リモートデスクトップなどの開いているセッションは、毎回サイズが調整されます",
|
||||
"showPinAppRailButton": "アプリレール固定ボタンを表示",
|
||||
"showPinAppRailButtonDesc": "アプリレールを開いたまま固定するためのボタンをアプリレールに表示します。デフォルトではオフです。",
|
||||
"settingsNavigation": "ナビゲーション",
|
||||
@@ -1936,7 +1932,6 @@
|
||||
"syncEntities_other": "デスクトップ同期"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "{{name}} を有効にしますか?",
|
||||
"installTitle": "{{name}} をインストール",
|
||||
"updateTitle": "{{name}} をアップデート",
|
||||
"agreeUpdate": "同意してアップデート",
|
||||
|
||||
@@ -638,8 +638,6 @@
|
||||
"actionsClick": "클릭하여 펼치기/접기",
|
||||
"openOnDoubleClick": "더블 클릭 시 열기",
|
||||
"openOnDoubleClickDesc": "단일 클릭 대신 더블 클릭으로 호스트 세션을 열도록 설정합니다",
|
||||
"closeOnOpen": "열기 후 사이드바 숨기기",
|
||||
"closeOnOpenDesc": "사이드바에서 호스트를 열면 사이드바를 닫습니다",
|
||||
"showFolderPaths": "폴더 경로 표시",
|
||||
"showFolderPathsDesc": "중첩된 하위 폴더 이름 위에 상위 폴더 경로를 표시합니다. 하위 폴더는 어느 경우든 들여쓰기가 유지됩니다.",
|
||||
"rowDetailsTitle": "행 세부 정보",
|
||||
@@ -1085,7 +1083,7 @@
|
||||
"acceptNewKey": "새 키 수락 및 계속"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix 서버가 응답하지 않습니다",
|
||||
"databaseConnection": "데이터베이스에 연결할 수 없습니다",
|
||||
"embeddedServerFailed": "Termix의 로컬 서버를 시작할 수 없습니다",
|
||||
"unknownError": "알 수 없는 오류",
|
||||
"syncedHostMissing": "동기화된 호스트가 연결된 서버에 존재하지 않습니다",
|
||||
@@ -1112,7 +1110,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "아직 Termix 계정이 없습니다. 관리자에게 외부 계정 자동 생성을 활성화해 달라고 요청하세요.",
|
||||
"userNotAllowed": "귀하의 계정은 등록이 허용되지 않았습니다. 관리자에게 문의해 주세요.",
|
||||
"databaseConnectionFailed": "서버가 아직 시작 중이거나 중지되었을 수 있습니다. 이 페이지는 계속 시도하다가 서버가 다시 연결되면 자동으로 계속됩니다.",
|
||||
"databaseConnectionFailed": "데이터베이스 서버 연결 실패",
|
||||
"embeddedServerPortInUse": "포트 {{port}}는 이미 다른 프로그램에서 사용 중입니다. 일반적으로 Termix Docker 컨테이너나 이전 세션의 Termix 프로세스가 원인입니다. 해당 프로그램을 닫은 후 Termix를 다시 시작하세요.",
|
||||
"embeddedServerPortInUseUnknownPort": "Termix가 필요로 하는 포트 중 하나가 이미 다른 프로그램에서 사용 중입니다. 해당 포트를 사용하는 프로그램을 닫은 후 Termix를 다시 시작하세요.",
|
||||
"embeddedServerOldDatabase": "로컬 데이터가 Termix 2.8 이하 버전에서 생성된 것으로, 먼저 2.9 버전을 거쳐야 합니다. Termix 2.9.1을 설치하고 한 번 실행하여 데이터를 마이그레이션한 후, 다시 이 버전으로 업데이트하세요.",
|
||||
@@ -1651,8 +1649,6 @@
|
||||
"pinAppRailDesc": "마우스를 올리면 확장되는 대신 왼쪽 사이드바 앱 레일을 항상 확장된 상태로 유지합니다",
|
||||
"expandAppRailOnHover": "마우스 오버 시 앱 레일 확장",
|
||||
"expandAppRailOnHoverDesc": "포인터가 왼쪽 사이드바 앱 레일 위에 있을 때 확장되도록 허용합니다",
|
||||
"appRailHoverPushesContent": "확장 시 콘텐츠 밀기",
|
||||
"appRailHoverPushesContentDesc": "페이지 위에 레일을 열지 않고 확장된 레일 공간을 확보합니다. 원격 데스크톱과 같은 열린 세션은 매번 크기가 조정됩니다",
|
||||
"showPinAppRailButton": "앱 레일 고정 버튼 표시",
|
||||
"showPinAppRailButtonDesc": "앱 레일을 열려 있도록 고정하는 버튼을 앱 레일에 표시합니다. 기본적으로 꺼져 있습니다.",
|
||||
"settingsNavigation": "네비게이션",
|
||||
@@ -1936,7 +1932,6 @@
|
||||
"syncEntities_other": "데스크톱 동기화"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "{{name}}을(를) 활성화하시겠습니까?",
|
||||
"installTitle": "{{name}} 설치",
|
||||
"updateTitle": "{{name}} 업데이트",
|
||||
"agreeUpdate": "동의하고 업데이트",
|
||||
|
||||
@@ -656,8 +656,6 @@
|
||||
"actionsClick": "Paspausti, kad išskleistumėte/suskleistumėte",
|
||||
"openOnDoubleClick": "Atidaryti dukart paspaudus",
|
||||
"openOnDoubleClickDesc": "Reikalauti dukart paspausti, kad atidarytumėte serverio sesiją, vietoj vieno paspaudimo",
|
||||
"closeOnOpen": "Slėpti šoninę juostą atidarius",
|
||||
"closeOnOpenDesc": "Uždaryti šoninę juostą, kai atidarote priimtuvą iš jos",
|
||||
"showFolderPaths": "Rodyti aplanko kelius",
|
||||
"showFolderPathsDesc": "Rodyti tėvinio aplanko kelią virš įdėto poaplanko pavadinimo. Poaplanai vis tiek lieka įtraukti.",
|
||||
"rowDetailsTitle": "Eilutės detalės",
|
||||
@@ -1127,7 +1125,7 @@
|
||||
"acceptNewKey": "Priimti naują raktą ir tęsti"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix serveris neatsako",
|
||||
"databaseConnection": "Nepavyko prisijungti prie duomenų bazės",
|
||||
"embeddedServerFailed": "Nepavyko paleisti Termix vietinio serverio",
|
||||
"unknownError": "Nežinoma klaida",
|
||||
"syncedHostMissing": "Sinchronizuotas serveris nesusietame serveryje neegzistuoja",
|
||||
@@ -1154,7 +1152,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Dar neturite Termix paskyros. Paprašykite administratoriaus įjungti funkciją „Automatiškai kurti išorines paskyras“.",
|
||||
"userNotAllowed": "Jūsų paskyra neturi teisės registruotis. Susisiekite su administratoriumi.",
|
||||
"databaseConnectionFailed": "Jis gali vis dar paleidžiamas arba sustojo. Šis puslapis nuolat bando prisijungti ir automatiškai atsinaujins, kai serveris vėl bus pasiekiamas.",
|
||||
"databaseConnectionFailed": "Nepavyko prisijungti prie duomenų bazės serverio",
|
||||
"embeddedServerPortInUse": "Prievadas {{port}} jau naudojamas kitos programos. Dažniausiai tai yra Termix Docker konteineris arba ankstesnės sesijos Termix procesas. Uždarykite jį ir paleiskite Termix iš naujo.",
|
||||
"embeddedServerPortInUseUnknownPort": "Viena iš prievadų, kurių Termix reikia, jau naudojama kitos programos. Uždarykite programą, kurią naudoja, ir paleiskite Termix iš naujo.",
|
||||
"embeddedServerOldDatabase": "Jūsų vietiniai duomenys yra iš Termix 2.8 arba senesnės versijos, todėl pirmiausia juos reikia atnaujinti per 2.9 versiją. Įdiekite Termix 2.9.1, atidarykite ją vieną kartą, kad duomenys būtų perkelti, tada vėl atnaujinkite į šią versiją.",
|
||||
@@ -1720,8 +1718,6 @@
|
||||
"pinAppRailDesc": "Visada išlaikyti kairę programų juostą išplėstą, vietoj to, kad ji išsiplėstų užvedus pelę",
|
||||
"expandAppRailOnHover": "Išplėsti programų juostą užvedus pelę",
|
||||
"expandAppRailOnHoverDesc": "Leisti kairiajai programų juostai išsiplėsti, kai pelės žymeklis užvedamas ant jos",
|
||||
"appRailHoverPushesContent": "Stumti turinį išsiplėtimo metu",
|
||||
"appRailHoverPushesContentDesc": "Sukurti vietos išsiplėtusiai juostai vietoj to, kad ji būtų rodoma virš puslapio. Atidarytos sesijos, pvz., nuotolinis darbalaukis, kiekvieną kartą keičia dydį",
|
||||
"showPinAppRailButton": "Rodyti programų juostos pritvirtinimo mygtuką",
|
||||
"showPinAppRailButtonDesc": "Rodyti mygtuką programų juostoje, kad būtų galima ją pritvirtinti. Numatyta išjungta.",
|
||||
"settingsNavigation": "Navigacija",
|
||||
@@ -2041,7 +2037,6 @@
|
||||
"syncEntities_other": "Darbalaukio sinchronizavimas"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Įgalinti „{{name}}“?",
|
||||
"installTitle": "Įdiegti {{name}}",
|
||||
"updateTitle": "Atnaujinti {{name}}",
|
||||
"agreeUpdate": "Sutikti ir atnaujinti",
|
||||
|
||||
@@ -650,8 +650,6 @@
|
||||
"actionsClick": "Klikšķināt, lai izvērstu/sakļautu",
|
||||
"openOnDoubleClick": "Atvērt ar dubultklikšķi",
|
||||
"openOnDoubleClickDesc": "Lai atvērtu mezгла sesiju, ir nepieciešams dubultklikšķis, nevis viens klikšķis",
|
||||
"closeOnOpen": "Apslēpt sānjoslu pēc atvēršanas",
|
||||
"closeOnOpenDesc": "Aizvērt sānjoslu pēc tam, kad no tās esat atvēris mezglu",
|
||||
"showFolderPaths": "Rādīt mapju ceļus",
|
||||
"showFolderPathsDesc": "Rādīt vecākmātes mapes ceļu virs iekšējām apakšmapēm. Apakšmapes joprojām ir atkārtas.",
|
||||
"rowDetailsTitle": "Rindas detaļas",
|
||||
@@ -1113,7 +1111,7 @@
|
||||
"acceptNewKey": "Pieņemt jauno atslēgu un turpināt"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Termix serveris neatbild",
|
||||
"databaseConnection": "Nevarēja izveidot savienojumu ar datubāzi",
|
||||
"embeddedServerFailed": "Termix vietējais serveris nevarēja palaisties",
|
||||
"unknownError": "Nezināma kļūda",
|
||||
"syncedHostMissing": "Sinhronizētais mezgls neeksistē saistītajā serverī",
|
||||
@@ -1140,7 +1138,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Jums vēl nav Termix konta. Lūdziet administratoru ieslēgt automātisko ārējo kontu izveidi.",
|
||||
"userNotAllowed": "Jūsu kontam nav atļauts reģistrēties. Lūdzu, sazinieties ar administratoru.",
|
||||
"databaseConnectionFailed": "Tas varētu vēl sākties vai ir apstājies. Šī lapa turpina mēģināt un automātiski atsāks darbu, kad serveris būs pieejams.",
|
||||
"databaseConnectionFailed": "Neizdevās izveidot savienojumu ar datubāzes serveri",
|
||||
"embeddedServerPortInUse": "Ports {{port}} jau tiek izmantots citā programmā. Parasti tas ir Termix Docker konteineris vai iepriekšējās sesijas Termix process. Aizveriet to un restartējiet Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Viens no portiem, kurus Termix nepieciešams, jau tiek izmantots citā programmā. Aizveriet to, kas to izmanto, un restartējiet Termix.",
|
||||
"embeddedServerOldDatabase": "Jūsu vietējie dati ir no Termix 2.8 vai vecākas versijas, un tiem vispirms ir jāiziet cauri 2.9 versijai. Instalējiet Termix 2.9.1, atveriet to vienu reizi, lai tas pārvietotu jūsu datus, pēc tam atkal atjauniniet uz šo versiju.",
|
||||
@@ -1697,8 +1695,6 @@
|
||||
"pinAppRailDesc": "Turiet kreiso sānjoslas lietotņu slīdi vienmēr izvērstu, nevis izvērstu, kad uz to norāda kursors",
|
||||
"expandAppRailOnHover": "Izvērst lietotņu slīdi, kad uz to norāda kursors",
|
||||
"expandAppRailOnHoverDesc": "Ļaujiet kreisajai sānjoslas lietotņu slīdij izvērsties, kad kursors pārvietojas pār to",
|
||||
"appRailHoverPushesContent": "Pārvietot saturu, kad tas izplešas",
|
||||
"appRailHoverPushesContentDesc": "Izveidot vietu izplestajai joslai, nevis atvērt to pār lapu. Atvērtās sesijas, piemēram, attālinātais darbvirsma, katru reizi maina izmēru",
|
||||
"showPinAppRailButton": "Rādīt pogu, lai piesprāustu lietotņu slīdi",
|
||||
"showPinAppRailButtonDesc": "Rādīt pogu lietotņu slīdē, lai to piesprāustu atvērtu. Noklusējuma stāvoklī ir izslēgts.",
|
||||
"settingsNavigation": "Navigācija",
|
||||
@@ -2006,7 +2002,6 @@
|
||||
"syncEntities_other": "Darbvirsas sinhronizācija"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Iespējot {{name}}?",
|
||||
"installTitle": "Instalēt {{name}}",
|
||||
"updateTitle": "Atjaunināt {{name}}",
|
||||
"agreeUpdate": "Piekrīt un atjaunina",
|
||||
|
||||
@@ -638,8 +638,6 @@
|
||||
"actionsClick": "Klik untuk kembangkan/runtuhkan",
|
||||
"openOnDoubleClick": "Buka dengan dwiklik",
|
||||
"openOnDoubleClickDesc": "Perlukan dwiklik untuk membuka sesi hos, bukan klik tunggal",
|
||||
"closeOnOpen": "Sembunyikan bar sisi selepas dibuka",
|
||||
"closeOnOpenDesc": "Tutup bar sisi selepas anda membuka hos daripadanya",
|
||||
"showFolderPaths": "Paparkan laluan folder",
|
||||
"showFolderPathsDesc": "Paparkan laluan folder induk di atas nama subfolder bersarang. Subfolder kekal bersebelah sama ada pilihan ini diaktifkan atau tidak.",
|
||||
"rowDetailsTitle": "Butiran baris",
|
||||
@@ -1085,7 +1083,7 @@
|
||||
"acceptNewKey": "Terima Kunci Baharu & Teruskan"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Pelayan Termix tidak memberi respons",
|
||||
"databaseConnection": "Tidak dapat menyambung ke pangkalan data",
|
||||
"embeddedServerFailed": "Pelayan tempatan Termix tidak dapat dimulakan",
|
||||
"unknownError": "Ralat tidak diketahui",
|
||||
"syncedHostMissing": "Hos yang diselaraskan tidak wujud pada pelayan yang dipautkan",
|
||||
@@ -1112,7 +1110,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Anda belum mempunyai akaun Termix. Minta pentadbir menghidupkan Cipta akaun luar secara automatik.",
|
||||
"userNotAllowed": "Akaun anda tidak dibenarkan untuk mendaftar. Sila hubungi pentadbir.",
|
||||
"databaseConnectionFailed": "Ia mungkin masih dalam proses permulaan, atau telah berhenti. Halaman ini akan terus cuba dan akan kembali berfungsi secara automatik apabila pelayan sedia.",
|
||||
"databaseConnectionFailed": "Gagal menyambung ke pelayan pangkalan data",
|
||||
"embeddedServerPortInUse": "Port {{port}} sudah digunakan oleh program lain. Ini biasanya bekas kontena Docker Termix atau proses Termix yang tinggal dari sesi sebelumnya. Tutupnya, kemudian mulakan semula Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Salah satu port yang diperlukan oleh Termix sudah digunakan oleh program lain. Tutup apa sahaja yang menggunakannya, kemudian mulakan semula Termix.",
|
||||
"embeddedServerOldDatabase": "Data tempatan anda adalah dari Termix 2.8 atau lebih lama dan perlu melalui versi 2.9 terlebih dahulu. Pasang Termix 2.9.1, buka sekali supaya ia boleh memindahkan data anda, kemudian kemaskini ke versi ini semula.",
|
||||
@@ -1651,8 +1649,6 @@
|
||||
"pinAppRailDesc": "Kekalkan landasan aplikasi bar sisi kiri sentiasa dikembangkan sebagai ganti mengembangkan semasa melayan",
|
||||
"expandAppRailOnHover": "Kembangkan Landasan Aplikasi Semasa Melayan",
|
||||
"expandAppRailOnHoverDesc": "Benarkan landasan aplikasi bar sisi kiri untuk dikembangkan apabila penunjuk bergerak di atasnya",
|
||||
"appRailHoverPushesContent": "Tolak kandungan apabila ia kembang",
|
||||
"appRailHoverPushesContentDesc": "Buat ruang untuk rel yang kembang bukannya membukanya di atas halaman. Sesi yang dibuka, seperti desktop jauh, akan saiz semula setiap kali",
|
||||
"showPinAppRailButton": "Paparkan Butang Pin Landasan Aplikasi",
|
||||
"showPinAppRailButtonDesc": "Paparkan butang dalam landasan aplikasi untuk meminyainya terbuka. Dimatikan secara lalai.",
|
||||
"settingsNavigation": "Navigasi",
|
||||
@@ -1936,7 +1932,6 @@
|
||||
"syncEntities_other": "Selaras desktop"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Dayakan {{name}}?",
|
||||
"installTitle": "Pasang {{name}}",
|
||||
"updateTitle": "Kemas kini {{name}}",
|
||||
"agreeUpdate": "Setuju dan kemas kini",
|
||||
|
||||
@@ -644,8 +644,6 @@
|
||||
"actionsClick": "Klikken om uit te vouwen/inklappen",
|
||||
"openOnDoubleClick": "Openen bij dubbelklik",
|
||||
"openOnDoubleClickDesc": "Vereis een dubbelklik om een hostsessie te openen in plaats van een enkele klik",
|
||||
"closeOnOpen": "Verberg zijbalk na openen",
|
||||
"closeOnOpenDesc": "Sluit de zijbalk nadat je een host vanuit de zijbalk hebt geopend",
|
||||
"showFolderPaths": "Mappaden tonen",
|
||||
"showFolderPathsDesc": "Toon het pad van de bovenliggende map boven geneste submapnamen. Submappen blijven in beide gevallen ingesprongen.",
|
||||
"rowDetailsTitle": "Regelgegevens",
|
||||
@@ -1099,7 +1097,7 @@
|
||||
"acceptNewKey": "Nieuwe sleutel accepteren & Doorgaan"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "De Termix-server reageert niet",
|
||||
"databaseConnection": "Kon geen verbinding maken met de database",
|
||||
"embeddedServerFailed": "De lokale server van Termix kon niet worden gestart",
|
||||
"unknownError": "Onbekende fout",
|
||||
"syncedHostMissing": "De gesynchroniseerde host bestaat niet op de gekoppelde server",
|
||||
@@ -1126,7 +1124,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "U heeft nog geen Termix-account. Vraag een beheerder om 'Automatisch externe accounts aanmaken' in te schakelen.",
|
||||
"userNotAllowed": "Uw account is niet gemachtigd om zich te registreren. Neem contact op met een beheerder.",
|
||||
"databaseConnectionFailed": "De server start mogelijk nog op of is gestopt. Deze pagina blijft proberen en gaat automatisch verder zodra de server weer beschikbaar is.",
|
||||
"databaseConnectionFailed": "Kan geen verbinding maken met de databaseserver",
|
||||
"embeddedServerPortInUse": "Poort {{port}} wordt al gebruikt door een ander programma. Dit is meestal een Termix Docker-container of een overgebleven Termix-proces van een eerdere sessie. Sluit deze af en start Termix opnieuw op.",
|
||||
"embeddedServerPortInUseUnknownPort": "Een van de poorten die Termix nodig heeft, wordt al gebruikt door een ander programma. Sluit het programma dat deze gebruikt en start Termix opnieuw op.",
|
||||
"embeddedServerOldDatabase": "Je lokale gegevens zijn afkomstig van Termix 2.8 of ouder en moeten eerst door versie 2.9 worden verwerkt. Installeer Termix 2.9.1, open het een keer zodat je gegevens kunnen worden gemigreerd en update daarna opnieuw naar deze versie.",
|
||||
@@ -1674,8 +1672,6 @@
|
||||
"pinAppRailDesc": "Houd de linker sidebar-app rail altijd uitgeklapt in plaats van alleen bij hover",
|
||||
"expandAppRailOnHover": "App Rail uitklappen bij hover",
|
||||
"expandAppRailOnHoverDesc": "Sta toe dat de linker sidebar-app rail uitklapt wanneer de aanwijzer erover beweegt",
|
||||
"appRailHoverPushesContent": "Inhoud verplaatsen bij uitklappen",
|
||||
"appRailHoverPushesContentDesc": "Maak ruimte voor de uitgeklapte zijbalk in plaats van deze over de pagina te tonen. Actieve sessies, zoals remote desktop, worden telkens opnieuw geschaald.",
|
||||
"showPinAppRailButton": "Knop 'App Rail vastzetten' tonen",
|
||||
"showPinAppRailButtonDesc": "Toon een knop in de app rail om deze open te houden. Standaard uitgeschakeld.",
|
||||
"settingsNavigation": "Navigatie",
|
||||
@@ -1971,7 +1967,6 @@
|
||||
"syncEntities_other": "Desktop-synchronisatie"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "{{name}} inschakelen?",
|
||||
"installTitle": "{{name}} installeren",
|
||||
"updateTitle": "{{name}} bijwerken",
|
||||
"agreeUpdate": "Akkoord en bijwerken",
|
||||
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user