mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89222bed85 | ||
|
|
ad46f42ab1 | ||
|
|
34a9c77114 | ||
|
|
2c785fe35b | ||
|
|
1cdbfc674c | ||
|
|
9d93fb0893 | ||
|
|
556f0406aa | ||
|
|
0589465e4e | ||
|
|
ec3cc9cf18 | ||
|
|
4c235bb23f | ||
|
|
b414b85746 | ||
|
|
d049588f7f | ||
|
|
86161d66e2 |
No files matched your search
@@ -115,9 +115,7 @@ jobs:
|
||||
Write-Host "Spectre libs present: $($found.FullName)"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
node scripts/install-windows-sharp.cjs
|
||||
run: npm ci
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 26.10.4
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pin classic layout plugin (fixes proxmox install)
|
||||
|
||||
## 26.10.3
|
||||
|
||||
### Added
|
||||
@@ -25,22 +19,9 @@
|
||||
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
|
||||
- A user who must have a second factor but has none can sign in only to set one up
|
||||
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
|
||||
- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app
|
||||
- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch
|
||||
- The linked server list holds one link per account, with room for more connection options per server
|
||||
- A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix
|
||||
- Plugins can create a folder with a color and icon and remove it when it is empty
|
||||
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
|
||||
- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine
|
||||
- Pasting a security key file now explains how to use it instead of failing to decode it
|
||||
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
|
||||
- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window
|
||||
- Plugins can show a web page in a native view in the desktop app, for sites that do not load in an iframe
|
||||
- The desktop app draws its own title bar in the colors of your theme instead of the native white one
|
||||
|
||||
### Changed
|
||||
|
||||
- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working
|
||||
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
|
||||
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
|
||||
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
|
||||
@@ -56,12 +37,6 @@
|
||||
- Importing hosts from a JSON or SSH config file and exporting hosts now come from the Import and Export plugin. Exports are now written to the audit log. Without the plugin there is no import or export
|
||||
- Generating and checking SSH keys in the credential editor and deploying a key to a host now come from the Credential Tools plugin. Deploys are now written to the audit log. Without the plugin those buttons are hidden
|
||||
|
||||
### Fixed
|
||||
|
||||
- Linking the desktop app to a server no longer stalls on the server dashboard after sign in
|
||||
- Exporting your data no longer fails when a plugin table has a text key, like saved passkeys
|
||||
- The Windows on ARM desktop app starts again instead of failing with a local server error
|
||||
|
||||
### Removed
|
||||
|
||||
- The desktop app no longer has its own client tunnel and native Remote Desktop code
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "26.10.3"
|
||||
sha256 "a0c9db16f144c1e2be6ce2f4ead9268a010d12e0d0608feb34976750661a2239"
|
||||
version "26.10.2"
|
||||
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -91,7 +91,6 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
||||
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
||||
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
||||
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
|
||||
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
|
||||
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
|
||||
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
|
||||
|
||||
+27
-69
@@ -9,8 +9,8 @@
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug",
|
||||
"sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
|
||||
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
@@ -22,43 +22,31 @@
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "audit-tools",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug",
|
||||
"sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b"
|
||||
},
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug",
|
||||
"sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
||||
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
||||
},
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.1/classic-layout-1.0.1.tmxplug",
|
||||
"sha256": "6b2d9acd77ef4b33fa0761c1aea7ed3c6171c325ee8febcedff94b5b2bc7be34",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
|
||||
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "classic-themes",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
|
||||
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
|
||||
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "credential-tools",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
|
||||
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug",
|
||||
"sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
|
||||
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -67,8 +55,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug",
|
||||
"sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
|
||||
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -83,49 +71,25 @@
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
|
||||
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
|
||||
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug",
|
||||
"sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
|
||||
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "host-status",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
|
||||
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170"
|
||||
},
|
||||
{
|
||||
"id": "hosts-config",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
|
||||
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad"
|
||||
},
|
||||
{
|
||||
"id": "import-export",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
|
||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58"
|
||||
},
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
||||
},
|
||||
{
|
||||
"id": "login-security",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
|
||||
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
@@ -147,8 +111,8 @@
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug",
|
||||
"sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
|
||||
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -195,8 +159,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug",
|
||||
"sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
|
||||
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -219,12 +183,6 @@
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
||||
},
|
||||
{
|
||||
"id": "sync",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug",
|
||||
"sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
@@ -250,8 +208,8 @@
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug",
|
||||
"sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
|
||||
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
@@ -266,8 +224,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug",
|
||||
"sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
|
||||
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -294,14 +252,14 @@
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug",
|
||||
"sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
|
||||
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
||||
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
|
||||
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
const { isolatedUrl } = require("./isolated-window.cjs");
|
||||
|
||||
function cleanBounds(bounds) {
|
||||
const value = (n) => Math.max(0, Math.round(Number(n) || 0));
|
||||
return {
|
||||
x: value(bounds?.x),
|
||||
y: value(bounds?.y),
|
||||
width: value(bounds?.width),
|
||||
height: value(bounds?.height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Native web views drawn inside the main window, for pages that do not work
|
||||
* in a sandboxed iframe. Each one is a WebContentsView in its own
|
||||
* non-persistent session, with the same limits as an isolated window: no
|
||||
* permissions, no downloads, http(s) and ws(s) only. The renderer owns the
|
||||
* placement and sends the bounds of the element the view should cover.
|
||||
*/
|
||||
function createEmbeddedViews({ WebContentsView, session, getMainWindow }) {
|
||||
const views = new Map();
|
||||
|
||||
function destroy(id) {
|
||||
const entry = views.get(id);
|
||||
if (!entry) return;
|
||||
views.delete(id);
|
||||
const main = getMainWindow();
|
||||
if (main && !main.isDestroyed()) {
|
||||
try {
|
||||
main.contentView.removeChildView(entry.view);
|
||||
} catch {
|
||||
/* already detached */
|
||||
}
|
||||
}
|
||||
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
|
||||
void Promise.allSettled([
|
||||
entry.session.clearStorageData(),
|
||||
entry.session.clearCache(),
|
||||
entry.session.closeAllConnections(),
|
||||
]);
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
for (const id of [...views.keys()]) destroy(id);
|
||||
}
|
||||
|
||||
function open(options = {}) {
|
||||
const main = getMainWindow();
|
||||
if (!main) throw new Error("No window to attach a view to");
|
||||
const id = String(options.id || "");
|
||||
if (!id) throw new Error("A view needs an id");
|
||||
const url = isolatedUrl(options.url);
|
||||
const existing = views.get(id);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.url === url.href &&
|
||||
existing.ignoreCert === !!options.ignoreCert
|
||||
) {
|
||||
setBounds(id, options.bounds);
|
||||
setVisible(id, options.visible !== false);
|
||||
return { success: true };
|
||||
}
|
||||
destroy(id);
|
||||
}
|
||||
|
||||
const isolated = session.fromPartition(
|
||||
`embedded-view-${id}-${Date.now()}`,
|
||||
{
|
||||
cache: false,
|
||||
},
|
||||
);
|
||||
isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false));
|
||||
isolated.setPermissionCheckHandler(() => false);
|
||||
isolated.on("will-download", (event) => event.preventDefault());
|
||||
isolated.webRequest.onBeforeRequest((details, callback) => {
|
||||
const protocol = new URL(details.url).protocol;
|
||||
callback({
|
||||
cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol),
|
||||
});
|
||||
});
|
||||
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
session: isolated,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
},
|
||||
});
|
||||
const canNavigate = (target) => {
|
||||
try {
|
||||
return target === "about:blank" || !!isolatedUrl(target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for (const name of ["will-navigate", "will-redirect"]) {
|
||||
view.webContents.on(name, (event, target) => {
|
||||
if (!canNavigate(target)) event.preventDefault();
|
||||
});
|
||||
}
|
||||
view.webContents.setWindowOpenHandler(({ url: target }) =>
|
||||
canNavigate(target)
|
||||
? {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: { session: isolated, sandbox: true },
|
||||
},
|
||||
}
|
||||
: { action: "deny" },
|
||||
);
|
||||
|
||||
views.set(id, {
|
||||
view,
|
||||
session: isolated,
|
||||
url: url.href,
|
||||
origin: url.origin,
|
||||
ignoreCert: !!options.ignoreCert,
|
||||
});
|
||||
main.contentView.addChildView(view);
|
||||
setBounds(id, options.bounds);
|
||||
setVisible(id, options.visible !== false);
|
||||
view.webContents.loadURL(url.href).catch(() => {});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function setBounds(id, bounds) {
|
||||
const entry = views.get(id);
|
||||
if (entry) entry.view.setBounds(cleanBounds(bounds));
|
||||
}
|
||||
|
||||
function setVisible(id, visible) {
|
||||
const entry = views.get(id);
|
||||
if (entry) entry.view.setVisible(!!visible);
|
||||
}
|
||||
|
||||
function handleCertificateError(event, contents, url, callback) {
|
||||
for (const entry of views.values()) {
|
||||
if (entry.view.webContents !== contents) continue;
|
||||
event.preventDefault();
|
||||
let allowed = false;
|
||||
try {
|
||||
allowed = entry.ignoreCert && new URL(url).origin === entry.origin;
|
||||
} catch {
|
||||
/* malformed URL is refused */
|
||||
}
|
||||
callback(allowed);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
setBounds,
|
||||
setVisible,
|
||||
destroy,
|
||||
destroyAll,
|
||||
handleCertificateError,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createEmbeddedViews, cleanBounds };
|
||||
@@ -34,8 +34,6 @@ function setLinkedServer(config) {
|
||||
basicAuth:
|
||||
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
|
||||
allowInvalidCertificate: !!config.allowInvalidCertificate,
|
||||
clientCert:
|
||||
config.clientCert && config.clientCert.pfx ? config.clientCert : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,16 +45,6 @@ function isLinkedOrigin(url) {
|
||||
return !!linked && getOrigin(url) === linked.origin;
|
||||
}
|
||||
|
||||
// The PFX of the link's client certificate, for TLS this process makes itself
|
||||
// to the linked origin. Null for any other origin.
|
||||
function clientCertOptionsFor(url) {
|
||||
if (!isLinkedOrigin(url) || !linked.clientCert) return {};
|
||||
return {
|
||||
pfx: Buffer.from(linked.clientCert.pfx, "base64"),
|
||||
passphrase: linked.clientCert.passphrase || "",
|
||||
};
|
||||
}
|
||||
|
||||
function basicAuthValue(basicAuth) {
|
||||
return `Basic ${Buffer.from(
|
||||
`${basicAuth.username}:${basicAuth.password || ""}`,
|
||||
@@ -111,7 +99,6 @@ module.exports = {
|
||||
setLinkedServer,
|
||||
getLinkedServer,
|
||||
isLinkedOrigin,
|
||||
clientCertOptionsFor,
|
||||
applyLinkHeaders,
|
||||
linkedRequestHeaders,
|
||||
answerLogin,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {
|
||||
app,
|
||||
BrowserWindow,
|
||||
WebContentsView,
|
||||
shell,
|
||||
ipcMain,
|
||||
dialog,
|
||||
@@ -31,10 +30,6 @@ const { quitApp } = require("./app-quit.cjs");
|
||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||
// Saved by the file manager after the user picks an editor in a file dialog.
|
||||
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
|
||||
|
||||
// The height of the title bar the app draws itself. The renderer uses the
|
||||
// same number, so the bar and the window controls always line up.
|
||||
const TITLE_BAR_HEIGHT = 40;
|
||||
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
|
||||
|
||||
// The main process's Node.js networking (the global `fetch`) only
|
||||
@@ -464,7 +459,6 @@ function isInvalidCertificateAllowedForUrl(url) {
|
||||
function getTlsVerificationOptions(url) {
|
||||
return {
|
||||
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
|
||||
...linkedServer.clientCertOptionsFor(url),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,38 +496,12 @@ app.commandLine.appendSwitch("--enable-features=NetworkService");
|
||||
|
||||
let mainWindow = null;
|
||||
const { createIsolatedWindows } = require("./isolated-window.cjs");
|
||||
const { createEmbeddedViews } = require("./embedded-view.cjs");
|
||||
const isolatedWindows = createIsolatedWindows({
|
||||
BrowserWindow,
|
||||
session,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
const embeddedViews = createEmbeddedViews({
|
||||
WebContentsView,
|
||||
session,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
// Only the app's own page may place native views, never a frame inside it.
|
||||
function fromMainFrame(event) {
|
||||
return !!mainWindow && event.senderFrame === mainWindow.webContents.mainFrame;
|
||||
}
|
||||
|
||||
ipcMain.handle("embedded-view-open", (event, options) => {
|
||||
if (!fromMainFrame(event)) throw new Error("Not allowed");
|
||||
return embeddedViews.open(options);
|
||||
});
|
||||
ipcMain.handle("embedded-view-bounds", (event, id, bounds) => {
|
||||
if (fromMainFrame(event)) embeddedViews.setBounds(String(id), bounds);
|
||||
});
|
||||
ipcMain.handle("embedded-view-visible", (event, id, visible) => {
|
||||
if (fromMainFrame(event)) embeddedViews.setVisible(String(id), visible);
|
||||
});
|
||||
ipcMain.handle("embedded-view-close", (event, id) => {
|
||||
if (fromMainFrame(event)) embeddedViews.destroy(String(id));
|
||||
});
|
||||
|
||||
// Requests from the embedded backend over the fork IPC channel (the other
|
||||
// direction from the "shutdown" message main already sends it). A plugin's
|
||||
// ctx.desktop.openIsolatedWindow is the one caller today.
|
||||
@@ -722,39 +690,6 @@ ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Mutual TLS for the linked server: Chromium asks which certificate from the
|
||||
// OS store to present. Other origins keep Chromium's default (none).
|
||||
const chosenClientCerts = new Map();
|
||||
|
||||
app.on(
|
||||
"select-client-certificate",
|
||||
async (event, _webContents, url, list, callback) => {
|
||||
if (!linkedServer.isLinkedOrigin(url) || !list.length) return;
|
||||
event.preventDefault();
|
||||
const origin = new URL(url).origin;
|
||||
const remembered = list.find(
|
||||
(cert) => cert.fingerprint === chosenClientCerts.get(origin),
|
||||
);
|
||||
let chosen = remembered || (list.length === 1 ? list[0] : null);
|
||||
if (!chosen) {
|
||||
const { response } = await dialog.showMessageBox(mainWindow, {
|
||||
type: "question",
|
||||
title: "Client certificate",
|
||||
message: "Choose the certificate for this server",
|
||||
detail: origin,
|
||||
buttons: [
|
||||
...list.slice(0, 8).map((cert) => cert.subjectName),
|
||||
"Cancel",
|
||||
],
|
||||
cancelId: Math.min(list.length, 8),
|
||||
});
|
||||
chosen = list[response] || null;
|
||||
}
|
||||
if (chosen) chosenClientCerts.set(origin, chosen.fingerprint);
|
||||
callback(chosen || undefined);
|
||||
},
|
||||
);
|
||||
|
||||
app.on(
|
||||
"certificate-error",
|
||||
(event, _webContents, url, error, certificate, callback) => {
|
||||
@@ -762,10 +697,6 @@ app.on(
|
||||
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
|
||||
)
|
||||
return;
|
||||
if (
|
||||
embeddedViews.handleCertificateError(event, _webContents, url, callback)
|
||||
)
|
||||
return;
|
||||
if (isWebEndpointCertificateAllowed(url)) {
|
||||
event.preventDefault();
|
||||
logToFile("Allowed invalid certificate for configured web endpoint", {
|
||||
@@ -1331,27 +1262,12 @@ function createWindow() {
|
||||
? "macOS"
|
||||
: "Linux";
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "Termix",
|
||||
// The app draws its own title bar so it follows the active theme. macOS
|
||||
// keeps the traffic lights on top of it, Windows and Linux get the
|
||||
// native controls as an overlay whose colors the renderer keeps in sync.
|
||||
titleBarStyle: isMac ? "hiddenInset" : "hidden",
|
||||
...(isMac
|
||||
? { trafficLightPosition: { x: 14, y: 16 } }
|
||||
: {
|
||||
titleBarOverlay: {
|
||||
color: "#0f0f14",
|
||||
symbolColor: "#e6e6ea",
|
||||
height: TITLE_BAR_HEIGHT,
|
||||
},
|
||||
}),
|
||||
icon: path.join(
|
||||
appRoot,
|
||||
"public",
|
||||
@@ -1558,24 +1474,7 @@ function createWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("did-start-navigation", (details) => {
|
||||
if (details.isMainFrame && !details.isSameDocument) {
|
||||
embeddedViews.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
const sendTitleBarVisible = () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send(
|
||||
"title-bar-visible-changed",
|
||||
titleBarVisible(mainWindow),
|
||||
);
|
||||
};
|
||||
mainWindow.on("enter-full-screen", sendTitleBarVisible);
|
||||
mainWindow.on("leave-full-screen", sendTitleBarVisible);
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
embeddedViews.destroyAll();
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
@@ -1603,50 +1502,10 @@ ipcMain.handle("get-app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
/** The window an IPC call came from, so the main window and the extra
|
||||
* windows (isolated views, sign-in) can each answer for themselves. */
|
||||
function windowFor(event) {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win && !win.isDestroyed()) return win;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
|
||||
return null;
|
||||
}
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
// Whether the app-drawn title bar shows in the window that asks. Only the
|
||||
// main window hides the native bar, and in full screen macOS hides the
|
||||
// traffic lights, so the bar goes away there too.
|
||||
function titleBarVisible(win) {
|
||||
return !!win && win === mainWindow && !win.isFullScreen();
|
||||
}
|
||||
|
||||
ipcMain.handle("get-title-bar-visible", (event) => {
|
||||
return titleBarVisible(windowFor(event));
|
||||
});
|
||||
|
||||
// The renderer sends the colors of the active theme so the window controls
|
||||
// the system draws (Windows and Linux) match the app.
|
||||
ipcMain.handle("set-title-bar-overlay", (event, colors) => {
|
||||
const win = windowFor(event);
|
||||
if (!win || process.platform === "darwin") return;
|
||||
const options = {};
|
||||
if (typeof colors?.color === "string" && colors.color) {
|
||||
options.color = colors.color;
|
||||
}
|
||||
if (typeof colors?.symbolColor === "string" && colors.symbolColor) {
|
||||
options.symbolColor = colors.symbolColor;
|
||||
}
|
||||
if (Object.keys(options).length === 0) return;
|
||||
try {
|
||||
win.setTitleBarOverlay(options);
|
||||
} catch (error) {
|
||||
logToFile("setTitleBarOverlay failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-embedded-server-status", () => {
|
||||
return {
|
||||
running:
|
||||
|
||||
@@ -32,19 +32,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
|
||||
},
|
||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||
|
||||
// Whether the app-drawn title bar shows in this window, and when it changes
|
||||
// (full screen hides it).
|
||||
getTitleBarVisible: () => ipcRenderer.invoke("get-title-bar-visible"),
|
||||
onTitleBarVisibleChange: (callback) => {
|
||||
const listener = (_event, visible) => callback(visible);
|
||||
ipcRenderer.on("title-bar-visible-changed", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("title-bar-visible-changed", listener);
|
||||
},
|
||||
// Keeps the window controls the system draws in the colors of the theme.
|
||||
setTitleBarOverlay: (colors) =>
|
||||
ipcRenderer.invoke("set-title-bar-overlay", colors),
|
||||
getEmbeddedServerStatus: () =>
|
||||
ipcRenderer.invoke("get-embedded-server-status"),
|
||||
|
||||
@@ -93,16 +80,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
||||
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
||||
|
||||
// Native web views placed over an element of the page (WebContentsView).
|
||||
embeddedView: {
|
||||
open: (options) => ipcRenderer.invoke("embedded-view-open", options),
|
||||
setBounds: (id, bounds) =>
|
||||
ipcRenderer.invoke("embedded-view-bounds", id, bounds),
|
||||
setVisible: (id, visible) =>
|
||||
ipcRenderer.invoke("embedded-view-visible", id, visible),
|
||||
close: (id) => ipcRenderer.invoke("embedded-view-close", id),
|
||||
},
|
||||
|
||||
invoke: invokeAllowed,
|
||||
});
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.4",
|
||||
"version": "26.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.4",
|
||||
"version": "26.10.3",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.4",
|
||||
"version": "26.10.3",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -15,7 +15,7 @@
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
|
||||
"prepare": "husky || true",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
|
||||
@@ -1,44 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.19
|
||||
|
||||
Plugin API 1.11.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `app.registerCompanionLayout` and `useCompanions`, so a plugin can let each host turn the panel bar on or off, move it and choose which panels show and in what order
|
||||
|
||||
## 1.0.18
|
||||
|
||||
Plugin API 1.10.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `clientCert` on the sync link, link patch and remote target, and the `client_cert` remote error kind, so a sync plugin can link to a server that needs mutual TLS
|
||||
|
||||
## 1.0.17
|
||||
|
||||
Plugin API 1.9.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running
|
||||
- `NativeView` in `@termix-ssh/plugin-sdk/ui`, which shows a web page in a native view in the desktop app, for sites that do not work in an iframe
|
||||
|
||||
## 1.0.16
|
||||
|
||||
Plugin API 1.8.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
|
||||
- `ctx.ssh.transports.provide` and the `ssh:transport` capability, so a plugin can carry SSH connections over its own stream
|
||||
- `ctx.hosts.folders.ensure` and `removeEmpty`
|
||||
|
||||
### Removed
|
||||
|
||||
- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin
|
||||
|
||||
## 1.0.15
|
||||
|
||||
Plugin API 1.7.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.19 | Plugin API 1.11.0
|
||||
SDK 1.0.15 | Plugin API 1.7.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.
|
||||
@@ -202,40 +202,37 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
||||
|
||||
### What ctx has
|
||||
|
||||
| Member | Capability | What it does |
|
||||
| ---------------------- | -------------------------------- | --------------------------------------------------------------- |
|
||||
| `pluginId`, `manifest` | none | Your id and manifest |
|
||||
| `log` | none | Log lines tagged with your plugin |
|
||||
| `db` | `db:own` | Your own tables |
|
||||
| `kv` | `kv:own` | Small key value state |
|
||||
| `files.dataDir()` | `files:own` | A folder of your own |
|
||||
| `settings` | none | Your settings |
|
||||
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
|
||||
| `http`, `ws` | `network:serve` | Routes and sockets |
|
||||
| `rbac` | none | Check the actor's permissions |
|
||||
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
|
||||
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
|
||||
| `credentials` | varies | The actor's saved keys, and protocol logins |
|
||||
| `services` | per service | Call other plugins and offer your own |
|
||||
| `events` | `events:core` for core topics | Emit and listen for events |
|
||||
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
|
||||
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
|
||||
| `process` | `process:spawn` | Run programs on the server |
|
||||
| `schedule` | none | Timers that stop on disable and never overlap |
|
||||
| `audit.record` | none | Write your own audit line |
|
||||
| `sync` | none | Sync your tables to a linked desktop |
|
||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
||||
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
|
||||
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
|
||||
| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream |
|
||||
| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty |
|
||||
| `users.getLocale` | `users:read` | A user's UI language |
|
||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
||||
| `currentActor()` | none | The user the current call runs for |
|
||||
| `disposables.add(fn)` | none | Extra cleanup on disable |
|
||||
| Member | Capability | What it does |
|
||||
| ---------------------- | -------------------------------- | -------------------------------------------------------------- |
|
||||
| `pluginId`, `manifest` | none | Your id and manifest |
|
||||
| `log` | none | Log lines tagged with your plugin |
|
||||
| `db` | `db:own` | Your own tables |
|
||||
| `kv` | `kv:own` | Small key value state |
|
||||
| `files.dataDir()` | `files:own` | A folder of your own |
|
||||
| `settings` | none | Your settings |
|
||||
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
|
||||
| `http`, `ws` | `network:serve` | Routes and sockets |
|
||||
| `rbac` | none | Check the actor's permissions |
|
||||
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
|
||||
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
|
||||
| `credentials` | varies | The actor's saved keys, and protocol logins |
|
||||
| `services` | per service | Call other plugins and offer your own |
|
||||
| `events` | `events:core` for core topics | Emit and listen for events |
|
||||
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
|
||||
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
|
||||
| `process` | `process:spawn` | Run programs on the server |
|
||||
| `schedule` | none | Timers that stop on disable and never overlap |
|
||||
| `audit.record` | none | Write your own audit line |
|
||||
| `sync` | none | Sync your tables to a linked desktop |
|
||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
||||
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
|
||||
| `users.getLocale` | `users:read` | A user's UI language |
|
||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
||||
| `currentActor()` | none | The user the current call runs for |
|
||||
| `disposables.add(fn)` | none | Extra cleanup on disable |
|
||||
|
||||
### The actor
|
||||
|
||||
@@ -304,9 +301,6 @@ router.post("/notes", async (req, res) => {
|
||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||
`hosts.edit` checks a core permission.
|
||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
||||
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
||||
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
||||
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
|
||||
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
|
||||
@@ -495,8 +489,6 @@ your plugin's error boundary and Suspense.
|
||||
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `registerRailItem` | A sidebar rail button |
|
||||
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
|
||||
| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle |
|
||||
| `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type |
|
||||
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
|
||||
| `registerHostAction` | A host button, palette entry or default connect |
|
||||
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
||||
@@ -632,9 +624,6 @@ Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the t
|
||||
exports with props is `ui.api.txt` in the Termix repo
|
||||
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
|
||||
|
||||
- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the
|
||||
page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders
|
||||
`fallback` in a browser.
|
||||
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
|
||||
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
|
||||
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
|
||||
@@ -718,14 +707,6 @@ lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`,
|
||||
refuses the login. A user who must have a second factor but has none gets a session that can only set one
|
||||
up, and `/users/me` reports `restriction: "second-factor-setup"`.
|
||||
|
||||
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
|
||||
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
|
||||
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
|
||||
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
|
||||
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
|
||||
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
|
||||
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
|
||||
|
||||
## Testing
|
||||
|
||||
```ts
|
||||
@@ -912,48 +893,46 @@ 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 |
|
||||
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
||||
| `sync:provide` | high | Run sync: answer every /sync request |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||
| `hosts:export` | high | Export hosts with their saved logins |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `ui:layout` | high | Replace the whole app layout |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
|
||||
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
|
||||
| `network:probe` | medium | Check that a host answers on a TCP port |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
| `ui:theme` | low | Add color themes, CSS only |
|
||||
| Capability | Risk | What it allows |
|
||||
| ------------------------ | -------- | ------------------------------------------------------- |
|
||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Add users and change their roles |
|
||||
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||
| `hosts:export` | high | Export hosts with their saved logins |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `ui:layout` | high | Replace the whole app layout |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
|
||||
| `network:probe` | medium | Check that a host answers on a TCP port |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
| `ui:theme` | low | Add color themes, CSS only |
|
||||
|
||||
An update that adds a capability waits for an admin to approve it.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.19",
|
||||
"version": "1.0.15",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -152,8 +152,6 @@
|
||||
"users:write",
|
||||
"auth:provide",
|
||||
"auth:policy",
|
||||
"sync:provide",
|
||||
"ssh:transport",
|
||||
"system:tls",
|
||||
"device:serial",
|
||||
"hosts:write",
|
||||
|
||||
@@ -236,448 +236,6 @@ export interface SyncEntityRegistration {
|
||||
*/
|
||||
export interface PluginSync {
|
||||
registerEntity: (entity: SyncEntityRegistration) => void;
|
||||
/** Needs sync:provide. Takes over /sync/v2 and /sync/link. Removed on deactivate. */
|
||||
provide: (provider: PluginSyncProvider) => () => void;
|
||||
/** Needs sync:provide. The replica store, change feed and link registry. */
|
||||
readonly host: PluginSyncHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin that owns server and desktop sync. Core forwards every request
|
||||
* under /sync to `handle` (an Express style handler) and answers 404 when no
|
||||
* plugin provides it. Call `next()` to let core answer instead.
|
||||
*/
|
||||
export interface PluginSyncProvider {
|
||||
handle: (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
next: (error?: unknown) => void,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** A kind of data that syncs, as the sync provider sees it. */
|
||||
export interface SyncEntityInfo {
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
/** Whether the row would show in a load even if it exists. */
|
||||
covers?: (syncId: string) => boolean;
|
||||
}
|
||||
|
||||
/** A row ready for the wire, with its content hash. */
|
||||
export interface SyncWireRow {
|
||||
syncId: string;
|
||||
wire: SyncRow;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface SyncRecordInfo {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
hash: string | null;
|
||||
deleted: boolean;
|
||||
error: string | null;
|
||||
errorHash: string | null;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface SyncConflictInfo {
|
||||
id: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
localRow: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SyncAccountInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface SyncProxyHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SyncBasicAuth {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** A client certificate for a server that asks for mutual TLS. */
|
||||
export interface SyncClientCert {
|
||||
/** Base64 of the PFX/P12 file. */
|
||||
pfx: string;
|
||||
passphrase: string;
|
||||
/** The file name, to show which certificate is saved. */
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/** Extra per server connection options. Kept open so new ones need no migration. */
|
||||
export type SyncLinkOptions = Record<string, unknown>;
|
||||
|
||||
/** One server a desktop is linked to, with a replica of its data. */
|
||||
export interface SyncLinkInfo {
|
||||
id: number;
|
||||
/** The local user the replica belongs to. */
|
||||
userId: string;
|
||||
serverUrl: string;
|
||||
serverName: string | null;
|
||||
serverVersion: string | null;
|
||||
sessionToken: string | null;
|
||||
customHeaders: SyncProxyHeader[];
|
||||
basicAuth: SyncBasicAuth | null;
|
||||
allowInvalidCertificate: boolean;
|
||||
clientCert: SyncClientCert | null;
|
||||
remoteUserId: string | null;
|
||||
remoteUsername: string | null;
|
||||
account: {
|
||||
remoteUserId?: string;
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
} | null;
|
||||
disabledTypes: string[];
|
||||
/** Extra connection options for this server, kept open for new ones. */
|
||||
options: SyncLinkOptions;
|
||||
knownTypes: string[];
|
||||
cursor: number;
|
||||
status: "idle" | "syncing" | "offline" | "error" | "signed_out";
|
||||
lastError: string | null;
|
||||
linkedAt: string;
|
||||
lastSyncAt: string | null;
|
||||
}
|
||||
|
||||
export interface SyncLinkCreate {
|
||||
userId: string;
|
||||
serverUrl: string;
|
||||
serverName?: string | null;
|
||||
serverVersion?: string | null;
|
||||
sessionToken: string;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: SyncClientCert | null;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
}
|
||||
|
||||
export type SyncLinkPatch = Partial<
|
||||
Pick<
|
||||
SyncLinkInfo,
|
||||
| "serverName"
|
||||
| "serverVersion"
|
||||
| "sessionToken"
|
||||
| "customHeaders"
|
||||
| "basicAuth"
|
||||
| "allowInvalidCertificate"
|
||||
| "clientCert"
|
||||
| "remoteUserId"
|
||||
| "remoteUsername"
|
||||
| "account"
|
||||
| "disabledTypes"
|
||||
| "options"
|
||||
| "knownTypes"
|
||||
| "cursor"
|
||||
| "status"
|
||||
| "lastError"
|
||||
| "lastSyncAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export type SyncRemoteErrorKind =
|
||||
| "offline"
|
||||
| "tls"
|
||||
| "client_cert"
|
||||
| "proxy"
|
||||
| "basic_auth"
|
||||
| "signed_out"
|
||||
| "server";
|
||||
|
||||
export interface SyncRemoteError extends Error {
|
||||
readonly kind: SyncRemoteErrorKind;
|
||||
readonly status?: number;
|
||||
}
|
||||
|
||||
export interface SyncRemoteTarget {
|
||||
serverUrl: string;
|
||||
sessionToken?: string | null;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: SyncClientCert | null;
|
||||
}
|
||||
|
||||
export interface SyncRemoteRequest {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SyncPushOp {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
baseRevision: number;
|
||||
op: "upsert" | "delete";
|
||||
row?: SyncRow;
|
||||
}
|
||||
|
||||
export type SyncPushResult =
|
||||
| { syncId: string; entityType: string; status: "ok"; revision: number }
|
||||
| {
|
||||
syncId: string;
|
||||
entityType: string;
|
||||
status: "conflict";
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}
|
||||
| { syncId: string; entityType: string; status: "rejected"; reason: string };
|
||||
|
||||
export interface SyncRemotePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
source: string;
|
||||
artifact: boolean;
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
desktop: string;
|
||||
syncEntities: string[];
|
||||
granted: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sync provider gets from core: the replica store, the change feed,
|
||||
* the linked server registry, sessions and a few helpers. Core keeps all of
|
||||
* it neutral; the provider decides when and how to use it. Needs sync:provide.
|
||||
*/
|
||||
export interface PluginSyncHost {
|
||||
/** True on the desktop app's own backend. */
|
||||
readonly embedded: boolean;
|
||||
info: { appName: () => Promise<string>; version: () => string };
|
||||
|
||||
entities: {
|
||||
list: () => SyncEntityInfo[];
|
||||
get: (type: string) => SyncEntityInfo | undefined;
|
||||
/** Registers core's own entities once. */
|
||||
ensureCore: () => Promise<void>;
|
||||
onChanged: (listener: () => void) => () => void;
|
||||
onLocalWrite: (listener: () => void) => () => void;
|
||||
};
|
||||
|
||||
/** Rows and bookkeeping of one local user's replica. */
|
||||
replica: {
|
||||
resolvers: (userId: string, onMissing?: () => void) => unknown;
|
||||
loadRows: (
|
||||
type: string,
|
||||
userId: string,
|
||||
resolvers: unknown,
|
||||
) => Promise<{ rows: Map<string, SyncWireRow>; failed: Set<string> }>;
|
||||
loadRow: (
|
||||
type: string,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
resolvers: unknown,
|
||||
) => Promise<SyncWireRow | null>;
|
||||
writeRow: (
|
||||
type: string,
|
||||
userId: string,
|
||||
wire: SyncRow,
|
||||
resolvers: unknown,
|
||||
) => Promise<void>;
|
||||
deleteRow: (type: string, userId: string, syncId: string) => Promise<void>;
|
||||
orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[];
|
||||
counts: (userId: string) => Promise<Record<string, number>>;
|
||||
wipe: (userId: string) => Promise<void>;
|
||||
regenerateSyncIds: (userId: string) => Promise<void>;
|
||||
removeSharedCopies: (userId: string) => Promise<void>;
|
||||
records: {
|
||||
list: (userId: string) => Promise<Map<string, SyncRecordInfo>>;
|
||||
put: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
values: { revision: number; hash: string | null; deleted: boolean },
|
||||
) => Promise<void>;
|
||||
setError: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
reason: string,
|
||||
hash: string,
|
||||
) => Promise<void>;
|
||||
clearErrors: (userId: string) => Promise<void>;
|
||||
listErrors: (userId: string) => Promise<SyncRecordInfo[]>;
|
||||
deleteForUser: (userId: string) => Promise<void>;
|
||||
key: (type: string, syncId: string) => string;
|
||||
};
|
||||
conflicts: {
|
||||
save: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
row: SyncRow,
|
||||
revision: number,
|
||||
) => Promise<void>;
|
||||
list: (userId: string) => Promise<SyncConflictInfo[]>;
|
||||
get: (userId: string, id: number) => Promise<SyncConflictInfo | null>;
|
||||
delete: (userId: string, id: number) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
/** The server side: what changed for an account and what a desktop pushed. */
|
||||
feed: {
|
||||
withUserLock: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
|
||||
changes: (
|
||||
userId: string,
|
||||
cursor: number,
|
||||
limit: number,
|
||||
types?: string[],
|
||||
) => Promise<{
|
||||
records: Array<{
|
||||
seq: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}>;
|
||||
hasMore: boolean;
|
||||
nextCursor: number;
|
||||
entityTypes: Array<{
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
}>;
|
||||
}>;
|
||||
parsePushOps: (body: unknown) => Promise<SyncPushOp[] | null>;
|
||||
applyPush: (userId: string, ops: SyncPushOp[]) => Promise<SyncPushResult[]>;
|
||||
/** Streams a change notice to the account's open event streams. */
|
||||
listen: (userId: string, res: unknown) => Promise<() => void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The linked server registry, kept in core so it is there before plugins.
|
||||
* One link per local user. Without a user, the active account is meant.
|
||||
*/
|
||||
links: {
|
||||
list: () => Promise<SyncLinkInfo[]>;
|
||||
get: (userId?: string) => Promise<SyncLinkInfo | null>;
|
||||
create: (link: SyncLinkCreate) => Promise<SyncLinkInfo>;
|
||||
update: (
|
||||
patch: SyncLinkPatch,
|
||||
userId?: string,
|
||||
) => Promise<SyncLinkInfo | null>;
|
||||
remove: (userId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Desktop accounts: the local user and the replica user of each server. */
|
||||
accounts: {
|
||||
list: () => Promise<SyncAccountInfo[]>;
|
||||
/** A new empty account, ready to be linked to a server. */
|
||||
create: () => Promise<string>;
|
||||
/** Which account the app signs in as from now on. */
|
||||
setActive: (userId: string) => Promise<void>;
|
||||
activeId: () => Promise<string | null>;
|
||||
/** Deletes an account and all its data. The last one cannot go. */
|
||||
remove: (userId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
http: {
|
||||
json: <T>(
|
||||
target: SyncRemoteTarget,
|
||||
pathname: string,
|
||||
request?: SyncRemoteRequest,
|
||||
) => Promise<T>;
|
||||
fetch: (
|
||||
target: SyncRemoteTarget,
|
||||
pathname: string,
|
||||
request?: SyncRemoteRequest,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: AsyncIterable<Uint8Array> | null;
|
||||
}>;
|
||||
normalizeServerUrl: (input: string) => string | null;
|
||||
isRemoteError: (error: unknown) => error is SyncRemoteError;
|
||||
};
|
||||
|
||||
sessions: {
|
||||
/** A long lived session for a linked desktop. Shows in the account's sessions. */
|
||||
issueDesktop: (
|
||||
userId: string,
|
||||
options: { days: number; deviceInfo: string },
|
||||
) => Promise<string>;
|
||||
revoke: (sessionId: string) => Promise<void>;
|
||||
find: (
|
||||
sessionId: string,
|
||||
) => Promise<{ expiresAt: string; deviceInfo: string | null } | null>;
|
||||
findUser: (
|
||||
userId: string,
|
||||
) => Promise<{ id: string; username: string } | null>;
|
||||
/** The user and session behind a token, or null when it is not valid. */
|
||||
verify: (
|
||||
token: string,
|
||||
) => Promise<{ userId: string; sessionId?: string } | null>;
|
||||
/** Sets the sign in cookie on a response, like a login does. A name sets another cookie. */
|
||||
setCookie: (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
value: string,
|
||||
options?: { name?: string; days?: number },
|
||||
) => void;
|
||||
/** Removes the sign in cookie, without ending the session behind it. */
|
||||
clearCookie: (req: unknown, res: unknown, name?: string) => void;
|
||||
/** Express middleware that signs the request in and sets userId and sessionId. */
|
||||
middleware: () => unknown;
|
||||
/** Express middleware that lets a request through only on a desktop. */
|
||||
desktopOnly: () => unknown;
|
||||
};
|
||||
|
||||
hosts: {
|
||||
findForSync: (
|
||||
userId: string,
|
||||
syncId: string,
|
||||
) => Promise<{ id: number; name: string | null } | null>;
|
||||
statusBySyncId: (userId: string) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
plugins: {
|
||||
listForDesktop: () => Promise<{
|
||||
plugins: SyncRemotePlugin[];
|
||||
registryKeys: unknown;
|
||||
}>;
|
||||
/** Sends a plugin archive; false when there is no such plugin to share. */
|
||||
sendPackage: (pluginId: string, res: unknown) => Promise<boolean>;
|
||||
mirror: (link: SyncLinkInfo) => Promise<void>;
|
||||
lastRemote: () => SyncRemotePlugin[];
|
||||
localNames: () => Map<string, string>;
|
||||
};
|
||||
|
||||
desktop: {
|
||||
/** Tells the app's main process how to reach the linked origin. */
|
||||
applyProxy: (link: SyncLinkInfo | null) => Promise<void>;
|
||||
deviceName: () => string;
|
||||
};
|
||||
|
||||
alerts: {
|
||||
send: (alert: {
|
||||
title: string;
|
||||
body: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
category: string;
|
||||
dedupeKey: string;
|
||||
link?: { tab: string };
|
||||
}) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1414,18 +972,6 @@ export interface PluginHosts {
|
||||
activity: PluginHostActivityApi;
|
||||
/** Read only hosts this plugin manages. */
|
||||
locks: PluginHostLocksApi;
|
||||
/** Folder rows for the acting user. Needs hosts:write. */
|
||||
folders: PluginHostFoldersApi;
|
||||
}
|
||||
|
||||
export interface PluginHostFoldersApi {
|
||||
/** Creates the folder row if missing and sets its color and icon. */
|
||||
ensure: (
|
||||
name: string,
|
||||
style?: { color?: string | null; icon?: string | null },
|
||||
) => Promise<void>;
|
||||
/** Removes the folder row and subfolders once no host is left in them. */
|
||||
removeEmpty: (name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PluginHostImportOptions {
|
||||
@@ -1808,29 +1354,7 @@ export interface PluginNetwork {
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A way to reach some hosts other than a plain TCP connection. `claims` is
|
||||
* asked for every connection, so it must be quick. `open` returns a stream
|
||||
* the SSH client runs over, like a relayed socket.
|
||||
*/
|
||||
export interface PluginSshTransportProvider {
|
||||
claims: (hostId: number) => boolean;
|
||||
open: (host: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
}) => Promise<import("node:stream").Duplex>;
|
||||
}
|
||||
|
||||
export interface PluginSshTransports {
|
||||
/** Needs ssh:transport. Removed on deactivate. */
|
||||
provide: (provider: PluginSshTransportProvider) => () => void;
|
||||
}
|
||||
|
||||
export interface PluginSsh {
|
||||
/** Other ways to reach hosts, used by every connection to them. */
|
||||
transports: PluginSshTransports;
|
||||
|
||||
/**
|
||||
* Whether a TCP port answers through the host's jump host chain. Chains are
|
||||
* shared and closed when idle. The acting user must be able to connect to
|
||||
|
||||
@@ -48,8 +48,6 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("db:core-refs", "high"),
|
||||
entry("auth:provide", "high"),
|
||||
entry("auth:policy", "high"),
|
||||
entry("sync:provide", "high"),
|
||||
entry("ssh:transport", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
entry("ui:layout", "high"),
|
||||
|
||||
@@ -35,17 +35,6 @@ export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
setZoomFactor?: (factor: number) => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
/** Whether the app-drawn title bar shows in this window. */
|
||||
getTitleBarVisible?: () => Promise<boolean>;
|
||||
/** Calls back when the title bar shows or hides; returns an unsubscribe. */
|
||||
onTitleBarVisibleChange?: (
|
||||
callback: (visible: boolean) => void,
|
||||
) => () => void;
|
||||
/** Keeps the window controls the system draws in the colors of the theme. */
|
||||
setTitleBarOverlay?: (colors: {
|
||||
color?: string;
|
||||
symbolColor?: string;
|
||||
}) => Promise<void>;
|
||||
getSetting?: (key: string) => Promise<string | null | undefined>;
|
||||
setSetting?: (key: string, value: string) => Promise<void>;
|
||||
|
||||
|
||||
@@ -814,66 +814,6 @@ export interface SettingsComponentProps {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/** What a companion starts with. Read from the plugin's own settings. */
|
||||
export interface CompanionDefaults {
|
||||
/** Open by itself the first time a matching tab shows. */
|
||||
open?: boolean;
|
||||
side?: "left" | "right" | "bottom";
|
||||
/** Pixels of width, or height at the bottom. */
|
||||
size?: number;
|
||||
/** Show the edge button that opens and closes it. Default true. */
|
||||
edgeButton?: boolean;
|
||||
}
|
||||
|
||||
/** A panel a tab type can show beside it, for a picker. */
|
||||
export interface CompanionInfo {
|
||||
/** The id registerCompanionLayout's hidden and order lists use. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
}
|
||||
|
||||
/** How a host wants the panel bar beside a tab. */
|
||||
export interface CompanionLayout {
|
||||
/** False removes the bar and its panels. */
|
||||
enabled?: boolean;
|
||||
/** Where a panel opens until the user moves it. */
|
||||
side?: "left" | "right" | "bottom";
|
||||
/** Companion ids in bar order. */
|
||||
order?: string[];
|
||||
/** Companion ids left out. */
|
||||
hidden?: string[];
|
||||
}
|
||||
|
||||
export interface CompanionProps {
|
||||
/** The tab it sits beside. */
|
||||
tab: PluginTabRecord;
|
||||
host?: PluginHostRecord;
|
||||
/** The host in the shape the connection APIs take. */
|
||||
sshHost?: Record<string, unknown>;
|
||||
/** The tab's surface handle (see TabHandle), read at call time. */
|
||||
getHandle: () => TabHandle | null;
|
||||
side: "left" | "right" | "bottom";
|
||||
/** False while the tab is in the background or the panel is closed. */
|
||||
isVisible: boolean;
|
||||
close: () => void;
|
||||
setSide: (side: "left" | "right" | "bottom") => void;
|
||||
}
|
||||
|
||||
export interface CompanionOptions {
|
||||
icon?: IconComponent;
|
||||
titleKey?: string;
|
||||
order?: number;
|
||||
/** Tab types it applies to. Any tab with a host when empty. */
|
||||
tabTypes?: string[];
|
||||
when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean;
|
||||
/**
|
||||
* A hook returning the starting state. It runs in the plugin's scope, so it
|
||||
* can read the plugin's settings.
|
||||
*/
|
||||
useDefaults?: () => CompanionDefaults;
|
||||
}
|
||||
|
||||
export type ActionHandler = (...args: never[]) => unknown;
|
||||
|
||||
export interface ActionSlotDefinition {
|
||||
@@ -1128,26 +1068,6 @@ export interface TermixApp extends TermixAppInfo {
|
||||
component: ComponentType<TabProps>,
|
||||
options?: TabOptions,
|
||||
) => Disposer;
|
||||
/**
|
||||
* A panel that sits beside any host tab the options match (a terminal, a
|
||||
* desktop, ...), bound to that tab's host. The user opens, resizes and moves
|
||||
* it; the tab keeps running underneath.
|
||||
*/
|
||||
registerCompanion: (
|
||||
id: string,
|
||||
component: ComponentType<CompanionProps>,
|
||||
options?: CompanionOptions,
|
||||
) => Disposer;
|
||||
/**
|
||||
* Lets a plugin decide, per host, how the panel bar looks on one tab type.
|
||||
* Read from the host payload, so it must not call hooks.
|
||||
*/
|
||||
registerCompanionLayout: (
|
||||
tabType: string,
|
||||
resolve: (host: PluginHostRecord | undefined) => CompanionLayout,
|
||||
) => Disposer;
|
||||
/** Opens a companion on a tab, or closes it when it is showing. */
|
||||
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
|
||||
registerHostEditorSection: (
|
||||
section: HostEditorSectionContribution,
|
||||
) => Disposer;
|
||||
@@ -1375,7 +1295,6 @@ export interface PluginHostBridge {
|
||||
hostProtocols: (host: PluginHostRecord) => string[];
|
||||
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
|
||||
useHostActions: () => HostActionContribution[];
|
||||
useCompanions: (tabType: string) => CompanionInfo[];
|
||||
useActivityTypes: () => string[];
|
||||
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
||||
useExtensions: (pointId: string) => ExtensionContribution[];
|
||||
@@ -1785,14 +1704,6 @@ export function useHostActions(): HostActionContribution[] {
|
||||
return requireHost().useHostActions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every panel that can open beside a tab of this type, for a settings screen
|
||||
* that lets the user hide or reorder them. Reactive.
|
||||
*/
|
||||
export function useCompanions(tabType: string): CompanionInfo[] {
|
||||
return requireHost().useCompanions(tabType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent-activity types every registered tab claims (registerTab's
|
||||
* activityTypes), core's and plugins'. For a filter that lists what a user
|
||||
|
||||
@@ -37,7 +37,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.11.0";
|
||||
export const PLUGIN_API_VERSION = "1.7.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
|
||||
@@ -68,7 +68,7 @@ import type {
|
||||
PluginSshKeyCredential,
|
||||
PluginSshKeyCredentialInput,
|
||||
} from "./backend.js";
|
||||
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
import type {
|
||||
PluginExternalClientRequest,
|
||||
@@ -104,8 +104,6 @@ export interface FakeScheduledJob {
|
||||
|
||||
export interface FakeContextOptions {
|
||||
pluginId?: string;
|
||||
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||
syncHost?: Partial<PluginSyncHost>;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Acting user returned by currentActor and used by asUser. */
|
||||
actor?: string;
|
||||
@@ -758,12 +756,6 @@ export function createFakeContext(
|
||||
registerEntity: (entity) => {
|
||||
syncEntities.push(entity);
|
||||
},
|
||||
provide: () => () => {},
|
||||
get host() {
|
||||
if (!options.syncHost)
|
||||
throw new Error("Pass syncHost to use ctx.sync.host");
|
||||
return options.syncHost as PluginSyncHost;
|
||||
},
|
||||
},
|
||||
|
||||
registry: {
|
||||
@@ -1070,10 +1062,6 @@ export function createFakeContext(
|
||||
};
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
ensure: async () => {},
|
||||
removeEmpty: async () => true,
|
||||
},
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
hostLockProviders.push(provider);
|
||||
@@ -1152,7 +1140,6 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
ssh: {
|
||||
transports: { provide: () => () => {} },
|
||||
probe: async (hostId, probeOptions) =>
|
||||
options.reachable?.(hostId, probeOptions?.port) ?? false,
|
||||
connect: async (host, connectOptions) => {
|
||||
@@ -1595,8 +1582,6 @@ export function createFakeContext(
|
||||
|
||||
export interface MockContextOptions {
|
||||
pluginId?: string;
|
||||
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||
syncHost?: Partial<PluginSyncHost>;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Capabilities the plugin is granted. Anything else throws. */
|
||||
capabilities?: string[];
|
||||
@@ -1759,7 +1744,6 @@ export function createMockCtx(
|
||||
|
||||
const base = createFakeContext({
|
||||
pluginId: options.pluginId,
|
||||
syncHost: options.syncHost,
|
||||
actor: options.actor,
|
||||
settings: options.settings,
|
||||
coreSettings: options.coreSettings,
|
||||
@@ -2012,16 +1996,6 @@ export function createMockCtx(
|
||||
return ctx.hosts.activity.provide(provider);
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
ensure: async (name, style) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.folders.ensure(name, style);
|
||||
},
|
||||
removeEmpty: async (name) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.folders.removeEmpty(name);
|
||||
},
|
||||
},
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
require("hosts:lock");
|
||||
@@ -2055,12 +2029,6 @@ export function createMockCtx(
|
||||
|
||||
ssh: {
|
||||
...ctx.ssh,
|
||||
transports: {
|
||||
provide: (provider) => {
|
||||
require("ssh:transport");
|
||||
return ctx.ssh.transports.provide(provider);
|
||||
},
|
||||
},
|
||||
probe: async (hostId, probeOptions) => {
|
||||
require("ssh:connect");
|
||||
return ctx.ssh.probe(hostId, probeOptions);
|
||||
|
||||
@@ -139,7 +139,6 @@ const offline = () =>
|
||||
}),
|
||||
hostProtocols: () => ["ssh"],
|
||||
useHostActions: () => [],
|
||||
useCompanions: () => [],
|
||||
useActivityTypes: () => [],
|
||||
activityTarget: () => undefined,
|
||||
useExtensions: () => [],
|
||||
|
||||
@@ -176,6 +176,7 @@ LANGUAGES (value): readonly { code: string; label: string; }[]
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
|
||||
LinkedAccountInfo (type): LinkedAccountInfo
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
@@ -196,8 +197,6 @@ ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value
|
||||
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
|
||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
|
||||
NativeViewProps (type): NativeViewProps
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||
OpenTabRecord (type): OpenTabRecord
|
||||
@@ -228,6 +227,7 @@ PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDiv
|
||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ProxyNode (type): ProxyNode
|
||||
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
|
||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||
RadialGaugeProps (type): RadialGaugeProps
|
||||
RailGroup (type): RailGroup
|
||||
@@ -288,6 +288,12 @@ SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
SyncBasicAuth (type): SyncBasicAuth
|
||||
SyncConflictItem (type): SyncConflictItem
|
||||
SyncEntityInfo (type): SyncEntityInfo
|
||||
SyncErrorItem (type): SyncErrorItem
|
||||
SyncProxyHeader (type): SyncProxyHeader
|
||||
SyncStatus (type): SyncStatus
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
@@ -437,6 +443,8 @@ getSessionTimeoutMinutes (value): () => Promise<number>
|
||||
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
|
||||
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
|
||||
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
|
||||
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
|
||||
getSyncErrors (value): () => Promise<SyncErrorItem[]>
|
||||
getTabType (value): (id: string) => TabTypeDef
|
||||
getTlsStatus (value): () => Promise<TlsStatus>
|
||||
getUpdateChannel (value): () => Promise<UpdateChannelState>
|
||||
@@ -501,6 +509,7 @@ quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef
|
||||
railItemLabel (value): (id: string, t: (key: string) => string) => string
|
||||
readFromClipboard (value): () => Promise<string>
|
||||
recordHostActionPreference (value): (hostId: string, action: TabType) => void
|
||||
refreshSyncStatus (value): () => void
|
||||
registerLayout (value): (item: RegisteredLayout) => () => void
|
||||
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
|
||||
releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
@@ -523,6 +532,7 @@ resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaul
|
||||
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
|
||||
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
retrySyncErrors (value): () => Promise<SyncStatus>
|
||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
||||
@@ -546,17 +556,21 @@ setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||
setSyncStatus (value): (next: SyncStatus) => void
|
||||
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
|
||||
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
|
||||
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
|
||||
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
|
||||
shell (value): TabShellCallbacks
|
||||
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||
syncNow (value): () => Promise<SyncStatus>
|
||||
tabIcon (value): (type: TabType) => Element
|
||||
tabJumpHotkeyKeys (value): () => string[]
|
||||
takePendingManageRequest (value): () => ManageRequest
|
||||
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
|
||||
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
|
||||
@@ -574,6 +588,7 @@ updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
|
||||
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
|
||||
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
|
||||
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
|
||||
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
|
||||
usageBarColor (value): (percent: number) => string
|
||||
usageColor (value): (percent: number) => string
|
||||
@@ -586,7 +601,6 @@ useChooseTheme (value): () => (themeId: string) => void
|
||||
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
||||
useConnectionGate (value): () => ConnectionGateController
|
||||
useConnectionLog (value): () => ConnectionLogContextType
|
||||
useDesktopLinked (value): () => boolean
|
||||
useDesktopUpdate (value): () => DesktopUpdateState
|
||||
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
|
||||
useHostActions (value): () => HostActionDef[]
|
||||
@@ -621,6 +635,7 @@ useSshAuthEditors (value): () => SshAuthEditorDef[]
|
||||
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
|
||||
useStatusColorScheme (value): () => StatusColorScheme
|
||||
useSurfaceKind (value): () => SurfaceKind
|
||||
useSyncStatus (value): () => any
|
||||
useTabHandlesVersion (value): () => number
|
||||
useTabs (value): () => TabContextType
|
||||
useTabsSafe (value): () => TabContextType
|
||||
|
||||
@@ -197,6 +197,7 @@ const ALLOWED = [
|
||||
"Label",
|
||||
"LineChart",
|
||||
"LineChartSeries",
|
||||
"LinkServerDialog",
|
||||
"LinkedAccountInfo",
|
||||
"ListBadge",
|
||||
"ListRow",
|
||||
@@ -217,8 +218,6 @@ const ALLOWED = [
|
||||
"Meter",
|
||||
"MetricCard",
|
||||
"MiniStat",
|
||||
"NativeView",
|
||||
"NativeViewProps",
|
||||
"NumberField",
|
||||
"OPEN_PLUGINS_EVENT",
|
||||
"OpenTabRecord",
|
||||
@@ -249,6 +248,7 @@ const ALLOWED = [
|
||||
"PopoverContent",
|
||||
"PopoverTrigger",
|
||||
"ProxyNode",
|
||||
"ProxySettingsFields",
|
||||
"RadialGauge",
|
||||
"RadialGaugeProps",
|
||||
"RailGroup",
|
||||
@@ -309,6 +309,12 @@ const ALLOWED = [
|
||||
"SurfaceScope",
|
||||
"Switch",
|
||||
"SwitchRow",
|
||||
"SyncBasicAuth",
|
||||
"SyncConflictItem",
|
||||
"SyncEntityInfo",
|
||||
"SyncErrorItem",
|
||||
"SyncProxyHeader",
|
||||
"SyncStatus",
|
||||
"TOTPDialog",
|
||||
"Tab",
|
||||
"TabShellCallbacks",
|
||||
@@ -458,6 +464,8 @@ const ALLOWED = [
|
||||
"getSessions",
|
||||
"getStatusCheckSettings",
|
||||
"getStatusClasses",
|
||||
"getSyncConflicts",
|
||||
"getSyncErrors",
|
||||
"getTabType",
|
||||
"getTlsStatus",
|
||||
"getUpdateChannel",
|
||||
@@ -522,6 +530,7 @@ const ALLOWED = [
|
||||
"railItemLabel",
|
||||
"readFromClipboard",
|
||||
"recordHostActionPreference",
|
||||
"refreshSyncStatus",
|
||||
"registerLayout",
|
||||
"registerTabCloseGuard",
|
||||
"releaseUrlFrom",
|
||||
@@ -544,6 +553,7 @@ const ALLOWED = [
|
||||
"resolveHostDefaults",
|
||||
"resolveHostTabType",
|
||||
"resolveRemoteHostId",
|
||||
"retrySyncErrors",
|
||||
"revokeAllUserSessions",
|
||||
"revokeCredentialAccess",
|
||||
"revokeFolderAccess",
|
||||
@@ -567,17 +577,21 @@ const ALLOWED = [
|
||||
"setHostAuthOverride",
|
||||
"setNotificationPrivateEndpoints",
|
||||
"setPluginOptions",
|
||||
"setSyncStatus",
|
||||
"setUpdateChannel",
|
||||
"settleSyncConflict",
|
||||
"shareCredential",
|
||||
"shareFolder",
|
||||
"shareHost",
|
||||
"shell",
|
||||
"splitDefaultKey",
|
||||
"sshHostToHost",
|
||||
"syncNow",
|
||||
"tabIcon",
|
||||
"tabJumpHotkeyKeys",
|
||||
"takePendingManageRequest",
|
||||
"unlinkExternalFromPasswordAccount",
|
||||
"unlinkServer",
|
||||
"updateCredential",
|
||||
"updateExternalAutoProvision",
|
||||
"updateFolderMetadata",
|
||||
@@ -595,6 +609,7 @@ const ALLOWED = [
|
||||
"updateSessionTimeout",
|
||||
"updateHttpsSettings",
|
||||
"updateStatusCheckSettings",
|
||||
"updateSyncSettings",
|
||||
"uploadTlsCertificate",
|
||||
"usageBarColor",
|
||||
"usageColor",
|
||||
@@ -607,7 +622,6 @@ const ALLOWED = [
|
||||
"useConfirm",
|
||||
"useConnectionGate",
|
||||
"useConnectionLog",
|
||||
"useDesktopLinked",
|
||||
"useDesktopUpdate",
|
||||
"useEditorDefaultKeys",
|
||||
"useHostActions",
|
||||
@@ -642,6 +656,7 @@ const ALLOWED = [
|
||||
"useSshAuthProviders",
|
||||
"useStatusColorScheme",
|
||||
"useSurfaceKind",
|
||||
"useSyncStatus",
|
||||
"useTabHandlesVersion",
|
||||
"useTabs",
|
||||
"useTabsSafe",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
function findPackage(start) {
|
||||
let directory = path.dirname(start);
|
||||
while (directory !== path.dirname(directory)) {
|
||||
const manifest = path.join(directory, "package.json");
|
||||
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
|
||||
directory = path.dirname(directory);
|
||||
}
|
||||
throw new Error("Could not locate the installed sharp package manifest");
|
||||
}
|
||||
|
||||
// The build runs on x64 but ships arm64 and ia32 too, so npm ci alone
|
||||
// only installs the x64 binary.
|
||||
const sharpPackage = findPackage(require.resolve("sharp"));
|
||||
const packages = [
|
||||
"@img/sharp-win32-arm64",
|
||||
"@img/sharp-win32-ia32",
|
||||
"@img/sharp-win32-x64",
|
||||
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
|
||||
|
||||
// A .cmd file only starts through a shell on current Node.
|
||||
execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
|
||||
const SK_ED = "sk-ssh-ed25519@openssh.com";
|
||||
const SK_EC = "sk-ecdsa-sha2-nistp256@openssh.com";
|
||||
|
||||
function replaceOnce(source, from, to, label) {
|
||||
const first = source.indexOf(from);
|
||||
if (first === -1 || source.indexOf(from, first + 1) !== -1) {
|
||||
throw new Error(`ssh2 changed (${label}); review the security key patch`);
|
||||
}
|
||||
return source.replace(from, () => to);
|
||||
}
|
||||
|
||||
function patchKeyParserSource(source) {
|
||||
if (source.includes("// termix-sk")) return source;
|
||||
let out = replaceOnce(
|
||||
source,
|
||||
` case 'ecdsa-sha2-nistp521':
|
||||
return true;
|
||||
case 'ssh-ed25519':
|
||||
if (eddsaSupported)
|
||||
return true;`,
|
||||
` case 'ecdsa-sha2-nistp521':
|
||||
return true;
|
||||
case '${SK_EC}': // termix-sk
|
||||
return true;
|
||||
case '${SK_ED}':
|
||||
case 'ssh-ed25519':
|
||||
if (eddsaSupported)
|
||||
return true;`,
|
||||
"key types",
|
||||
);
|
||||
out = replaceOnce(
|
||||
out,
|
||||
` default:
|
||||
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
|
||||
}
|
||||
|
||||
return new OpenSSH_Public(`,
|
||||
` case '${SK_ED}':
|
||||
case '${SK_EC}': {
|
||||
// Public blob only; the security key signs, so there is no private half.
|
||||
const rest = data.subarray(data._pos || 0);
|
||||
const typeLen = Buffer.byteLength(baseType);
|
||||
pubSSH = Buffer.allocUnsafe(4 + typeLen + rest.length);
|
||||
writeUInt32BE(pubSSH, typeLen, 0);
|
||||
pubSSH.utf8Write(baseType, 4, typeLen);
|
||||
pubSSH.set(rest, 4 + typeLen);
|
||||
algo = baseType === '${SK_ED}' ? null : 'sha256';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
|
||||
}
|
||||
|
||||
return new OpenSSH_Public(`,
|
||||
"parseDER",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function patchAgentSource(source) {
|
||||
if (source.includes("skTrailer")) return source;
|
||||
return replaceOnce(
|
||||
source,
|
||||
` binaryParser.init(signature, 0);
|
||||
binaryParser.readString(true);
|
||||
signature = binaryParser.readString();
|
||||
binaryParser.clear();`,
|
||||
` binaryParser.init(signature, 0);
|
||||
binaryParser.readString(true);
|
||||
signature = binaryParser.readString();
|
||||
// Security key signatures end with flags and a counter.
|
||||
const skTrailer = signature === undefined
|
||||
? undefined
|
||||
: binaryParser.readRaw();
|
||||
binaryParser.clear();
|
||||
if (skTrailer && skTrailer.length)
|
||||
signature.skTrailer = Buffer.from(skTrailer);`,
|
||||
"agent sign response",
|
||||
);
|
||||
}
|
||||
|
||||
function patchProtocolSource(source) {
|
||||
if (source.includes("skTrailer")) return source;
|
||||
const start = source.indexOf(" authPK(username, pubKey, keyAlgo, cbSign) {");
|
||||
const end = source.indexOf(" authHostbased(", start);
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error("ssh2 changed (authPK); review the security key patch");
|
||||
}
|
||||
let region = source.slice(start, end);
|
||||
region = replaceOnce(
|
||||
region,
|
||||
` signature = convertSignature(signature, keyType);`,
|
||||
` const skTrailer = signature && signature.skTrailer;
|
||||
signature = convertSignature(signature, keyType);`,
|
||||
"authPK sign",
|
||||
);
|
||||
region = replaceOnce(
|
||||
region,
|
||||
` + 4 + algoLen + 4 + sigLen
|
||||
);`,
|
||||
` + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0)
|
||||
);`,
|
||||
"authPK alloc",
|
||||
);
|
||||
region = replaceOnce(
|
||||
region,
|
||||
` writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);`,
|
||||
` writeUInt32BE(
|
||||
packet,
|
||||
4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0),
|
||||
p += pubKeyLen
|
||||
);`,
|
||||
"authPK length",
|
||||
);
|
||||
region = replaceOnce(
|
||||
region,
|
||||
` packet.set(signature, p += 4);`,
|
||||
` packet.set(signature, p += 4);
|
||||
if (skTrailer)
|
||||
packet.set(skTrailer, p + sigLen);`,
|
||||
"authPK copy",
|
||||
);
|
||||
return source.slice(0, start) + region + source.slice(end);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const targets = [
|
||||
["ssh2/lib/protocol/keyParser.js", patchKeyParserSource],
|
||||
["ssh2/lib/agent.js", patchAgentSource],
|
||||
["ssh2/lib/protocol/Protocol.js", patchProtocolSource],
|
||||
];
|
||||
for (const [id, patch] of targets) {
|
||||
const target = require.resolve(id);
|
||||
const source = fs.readFileSync(target, "utf8");
|
||||
const result = patch(source);
|
||||
if (result !== source) fs.writeFileSync(target, result);
|
||||
}
|
||||
console.log(
|
||||
"[patch-ssh2-sk] Security key public keys and signatures enabled",
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
patchKeyParserSource,
|
||||
patchAgentSource,
|
||||
patchProtocolSource,
|
||||
};
|
||||
@@ -27,7 +27,8 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { syncForwarder } from "../sync/provider.js";
|
||||
import syncRoutes from "../sync/server/routes.js";
|
||||
import syncLinkRoutes from "../sync/client/routes.js";
|
||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import {
|
||||
@@ -35,7 +36,6 @@ import {
|
||||
mountPluginLegacyPaths,
|
||||
} from "./routes/plugin-api-routes.js";
|
||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import { registerSshRelay } from "../hosts/ssh-relay.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
@@ -1625,7 +1625,8 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/sync", syncForwarder);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/sync", syncLinkRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
@@ -1899,7 +1900,6 @@ const httpServer = http.createServer(app);
|
||||
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
|
||||
// left alone, so core's own upgrade handling is unaffected.
|
||||
attachPluginWebSockets(httpServer);
|
||||
registerSshRelay();
|
||||
|
||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
|
||||
@@ -236,29 +236,6 @@ export class HostFolderRepository {
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
/** Drops the folder rows (and subfolders) only when no host is left in them. */
|
||||
async deleteEmptyFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<boolean> {
|
||||
if ((await this.listHostsInFolder(userId, folderName)).length > 0) {
|
||||
return false;
|
||||
}
|
||||
await deleteReturning(
|
||||
this.context,
|
||||
sshFolders,
|
||||
and(
|
||||
eq(sshFolders.userId, userId),
|
||||
or(
|
||||
eq(sshFolders.name, folderName),
|
||||
like(sshFolders.name, `${folderName} / %`),
|
||||
),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteHostsAndFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
|
||||
@@ -61,23 +61,15 @@ export function isNativeTokenExportRequest(req: Request): boolean {
|
||||
* during testing, or a household sharing one install) -- a user in that
|
||||
* state deserves to get into the app they installed, not a confusing,
|
||||
* unexplained dead end. So this always returns a single, deterministic
|
||||
* user: the account the user switched to if it still exists, else the admin
|
||||
* account if one exists, else the earliest-registered account. It never
|
||||
* returns null when there are users.
|
||||
* user: the admin account if one exists, else the earliest-registered
|
||||
* account. It never returns null.
|
||||
*/
|
||||
export function resolveDesktopAutoSessionUser(
|
||||
allUsers: UserRecord[],
|
||||
preferredId?: string | null,
|
||||
): UserRecord | null {
|
||||
if (allUsers.length === 0) return null;
|
||||
if (allUsers.length === 1) return allUsers[0];
|
||||
|
||||
// An account the user switched to on purpose, while it still exists.
|
||||
const preferred = preferredId
|
||||
? allUsers.find((user) => user.id === preferredId)
|
||||
: undefined;
|
||||
if (preferred) return preferred;
|
||||
|
||||
const admin = allUsers.find((user) => user.isAdmin);
|
||||
if (admin) return admin;
|
||||
|
||||
|
||||
@@ -61,38 +61,4 @@ export function registerUserSocketTicketRoutes(
|
||||
res.status(500).json({ error: "Failed to issue a socket ticket" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ws-ticket/api-key:
|
||||
* post:
|
||||
* summary: Get a WebSocket ticket for an API key
|
||||
* description: For another Termix server that manages this one with an API key. Returns a token that is valid for one minute and only opens plugin WebSockets, as the key's user. It cannot call any other route.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The ticket.
|
||||
* 401:
|
||||
* description: The request did not use an API key.
|
||||
* 500:
|
||||
* description: Failed to issue the ticket.
|
||||
*/
|
||||
router.post("/ws-ticket/api-key", authenticateJWT, async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
if (!authReq.apiKeyId) {
|
||||
return res.status(401).json({ error: "An API key is required" });
|
||||
}
|
||||
try {
|
||||
const ticket = await authManager.issueApiKeySocketTicket(
|
||||
authReq.userId,
|
||||
authReq.apiKeyId,
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json({ ticket });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to issue an API key socket ticket", err);
|
||||
res.status(500).json({ error: "Failed to issue a socket ticket" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerTlsRoutes } from "./tls-routes.js";
|
||||
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
||||
import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js";
|
||||
import { shownIdentity } from "../../sync/shown-identity.js";
|
||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
@@ -569,6 +568,30 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* On a desktop linked to a server, the account it is signed in to there.
|
||||
* That account is who the user is; the local profile only mirrors it.
|
||||
*/
|
||||
async function describeDesktopLink(userId: string) {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
|
||||
try {
|
||||
const { getLink } = await import("../../sync/client/link-store.js");
|
||||
const link = await getLink();
|
||||
if (!link || link.userId !== userId) return null;
|
||||
return {
|
||||
serverUrl: link.serverUrl,
|
||||
serverName: link.serverName,
|
||||
username: link.account?.username ?? link.remoteUsername,
|
||||
isAdmin: !!link.account?.isAdmin,
|
||||
roles: link.account?.roles ?? [],
|
||||
permissions: link.account?.permissions ?? [],
|
||||
status: link.status,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me:
|
||||
@@ -601,19 +624,18 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
|
||||
const isDualAuth = hasPassword && isExternalAccount(user);
|
||||
const shown = await shownIdentity(user);
|
||||
|
||||
res.json({
|
||||
userId: user.id,
|
||||
username: shown.username,
|
||||
is_admin: shown.isAdmin,
|
||||
username: user.username,
|
||||
is_admin: !!user.isAdmin,
|
||||
is_external: isExternalAccount(user),
|
||||
is_dual_auth: isDualAuth,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
user.id,
|
||||
),
|
||||
linked: shown.linked,
|
||||
linked: await describeDesktopLink(user.id),
|
||||
// "second-factor-setup" while the login policy wants a factor set up.
|
||||
restriction: (req as AuthenticatedRequest).sessionRestriction ?? null,
|
||||
});
|
||||
@@ -721,11 +743,7 @@ router.post("/internal/auto-session", async (req, res) => {
|
||||
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const allUsers = await userRepository.listAll();
|
||||
const { getSavedActiveUserId } = await import("../../sync/accounts.js");
|
||||
const userRecord = resolveDesktopAutoSessionUser(
|
||||
allUsers,
|
||||
await getSavedActiveUserId(),
|
||||
);
|
||||
const userRecord = resolveDesktopAutoSessionUser(allUsers);
|
||||
if (!userRecord) {
|
||||
return res.status(403).json({
|
||||
error: "No local users exist",
|
||||
@@ -736,11 +754,10 @@ router.post("/internal/auto-session", async (req, res) => {
|
||||
if (existingToken) {
|
||||
const existingPayload = await authManager.verifyJWTToken(existingToken);
|
||||
if (existingPayload?.userId === userRecord.id) {
|
||||
const shownNow = await shownIdentity(userRecord);
|
||||
return res.json({
|
||||
success: true,
|
||||
is_admin: shownNow.isAdmin,
|
||||
username: shownNow.username,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
token: existingToken,
|
||||
});
|
||||
}
|
||||
@@ -752,11 +769,10 @@ router.post("/internal/auto-session", async (req, res) => {
|
||||
rememberMe: true,
|
||||
});
|
||||
|
||||
const shown = await shownIdentity(userRecord);
|
||||
const response = {
|
||||
success: true,
|
||||
is_admin: shown.isAdmin,
|
||||
username: shown.username,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
token,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Other ways to reach a host, provided by plugins through
|
||||
* ctx.ssh.transports.provide. A provider claims some hosts by id and hands the
|
||||
* pipeline an open stream to them instead of a TCP connection, for example a
|
||||
* relay through another Termix server. The first provider that claims a host
|
||||
* wins, and nothing else (jump hosts, proxies) is tried for it.
|
||||
*/
|
||||
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginSshTransportProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export function provideSshTransport(
|
||||
pluginId: string,
|
||||
provider: PluginSshTransportProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearSshTransports(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
/** Opens a stream to the host if a provider claims it, else null. */
|
||||
export async function openProvidedTransport(host: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
}): Promise<Duplex | null> {
|
||||
for (const { provider } of providers.values()) {
|
||||
let claimed = false;
|
||||
try {
|
||||
claimed = provider.claims(host.id);
|
||||
} catch {
|
||||
claimed = false;
|
||||
}
|
||||
if (claimed) return provider.open(host);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* How the bytes get to a host: a transport a plugin provides, else port
|
||||
* knocking, a jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
|
||||
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
|
||||
* proxy, in that order. Sets config.sock and
|
||||
* returns the jump client so the caller can close it with the connection.
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "../../utils/socks5-helper.js";
|
||||
import type { ProxyNode } from "../../../types/index.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { openProvidedTransport } from "./transport-providers.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import { performPortKnocking } from "../terminal-auth-helpers.js";
|
||||
import type {
|
||||
@@ -60,7 +59,7 @@ export interface OpenTransportOptions {
|
||||
|
||||
export interface OpenedTransport {
|
||||
jumpClient: Client | null;
|
||||
via: "direct" | "proxy" | "jump" | "provided";
|
||||
via: "direct" | "proxy" | "jump";
|
||||
}
|
||||
|
||||
function forwardThrough(
|
||||
@@ -95,16 +94,6 @@ export async function openSshTransport(
|
||||
config: MutableConnectConfig,
|
||||
options: OpenTransportOptions = {},
|
||||
): Promise<OpenedTransport> {
|
||||
const provided = await openProvidedTransport({
|
||||
id: host.id,
|
||||
ip: host.ip,
|
||||
port: host.port || 22,
|
||||
});
|
||||
if (provided) {
|
||||
config.sock = provided;
|
||||
return { jumpClient: null, via: "provided" };
|
||||
}
|
||||
|
||||
if (
|
||||
options.portKnock !== false &&
|
||||
Array.isArray(host.portKnockSequence) &&
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* A relay to a host's SSH port, for another Termix server that manages this
|
||||
* one. It opens a WebSocket at /plugin-ws/core/ssh-relay with an API key
|
||||
* ticket, names a host, and the bytes of an SSH connection run through it.
|
||||
* This server reaches the host the way it always does, jump hosts, proxy and
|
||||
* port knocking included, so the other server never needs a route to it. The
|
||||
* SSH session itself stays between that server and the host.
|
||||
*
|
||||
* The caller needs connect access to the host, the same as for a session.
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { createWebSocketStream, type WebSocket } from "ws";
|
||||
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { resolveHostById } from "./host-resolver.js";
|
||||
import { openSshTransport } from "./connect/transport.js";
|
||||
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { registerCoreWsRoute } from "../plugins/ws.js";
|
||||
|
||||
const MAX_RELAYS_PER_USER = 100;
|
||||
const open = new Map<string, number>();
|
||||
|
||||
function close(ws: WebSocket, code: number, reason: string): void {
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelay(
|
||||
connection: PluginWebSocketConnection,
|
||||
): Promise<void> {
|
||||
const ws = connection.socket as WebSocket;
|
||||
const url = String((connection.request as { url?: string }).url ?? "");
|
||||
const hostId = Number(
|
||||
new URL(url, "http://relay").searchParams.get("hostId"),
|
||||
);
|
||||
const userId = connection.userId;
|
||||
|
||||
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) {
|
||||
close(ws, 1008, "A host is required");
|
||||
return;
|
||||
}
|
||||
if ((open.get(userId) ?? 0) >= MAX_RELAYS_PER_USER) {
|
||||
close(ws, 1013, "Too many connections");
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) {
|
||||
close(ws, 1008, "Host not found or access denied");
|
||||
return;
|
||||
}
|
||||
|
||||
open.set(userId, (open.get(userId) ?? 0) + 1);
|
||||
let stream: Duplex | null = null;
|
||||
let jumpEnd: (() => void) | null = null;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
open.set(userId, Math.max(0, (open.get(userId) ?? 1) - 1));
|
||||
stream?.destroy();
|
||||
jumpEnd?.();
|
||||
close(ws, 1000, "Closed");
|
||||
};
|
||||
ws.once("close", finish);
|
||||
|
||||
try {
|
||||
const sshHost = host as unknown as SshConnectHost;
|
||||
const config = {
|
||||
host: sshHost.ip,
|
||||
port: sshHost.port || 22,
|
||||
} as MutableConnectConfig;
|
||||
const { jumpClient } = await openSshTransport(sshHost, config);
|
||||
jumpEnd = () => jumpClient?.end();
|
||||
stream = (config.sock as Duplex | undefined) ?? null;
|
||||
if (!stream) {
|
||||
stream = net.connect({
|
||||
host: String(config.host),
|
||||
port: Number(config.port),
|
||||
});
|
||||
}
|
||||
const pipe = createWebSocketStream(ws);
|
||||
stream.once("error", finish);
|
||||
stream.once("close", finish);
|
||||
pipe.once("error", finish);
|
||||
stream.pipe(pipe);
|
||||
pipe.pipe(stream);
|
||||
} catch (error) {
|
||||
sshLogger.warn("A relay could not reach its host", {
|
||||
operation: "ssh_relay_open",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Mounts the relay once, for servers that other Termix servers manage. */
|
||||
export function registerSshRelay(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerCoreWsRoute("/ssh-relay", handleRelay);
|
||||
}
|
||||
@@ -157,7 +157,6 @@ export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
|
||||
customHeaders: link.customHeaders,
|
||||
basicAuth: link.basicAuth,
|
||||
allowInvalidCertificate: link.allowInvalidCertificate,
|
||||
clientCert: link.clientCert,
|
||||
},
|
||||
pathname,
|
||||
);
|
||||
|
||||
@@ -1041,31 +1041,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
},
|
||||
},
|
||||
|
||||
folders: {
|
||||
ensure: async (name, style) => {
|
||||
await requireWrite();
|
||||
const userId = actingUser();
|
||||
const { createCurrentHostFolderRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
await createCurrentHostFolderRepository().upsertMetadata(
|
||||
userId,
|
||||
name,
|
||||
style?.color ?? null,
|
||||
style?.icon ?? null,
|
||||
);
|
||||
},
|
||||
removeEmpty: async (name) => {
|
||||
await requireWrite();
|
||||
const userId = actingUser();
|
||||
const { createCurrentHostFolderRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentHostFolderRepository().deleteEmptyFolderRecords(
|
||||
userId,
|
||||
name,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
activity: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("hosts:activity-provide")) {
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
registerSecondFactor,
|
||||
} from "../auth/registry.js";
|
||||
import { provideAuthPolicy } from "../auth/policy.js";
|
||||
import { provideSshTransport } from "../hosts/connect/transport-providers.js";
|
||||
import type { VerifiedIdentity } from "../auth/types.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
@@ -297,26 +296,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
}`;
|
||||
|
||||
return {
|
||||
transports: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("ssh:transport")) {
|
||||
throw capabilityRefused(pluginId, "ssh:transport");
|
||||
}
|
||||
const remove = provideSshTransport(pluginId, provider);
|
||||
void audit(
|
||||
"ssh_transport_provide",
|
||||
`${pluginId} carries host connections`,
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
);
|
||||
const drop = bag.add(remove, "ssh transport");
|
||||
return () => {
|
||||
drop();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
},
|
||||
probe: async (hostId, probeOptions) => {
|
||||
await assertCapability(pluginId, "ssh:connect", declared);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) return false;
|
||||
|
||||
@@ -54,12 +54,6 @@ import {
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
import { provideSync } from "../sync/provider.js";
|
||||
import { createSyncHost } from "../sync/host.js";
|
||||
import type {
|
||||
PluginSync,
|
||||
PluginSyncHost,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import {
|
||||
needsExplicitPersist,
|
||||
resolveDatabaseDialect,
|
||||
@@ -195,34 +189,6 @@ export function createPluginContext(
|
||||
handle: PluginHandle,
|
||||
): PluginContext {
|
||||
const pluginId = manifest.id;
|
||||
let syncHost: PluginSyncHost | undefined;
|
||||
const syncApi: PluginSync = {
|
||||
registerEntity: (entity) => {
|
||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
||||
},
|
||||
provide: (provider) => {
|
||||
if (!manifest.capabilities.includes("sync:provide")) {
|
||||
throw capabilityRefused(pluginId, "sync:provide");
|
||||
}
|
||||
const remove = provideSync(pluginId, provider);
|
||||
const drop = handle.bag.add(remove, "sync provider");
|
||||
return () => {
|
||||
drop();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
} as PluginSync;
|
||||
// Not enumerable, so tools that walk the context do not trip the check.
|
||||
Object.defineProperty(syncApi, "host", {
|
||||
enumerable: false,
|
||||
get() {
|
||||
if (!manifest.capabilities.includes("sync:provide")) {
|
||||
throw capabilityRefused(pluginId, "sync:provide");
|
||||
}
|
||||
return (syncHost ??= createSyncHost());
|
||||
},
|
||||
});
|
||||
const auditCall = (
|
||||
action: string,
|
||||
details: string,
|
||||
@@ -554,7 +520,12 @@ export function createPluginContext(
|
||||
},
|
||||
},
|
||||
|
||||
sync: syncApi,
|
||||
sync: {
|
||||
registerEntity: (entity) => {
|
||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
||||
},
|
||||
},
|
||||
|
||||
kv: {
|
||||
get: (key) => kvGet(key),
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface UserOwnedTable {
|
||||
otherUserColumns: string[];
|
||||
/** refHost columns. */
|
||||
hostColumns: string[];
|
||||
/** Columns that travel, as SQL names. Auto ids and secrets do not. */
|
||||
/** Columns that travel, as SQL names. The primary key and secrets do not. */
|
||||
columns: string[];
|
||||
/** The owner column is the primary key: one row per user. */
|
||||
userIsKey: boolean;
|
||||
@@ -57,7 +57,9 @@ export function listUserOwnedTables(): UserOwnedTable[] {
|
||||
.filter(
|
||||
([, column]) =>
|
||||
column === user[1] ||
|
||||
(column.type !== "id" && column.type !== "encryptedText"),
|
||||
(column.type !== "id" &&
|
||||
!column.primaryKey &&
|
||||
column.type !== "encryptedText"),
|
||||
)
|
||||
.map(([property, column]) => columnName(property, column)),
|
||||
userIsKey: !!user[1].primaryKey,
|
||||
|
||||
@@ -54,8 +54,6 @@ interface Route {
|
||||
rawHandler?: RawUpgradeHandler;
|
||||
/** Sockets handed to rawHandler, destroyed when the route goes away. */
|
||||
rawSockets?: Set<Duplex>;
|
||||
/** Core's own socket: served under /plugin-ws/core/, no plugin grant. */
|
||||
core?: boolean;
|
||||
}
|
||||
|
||||
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
|
||||
@@ -165,10 +163,7 @@ async function handlePluginUpgrade(
|
||||
// checked per upgrade rather than at registration, and a revoke takes effect
|
||||
// on the next connection without a restart.
|
||||
const { hasCapability } = await import("./permissions.js");
|
||||
if (
|
||||
!route.core &&
|
||||
!(await hasCapability(route.pluginId, "network:serve", route.declared))
|
||||
) {
|
||||
if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) {
|
||||
reject(socket, 403, "Forbidden");
|
||||
return true;
|
||||
}
|
||||
@@ -308,32 +303,6 @@ export function registerPluginWsRoute(
|
||||
return () => disposeRoute(routeKey, pluginId, normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket core itself serves, at /plugin-ws/core/<path>, so it rides the same
|
||||
* proxy rules and token checks as plugin sockets. Authenticated like them.
|
||||
*/
|
||||
export function registerCoreWsRoute(
|
||||
path: string,
|
||||
handler: PluginWebSocketHandler,
|
||||
): () => void {
|
||||
const routeKey = key("core", path);
|
||||
const normalized = normalizePath(path);
|
||||
if (routes.has(routeKey)) return () => {};
|
||||
routes.set(routeKey, {
|
||||
pluginId: "core",
|
||||
path: normalized,
|
||||
options: {},
|
||||
declared: [],
|
||||
handler,
|
||||
core: true,
|
||||
});
|
||||
servers.set(
|
||||
routeKey,
|
||||
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
|
||||
);
|
||||
return () => disposeRoute(routeKey, "core", normalized);
|
||||
}
|
||||
|
||||
export function registerPluginWsUpgrade(
|
||||
pluginId: string,
|
||||
path: string,
|
||||
|
||||
@@ -265,15 +265,17 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Sync itself starts with the plugin that provides it.
|
||||
// After plugins, so their sync entities are registered before a pass.
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
try {
|
||||
const { startDesktopSync } = await import("./sync/client/engine.js");
|
||||
startDesktopSync();
|
||||
const { startDesktopUpdateCheck } =
|
||||
await import("./updates/desktop-update-check.js");
|
||||
startDesktopUpdateCheck();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Desktop update check failed to start", {
|
||||
operation: "update_check_start",
|
||||
systemLogger.warn("Desktop sync failed to start", {
|
||||
operation: "sync_start",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Accounts on a desktop. Each local user is one account: the plain local one,
|
||||
* or the replica of a linked server. Exactly one is active, and the app signs
|
||||
* in as it. Switching only changes which user the desktop auto session picks,
|
||||
* so no data is shared between accounts.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { resolveDesktopAutoSessionUser } from "../database/routes/desktop-auto-session.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
|
||||
const ACTIVE_KEY = "desktop_active_user";
|
||||
|
||||
/** The user the desktop signs in as. Falls back to the usual single-user pick. */
|
||||
export async function getActiveUserId(): Promise<string | null> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length === 0) return null;
|
||||
const saved = await createCurrentSettingsRepository().get(ACTIVE_KEY);
|
||||
return resolveDesktopAutoSessionUser(users, saved)?.id ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveUserId(userId: string): Promise<void> {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) throw new Error("No such account");
|
||||
await createCurrentSettingsRepository().set(ACTIVE_KEY, userId);
|
||||
}
|
||||
|
||||
/** Saved choice only, for the auto session route. */
|
||||
export async function getSavedActiveUserId(): Promise<string | null> {
|
||||
return createCurrentSettingsRepository().get(ACTIVE_KEY);
|
||||
}
|
||||
|
||||
export interface DesktopAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export async function listAccounts(): Promise<DesktopAccount[]> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
const active = await getActiveUserId();
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isAdmin: !!user.isAdmin,
|
||||
active: user.id === active,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A new empty account for another server to be linked to. */
|
||||
export async function createAccount(): Promise<string> {
|
||||
const users = createCurrentUserRepository();
|
||||
const id = crypto.randomUUID();
|
||||
await users.create({
|
||||
id,
|
||||
username: `server-${id.slice(0, 8)}`,
|
||||
passwordHash: "",
|
||||
isAdmin: true,
|
||||
});
|
||||
try {
|
||||
await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId: id,
|
||||
roleName: "admin",
|
||||
grantedBy: id,
|
||||
});
|
||||
} catch {
|
||||
// The account still works; admin is also set on the user row.
|
||||
}
|
||||
await AuthManager.getInstance().registerUser(
|
||||
id,
|
||||
crypto.randomBytes(32).toString("hex"),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Deletes an account and everything it owns. The last account stays. */
|
||||
export async function removeAccount(userId: string): Promise<void> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length <= 1) throw new Error("The last account cannot be removed");
|
||||
const active = await getActiveUserId();
|
||||
const { deleteUserAndRelatedData } =
|
||||
await import("../database/routes/delete-user-data.js");
|
||||
await deleteUserAndRelatedData(userId);
|
||||
if (active === userId) {
|
||||
const next = users.find((user) => user.id !== userId);
|
||||
if (next) await setActiveUserId(next.id);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import os from "os";
|
||||
|
||||
export function defaultDeviceName(): string {
|
||||
return os.hostname() || "Termix Desktop";
|
||||
}
|
||||
@@ -14,11 +14,7 @@ export async function applyElectronProxyConfig(
|
||||
link:
|
||||
| (Pick<
|
||||
SyncLink,
|
||||
| "serverUrl"
|
||||
| "customHeaders"
|
||||
| "basicAuth"
|
||||
| "allowInvalidCertificate"
|
||||
| "clientCert"
|
||||
"serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate"
|
||||
> & { sessionToken?: string | null; status?: string })
|
||||
| null,
|
||||
): Promise<void> {
|
||||
@@ -34,7 +30,6 @@ export async function applyElectronProxyConfig(
|
||||
headers: link.customHeaders ?? [],
|
||||
basicAuth: link.basicAuth ?? null,
|
||||
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
||||
clientCert: link.clientCert ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
/**
|
||||
* The linked desktop's side of sync.
|
||||
*
|
||||
* A pass pushes local edits, then pulls what changed on the server after the
|
||||
* saved cursor, then backfills any entity type this device has never pulled
|
||||
* (first link, a feature switched on, a category turned back on). Local
|
||||
* edits are found the same way the server finds changes: by comparing each
|
||||
* row's hash with the one recorded at the last sync.
|
||||
*/
|
||||
|
||||
import os from "os";
|
||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import {
|
||||
getEntity,
|
||||
listEntities,
|
||||
onEntitiesChanged,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { sendCoreAlert } from "../../notify/core-notify.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
loadWireRow,
|
||||
loadWireRows,
|
||||
writeWireRow,
|
||||
} from "../store.js";
|
||||
import {
|
||||
listRecords,
|
||||
putRecord,
|
||||
recordKey,
|
||||
saveConflict,
|
||||
setRecordError,
|
||||
type SyncRecord,
|
||||
} from "../records.js";
|
||||
import { orderSelfReferences } from "../wire.js";
|
||||
import { onLocalWrite } from "../server/change-watcher.js";
|
||||
import { getLink, updateLink, type SyncLink } from "./link-store.js";
|
||||
import { RemoteError, remoteFetch, remoteJson } from "./http.js";
|
||||
import { mirrorPlugins } from "./plugins.js";
|
||||
import { applyElectronProxyConfig } from "./electron-proxy.js";
|
||||
|
||||
export interface RemoteChange {
|
||||
seq: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}
|
||||
|
||||
interface ChangesPage {
|
||||
changes: RemoteChange[];
|
||||
nextCursor: number;
|
||||
hasMore: boolean;
|
||||
entityTypes: Array<{
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PushOp {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
baseRevision: number;
|
||||
op: "upsert" | "delete";
|
||||
row?: SyncRow;
|
||||
}
|
||||
|
||||
type PushResult =
|
||||
| { syncId: string; entityType: string; status: "ok"; revision: number }
|
||||
| {
|
||||
syncId: string;
|
||||
entityType: string;
|
||||
status: "conflict";
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}
|
||||
| { syncId: string; entityType: string; status: "rejected"; reason: string };
|
||||
|
||||
const MAX_BATCH_OPS = 200;
|
||||
const MAX_BATCH_BYTES = 1_000_000;
|
||||
const MAX_PAGES = 500;
|
||||
const SAFETY_INTERVAL_MS = 5 * 60_000;
|
||||
const REFRESH_CHECK_MS = 24 * 60 * 60_000;
|
||||
const DELETED_HASH = "deleted";
|
||||
|
||||
export interface EngineState {
|
||||
running: boolean;
|
||||
/** Local changes found in the last pass that the server has not taken. */
|
||||
pending: number;
|
||||
/** Entity types the server runs, from the last pull. */
|
||||
serverTypes: Array<{ type: string; owner: string; readOnly: boolean }>;
|
||||
lastPassAt: string | null;
|
||||
}
|
||||
|
||||
/** An entity this device could sync: registered here, and on the server. */
|
||||
function syncable(
|
||||
entity: RegisteredSyncEntity,
|
||||
link: SyncLink,
|
||||
serverTypes: Set<string> | null,
|
||||
): boolean {
|
||||
if (link.disabledTypes.includes(entity.type)) return false;
|
||||
if (serverTypes && !serverTypes.has(entity.type)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortChanges(changes: RemoteChange[]): RemoteChange[] {
|
||||
const byType = new Map<string, RemoteChange[]>();
|
||||
for (const change of changes) {
|
||||
const list = byType.get(change.entityType) ?? [];
|
||||
list.push(change);
|
||||
byType.set(change.entityType, list);
|
||||
}
|
||||
const types = [...byType.keys()].sort(
|
||||
(a, b) => (getEntity(a)?.order ?? 1e6) - (getEntity(b)?.order ?? 1e6),
|
||||
);
|
||||
const out: RemoteChange[] = [];
|
||||
for (const type of types) {
|
||||
const list = byType.get(type)!;
|
||||
const entity = getEntity(type);
|
||||
// Only the latest record of each row matters.
|
||||
const latest = new Map<string, RemoteChange>();
|
||||
for (const change of list) latest.set(change.syncId, change);
|
||||
const live = [...latest.values()].filter((change) => !change.deleted);
|
||||
const gone = [...latest.values()].filter((change) => change.deleted);
|
||||
if (entity) {
|
||||
const bySyncId = new Map(live.map((change) => [change.syncId, change]));
|
||||
const ordered = orderSelfReferences(
|
||||
entity,
|
||||
live.map((change) => ({
|
||||
...(change.row ?? {}),
|
||||
syncId: change.syncId,
|
||||
})),
|
||||
);
|
||||
out.push(...ordered.map((row) => bySyncId.get(row.syncId as string)!));
|
||||
} else {
|
||||
out.push(...live);
|
||||
}
|
||||
out.push(...gone);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SyncEngine {
|
||||
private running = false;
|
||||
private again = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private safety: NodeJS.Timeout | null = null;
|
||||
private backoffMs = 0;
|
||||
private events: AbortController | null = null;
|
||||
private eventsFor: string | null = null;
|
||||
private lastRefreshCheck = 0;
|
||||
private serverTypes: Set<string> | null = null;
|
||||
private stopFns: Array<() => void> = [];
|
||||
private alerted = false;
|
||||
readonly state: EngineState = {
|
||||
running: false,
|
||||
pending: 0,
|
||||
serverTypes: [],
|
||||
lastPassAt: null,
|
||||
};
|
||||
|
||||
start(): void {
|
||||
if (this.safety) return;
|
||||
registerCoreSyncEntities();
|
||||
this.safety = setInterval(() => this.request(0), SAFETY_INTERVAL_MS);
|
||||
this.safety.unref?.();
|
||||
this.stopFns.push(onLocalWrite(() => this.request(2000)));
|
||||
// A plugin that came up may bring entity types to backfill.
|
||||
this.stopFns.push(onEntitiesChanged(() => this.request(3000)));
|
||||
this.request(3000);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.safety) clearInterval(this.safety);
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.safety = this.timer = null;
|
||||
this.closeEvents();
|
||||
for (const stop of this.stopFns.splice(0)) stop();
|
||||
}
|
||||
|
||||
/** Runs a pass after `delayMs`, folding requests that arrive meanwhile. */
|
||||
request(delayMs = 0): void {
|
||||
if (this.timer) {
|
||||
if (delayMs > 0) return;
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.run();
|
||||
}, delayMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
/** Runs a pass now and waits for it. */
|
||||
async syncNow(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.again = true;
|
||||
while (this.running) await new Promise((r) => setTimeout(r, 100));
|
||||
return;
|
||||
}
|
||||
await this.run();
|
||||
}
|
||||
|
||||
/** Forgets what the server runs, after a relink or unlink. */
|
||||
reset(): void {
|
||||
this.serverTypes = null;
|
||||
this.state.serverTypes = [];
|
||||
this.state.pending = 0;
|
||||
this.alerted = false;
|
||||
this.backoffMs = 0;
|
||||
this.closeEvents();
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.again = true;
|
||||
return;
|
||||
}
|
||||
this.running = this.state.running = true;
|
||||
try {
|
||||
await this.pass();
|
||||
} finally {
|
||||
this.running = this.state.running = false;
|
||||
this.state.lastPassAt = new Date().toISOString();
|
||||
if (this.again) {
|
||||
this.again = false;
|
||||
this.request(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pass(): Promise<void> {
|
||||
const link = await getLink();
|
||||
if (!link?.sessionToken) {
|
||||
this.closeEvents();
|
||||
return;
|
||||
}
|
||||
await updateLink({ status: "syncing" });
|
||||
|
||||
try {
|
||||
await this.refreshSession(link);
|
||||
const fresh = (await getLink())!;
|
||||
await mirrorPlugins(fresh);
|
||||
|
||||
// A first pass pulls before it pushes, so rows both sides already
|
||||
// have are matched up instead of sent back as edits.
|
||||
const firstPass = fresh.knownTypes.length === 0;
|
||||
if (!firstPass) {
|
||||
await this.push(fresh);
|
||||
await this.pull(fresh);
|
||||
}
|
||||
await this.backfill((await getLink())!);
|
||||
if (firstPass) await this.push((await getLink())!);
|
||||
|
||||
this.backoffMs = 0;
|
||||
this.alerted = false;
|
||||
await updateLink({
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
});
|
||||
const done = (await getLink())!;
|
||||
await applyElectronProxyConfig(done).catch(() => {});
|
||||
this.openEvents(done);
|
||||
} catch (error) {
|
||||
await this.fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async fail(error: unknown): Promise<void> {
|
||||
const remote = error instanceof RemoteError ? error : null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
syncLogger.warn("Sync pass failed", {
|
||||
operation: "sync_pass",
|
||||
kind: remote?.kind ?? "internal",
|
||||
error: message,
|
||||
});
|
||||
|
||||
if (remote?.kind === "signed_out") {
|
||||
const link = await updateLink({
|
||||
status: "signed_out",
|
||||
lastError: message,
|
||||
});
|
||||
await applyElectronProxyConfig(link).catch(() => {});
|
||||
this.closeEvents();
|
||||
if (!this.alerted) {
|
||||
this.alerted = true;
|
||||
await sendCoreAlert({
|
||||
title: "Sync is paused",
|
||||
body: "This device was signed out of the server it syncs with. Sign in again from Sync to keep going.",
|
||||
severity: "warning",
|
||||
category: "termix.sync.signed_out",
|
||||
dedupeKey: "sync:signed_out",
|
||||
audience: "admins",
|
||||
link: { tab: "sync" },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const offline = remote?.kind === "offline";
|
||||
await updateLink({
|
||||
status: offline ? "offline" : "error",
|
||||
lastError: message,
|
||||
});
|
||||
this.backoffMs = Math.min(
|
||||
this.backoffMs ? this.backoffMs * 2 : 15_000,
|
||||
10 * 60_000,
|
||||
);
|
||||
this.request(this.backoffMs);
|
||||
}
|
||||
|
||||
private async refreshSession(link: SyncLink): Promise<void> {
|
||||
if (Date.now() - this.lastRefreshCheck < REFRESH_CHECK_MS) return;
|
||||
const result = await remoteJson<{ token: string | null }>(
|
||||
link,
|
||||
"/sync/v2/session/refresh",
|
||||
{ method: "POST", body: {} },
|
||||
);
|
||||
this.lastRefreshCheck = Date.now();
|
||||
if (result.token) await updateLink({ sessionToken: result.token });
|
||||
}
|
||||
|
||||
/** Local rows the server does not have in this form yet. */
|
||||
async outbox(link: SyncLink): Promise<{
|
||||
ops: PushOp[];
|
||||
hashes: Map<string, string>;
|
||||
}> {
|
||||
const records = await listRecords(link.userId);
|
||||
const resolvers = createResolvers(link.userId);
|
||||
const ops: PushOp[] = [];
|
||||
const hashes = new Map<string, string>();
|
||||
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
if (!link.knownTypes.includes(entity.type)) continue;
|
||||
if (!syncable(entity, link, this.serverTypes)) continue;
|
||||
|
||||
const { rows, failed } = await loadWireRows(
|
||||
entity,
|
||||
link.userId,
|
||||
resolvers,
|
||||
);
|
||||
for (const row of rows.values()) {
|
||||
const key = recordKey(entity.type, row.syncId);
|
||||
const record = records.get(key);
|
||||
if (record && !record.deleted && record.hash === row.hash) continue;
|
||||
// Unavailable server rows can recover without another local edit.
|
||||
if (record?.errorHash === row.hash && record.error !== "unavailable") {
|
||||
continue;
|
||||
}
|
||||
ops.push({
|
||||
entityType: entity.type,
|
||||
syncId: row.syncId,
|
||||
baseRevision: record?.revision ?? 0,
|
||||
op: "upsert",
|
||||
row: row.wire,
|
||||
});
|
||||
hashes.set(key, row.hash);
|
||||
}
|
||||
|
||||
for (const record of records.values()) {
|
||||
if (record.entityType !== entity.type || record.deleted) continue;
|
||||
if (record.revision === 0) continue;
|
||||
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
|
||||
if (entity.covers && !entity.covers(record.syncId)) continue;
|
||||
if (
|
||||
record.errorHash === DELETED_HASH &&
|
||||
record.error !== "unavailable"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ops.push({
|
||||
entityType: entity.type,
|
||||
syncId: record.syncId,
|
||||
baseRevision: record.revision,
|
||||
op: "delete",
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ops, hashes };
|
||||
}
|
||||
|
||||
private async push(link: SyncLink): Promise<void> {
|
||||
const { ops, hashes } = await this.outbox(link);
|
||||
this.state.pending = ops.length;
|
||||
if (ops.length === 0) return;
|
||||
|
||||
for (const batch of batches(ops)) {
|
||||
const { results } = await remoteJson<{ results: PushResult[] }>(
|
||||
link,
|
||||
"/sync/v2/push",
|
||||
{ method: "POST", body: { ops: batch }, timeoutMs: 120_000 },
|
||||
);
|
||||
const byKey = new Map(
|
||||
batch.map((op) => [recordKey(op.entityType, op.syncId), op]),
|
||||
);
|
||||
for (const result of results) {
|
||||
const key = recordKey(result.entityType, result.syncId);
|
||||
const op = byKey.get(key);
|
||||
if (!op) continue;
|
||||
await this.settle(link, op, result, hashes.get(key) ?? null);
|
||||
}
|
||||
this.state.pending = Math.max(0, this.state.pending - batch.length);
|
||||
}
|
||||
}
|
||||
|
||||
private async settle(
|
||||
link: SyncLink,
|
||||
op: PushOp,
|
||||
result: PushResult,
|
||||
hash: string | null,
|
||||
): Promise<void> {
|
||||
const userId = link.userId;
|
||||
const entity = getEntity(op.entityType);
|
||||
if (!entity) return;
|
||||
|
||||
if (result.status === "ok") {
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: op.op === "delete" ? null : hash,
|
||||
deleted: op.op === "delete",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "rejected") {
|
||||
await setRecordError(
|
||||
userId,
|
||||
op.entityType,
|
||||
op.syncId,
|
||||
result.reason,
|
||||
hash ?? DELETED_HASH,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// A live row the server could not send says nothing; keep ours to retry.
|
||||
if (!result.deleted && !result.row) return;
|
||||
|
||||
// Conflict: the server moved on since this device last saw the row. Its
|
||||
// version wins here, and a local edit it replaced is kept for the user.
|
||||
if (op.op === "upsert" && op.row) {
|
||||
await saveConflict(
|
||||
userId,
|
||||
op.entityType,
|
||||
op.syncId,
|
||||
op.row,
|
||||
result.revision,
|
||||
);
|
||||
}
|
||||
const resolvers = createResolvers(userId);
|
||||
if (result.deleted) {
|
||||
await deleteStoredRow(entity, userId, op.syncId);
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: null,
|
||||
deleted: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...result.row, syncId: op.syncId },
|
||||
resolvers,
|
||||
);
|
||||
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: written?.hash ?? null,
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchChanges(
|
||||
link: SyncLink,
|
||||
cursor: number,
|
||||
types?: string[],
|
||||
): Promise<{ changes: RemoteChange[]; cursor: number }> {
|
||||
const all: RemoteChange[] = [];
|
||||
let next = cursor;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const query = new URLSearchParams({
|
||||
cursor: String(next),
|
||||
limit: "1000",
|
||||
});
|
||||
if (types?.length) query.set("types", types.join(","));
|
||||
const result = await remoteJson<ChangesPage>(
|
||||
link,
|
||||
`/sync/v2/changes?${query.toString()}`,
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
all.push(...result.changes);
|
||||
next = result.nextCursor;
|
||||
this.learnTypes(result);
|
||||
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);
|
||||
await this.apply(
|
||||
link,
|
||||
changes.filter(
|
||||
(change) =>
|
||||
known.has(change.entityType) &&
|
||||
!link.disabledTypes.includes(change.entityType),
|
||||
),
|
||||
false,
|
||||
);
|
||||
await updateLink({ cursor });
|
||||
}
|
||||
|
||||
/** 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 },
|
||||
),
|
||||
);
|
||||
}
|
||||
const fresh = listEntities()
|
||||
.map((entity) => entity.type)
|
||||
.filter(
|
||||
(type) =>
|
||||
!link.knownTypes.includes(type) &&
|
||||
!link.disabledTypes.includes(type) &&
|
||||
this.serverTypes?.has(type),
|
||||
);
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
const { changes, cursor } = await this.fetchChanges(link, 0, fresh);
|
||||
await this.apply(link, changes, true);
|
||||
const current = (await getLink())!;
|
||||
await updateLink({
|
||||
knownTypes: [...new Set([...current.knownTypes, ...fresh])],
|
||||
// On a first pass the backfill is the whole pull, so it sets the cursor.
|
||||
...(link.knownTypes.length === 0 ? { cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes pulled changes. `adopt` is for a first pull of a type: a local row
|
||||
* with no record yet takes the server's version without a conflict.
|
||||
*/
|
||||
private async apply(
|
||||
link: SyncLink,
|
||||
changes: RemoteChange[],
|
||||
adopt: boolean,
|
||||
): Promise<void> {
|
||||
if (changes.length === 0) return;
|
||||
const userId = link.userId;
|
||||
const records = await listRecords(userId);
|
||||
let missed = false;
|
||||
const resolvers = createResolvers(userId, () => {
|
||||
missed = true;
|
||||
});
|
||||
const retry: RemoteChange[] = [];
|
||||
|
||||
for (const change of sortChanges(changes)) {
|
||||
const entity = getEntity(change.entityType);
|
||||
if (!entity) continue;
|
||||
const key = recordKey(change.entityType, change.syncId);
|
||||
const record = records.get(key);
|
||||
if (record && record.revision >= change.revision) continue;
|
||||
|
||||
try {
|
||||
if (!entity.readOnly) {
|
||||
await this.keepLocalEdit(
|
||||
entity,
|
||||
userId,
|
||||
change,
|
||||
record,
|
||||
adopt,
|
||||
resolvers,
|
||||
);
|
||||
}
|
||||
if (change.deleted) {
|
||||
await deleteStoredRow(entity, userId, change.syncId);
|
||||
await putRecord(userId, change.entityType, change.syncId, {
|
||||
revision: change.revision,
|
||||
hash: null,
|
||||
deleted: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
missed = false;
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...(change.row ?? {}), syncId: change.syncId },
|
||||
resolvers,
|
||||
);
|
||||
if (missed) retry.push(change);
|
||||
await this.recordApplied(entity, userId, change, resolvers);
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not apply a synced change", {
|
||||
operation: "sync_apply",
|
||||
entityType: change.entityType,
|
||||
syncId: change.syncId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rows that pointed at something written later in the same pull.
|
||||
for (const change of retry) {
|
||||
const entity = getEntity(change.entityType)!;
|
||||
try {
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...(change.row ?? {}), syncId: change.syncId },
|
||||
resolvers,
|
||||
);
|
||||
await this.recordApplied(entity, userId, change, resolvers);
|
||||
} catch {
|
||||
// The first write stands.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async recordApplied(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
change: RemoteChange,
|
||||
resolvers: ReturnType<typeof createResolvers>,
|
||||
): Promise<void> {
|
||||
const written = entity.readOnly
|
||||
? null
|
||||
: await loadWireRow(entity, userId, change.syncId, resolvers);
|
||||
await putRecord(userId, change.entityType, change.syncId, {
|
||||
revision: change.revision,
|
||||
hash: written?.hash ?? null,
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Saves a local edit the incoming change is about to replace. */
|
||||
private async keepLocalEdit(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
change: RemoteChange,
|
||||
record: SyncRecord | undefined,
|
||||
adopt: boolean,
|
||||
resolvers: ReturnType<typeof createResolvers>,
|
||||
): Promise<void> {
|
||||
if (!record && adopt) return;
|
||||
const local = await loadWireRow(
|
||||
entity,
|
||||
userId,
|
||||
change.syncId,
|
||||
resolvers,
|
||||
).catch(() => null);
|
||||
if (!local) return;
|
||||
if (record && !record.deleted && record.hash === local.hash) return;
|
||||
await saveConflict(
|
||||
userId,
|
||||
change.entityType,
|
||||
change.syncId,
|
||||
local.wire,
|
||||
change.revision,
|
||||
);
|
||||
}
|
||||
|
||||
private openEvents(link: SyncLink): void {
|
||||
const key = `${link.serverUrl}|${link.sessionToken}`;
|
||||
if (this.events && this.eventsFor === key) return;
|
||||
this.closeEvents();
|
||||
const controller = new AbortController();
|
||||
this.events = controller;
|
||||
this.eventsFor = key;
|
||||
void this.listen(link, controller);
|
||||
}
|
||||
|
||||
private closeEvents(): void {
|
||||
this.events?.abort();
|
||||
this.events = null;
|
||||
this.eventsFor = null;
|
||||
}
|
||||
|
||||
private async listen(
|
||||
link: SyncLink,
|
||||
controller: AbortController,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await remoteFetch(link, "/sync/v2/events", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.decode(chunk as Uint8Array, { stream: true });
|
||||
let index;
|
||||
while ((index = buffer.indexOf("\n\n")) >= 0) {
|
||||
const block = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 2);
|
||||
if (/^event: change$/m.test(block)) this.request(500);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Dropped or refused: the safety interval still syncs, and the next
|
||||
// successful pass reopens the stream.
|
||||
}
|
||||
if (this.events === controller) {
|
||||
this.events = null;
|
||||
this.eventsFor = null;
|
||||
if (!controller.signal.aborted) {
|
||||
setTimeout(() => this.request(0), 30_000).unref?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the link's proxy settings have to reach outside the backend. */
|
||||
async applyProxyConfig(): Promise<void> {
|
||||
await applyElectronProxyConfig(await getLink());
|
||||
}
|
||||
}
|
||||
|
||||
function* batches(ops: PushOp[]): Generator<PushOp[]> {
|
||||
let batch: PushOp[] = [];
|
||||
let bytes = 0;
|
||||
for (const op of ops) {
|
||||
const size = JSON.stringify(op).length;
|
||||
if (
|
||||
batch.length > 0 &&
|
||||
(batch.length >= MAX_BATCH_OPS || bytes + size > MAX_BATCH_BYTES)
|
||||
) {
|
||||
yield batch;
|
||||
batch = [];
|
||||
bytes = 0;
|
||||
}
|
||||
batch.push(op);
|
||||
bytes += size;
|
||||
}
|
||||
if (batch.length > 0) yield batch;
|
||||
}
|
||||
|
||||
let engine: SyncEngine | null = null;
|
||||
|
||||
export function getSyncEngine(): SyncEngine {
|
||||
if (!engine) engine = new SyncEngine();
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Starts syncing on a desktop. A server install never links. */
|
||||
export function startDesktopSync(): void {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return;
|
||||
const instance = getSyncEngine();
|
||||
instance.start();
|
||||
void instance.applyProxyConfig().catch(() => {});
|
||||
}
|
||||
|
||||
export function defaultDeviceName(): string {
|
||||
return os.hostname() || "Termix Desktop";
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import type { Dispatcher } from "undici";
|
||||
import type { BasicAuth, ClientCert, ProxyHeader } from "./link-store.js";
|
||||
import type { BasicAuth, ProxyHeader } from "./link-store.js";
|
||||
|
||||
export interface RemoteTarget {
|
||||
serverUrl: string;
|
||||
@@ -17,7 +17,6 @@ export interface RemoteTarget {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
}
|
||||
|
||||
export type RemoteErrorKind =
|
||||
@@ -25,8 +24,6 @@ export type RemoteErrorKind =
|
||||
| "offline"
|
||||
/** TLS failed and the certificate is not allowed. */
|
||||
| "tls"
|
||||
/** The server wants a client certificate and none was accepted. */
|
||||
| "client_cert"
|
||||
/** A proxy answered instead of Termix (its login page, a 404 page). */
|
||||
| "proxy"
|
||||
/** The proxy wants basic auth. */
|
||||
@@ -56,13 +53,6 @@ const TLS_CODES = new Set([
|
||||
"CERT_UNTRUSTED",
|
||||
]);
|
||||
|
||||
const CLIENT_CERT_CODES = new Set([
|
||||
"ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED",
|
||||
"ERR_SSL_SSLV3_ALERT_BAD_CERTIFICATE",
|
||||
"ERR_SSL_TLSV1_ALERT_UNKNOWN_CA",
|
||||
"ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE",
|
||||
]);
|
||||
|
||||
function envProxy(url: string): string | null {
|
||||
const proxy =
|
||||
process.env.https_proxy ||
|
||||
@@ -94,16 +84,10 @@ const dispatchers = new Map<string, Dispatcher>();
|
||||
function dispatcherFor(target: RemoteTarget): Dispatcher {
|
||||
const insecure = !!target.allowInvalidCertificate;
|
||||
const proxy = envProxy(target.serverUrl);
|
||||
const cert = target.clientCert;
|
||||
const key = `${insecure}|${proxy ?? ""}|${cert ? `${cert.passphrase}|${cert.pfx}` : ""}`;
|
||||
const key = `${insecure}|${proxy ?? ""}`;
|
||||
let dispatcher = dispatchers.get(key);
|
||||
if (!dispatcher) {
|
||||
const tls = {
|
||||
...(insecure ? { rejectUnauthorized: false } : {}),
|
||||
...(cert
|
||||
? { pfx: Buffer.from(cert.pfx, "base64"), passphrase: cert.passphrase }
|
||||
: {}),
|
||||
};
|
||||
const tls = insecure ? { rejectUnauthorized: false } : {};
|
||||
dispatcher = proxy
|
||||
? new ProxyAgent({ uri: proxy, requestTls: tls })
|
||||
: new Agent({ connect: tls });
|
||||
@@ -157,22 +141,10 @@ export function requestHeaders(target: RemoteTarget): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
// A server that requires a client certificate often just drops the
|
||||
// connection when it gets none, or one it does not accept.
|
||||
const DROPPED_CODES = new Set(["UND_ERR_SOCKET", "ECONNRESET"]);
|
||||
|
||||
function describeNetworkError(
|
||||
error: unknown,
|
||||
target?: RemoteTarget,
|
||||
): RemoteError {
|
||||
function describeNetworkError(error: unknown): RemoteError {
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })
|
||||
?.cause;
|
||||
const code = cause?.code ?? (error as { code?: string })?.code ?? "";
|
||||
const droppedOverTls =
|
||||
DROPPED_CODES.has(code) && target?.serverUrl.startsWith("https:");
|
||||
if (CLIENT_CERT_CODES.has(code) || droppedOverTls) {
|
||||
return new RemoteError("client_cert", cause?.message || code);
|
||||
}
|
||||
if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) {
|
||||
return new RemoteError("tls", cause?.message || code);
|
||||
}
|
||||
@@ -221,7 +193,7 @@ export async function remoteFetch(
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
throw describeNetworkError(error, target);
|
||||
throw describeNetworkError(error);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
@@ -256,7 +228,7 @@ export async function remoteStreamFetch(
|
||||
signal: request.signal,
|
||||
} as never);
|
||||
} catch (error) {
|
||||
throw describeNetworkError(error, target);
|
||||
throw describeNetworkError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
/**
|
||||
* The desktop's links to servers, one row each in sync_link and at most one
|
||||
* per local user, since each link has its own replica user. Secrets (the
|
||||
* session token, proxy headers, basic auth) are stored encrypted with the
|
||||
* system key. Without a user, a call means the active account's link.
|
||||
* The desktop's link to a server, one row in sync_link. Secrets (the session
|
||||
* token, proxy headers, basic auth) are stored encrypted with the system key.
|
||||
*/
|
||||
|
||||
import { createSecureContext } from "node:tls";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActiveUserId } from "../accounts.js";
|
||||
import { syncLink } from "../../database/db/schema.js";
|
||||
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
@@ -37,14 +33,6 @@ export interface BasicAuth {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** A PFX/P12 client certificate for servers that require mutual TLS. */
|
||||
export interface ClientCert {
|
||||
/** Base64 of the PFX/P12 file. */
|
||||
pfx: string;
|
||||
passphrase: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface SyncLink {
|
||||
id: number;
|
||||
userId: string;
|
||||
@@ -55,14 +43,11 @@ export interface SyncLink {
|
||||
customHeaders: ProxyHeader[];
|
||||
basicAuth: BasicAuth | null;
|
||||
allowInvalidCertificate: boolean;
|
||||
clientCert: ClientCert | null;
|
||||
remoteUserId: string | null;
|
||||
remoteUsername: string | null;
|
||||
account: LinkAccount | null;
|
||||
/** Entity types switched off on this device. */
|
||||
disabledTypes: string[];
|
||||
/** Extra connection options for this server, kept open for new ones. */
|
||||
options: Record<string, unknown>;
|
||||
/** Entity types this device has fully pulled at least once. */
|
||||
knownTypes: string[];
|
||||
cursor: number;
|
||||
@@ -105,11 +90,7 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
sessionToken = null;
|
||||
}
|
||||
}
|
||||
const scope = parseJson<{
|
||||
disabled?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
clientCert?: string;
|
||||
}>(row.scope, {});
|
||||
const scope = parseJson<{ disabled?: string[] }>(row.scope, {});
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
@@ -120,16 +101,10 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
|
||||
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
|
||||
allowInvalidCertificate: !!row.allowInvalidCertificate,
|
||||
clientCert: await decryptJson<ClientCert | null>(
|
||||
scope.clientCert ?? null,
|
||||
null,
|
||||
),
|
||||
remoteUserId: row.remoteUserId,
|
||||
remoteUsername: row.remoteUsername,
|
||||
account: parseJson<LinkAccount | null>(row.account, null),
|
||||
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
|
||||
options:
|
||||
scope.options && typeof scope.options === "object" ? scope.options : {},
|
||||
knownTypes: parseJson<string[]>(row.knownTypes, []),
|
||||
cursor: Number(row.cursor) || 0,
|
||||
status: (row.status as LinkStatus) || "idle",
|
||||
@@ -139,20 +114,8 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLinks(): Promise<SyncLink[]> {
|
||||
const rows = await db().select().from(syncLink);
|
||||
return Promise.all(rows.map(fromRow));
|
||||
}
|
||||
|
||||
/** The link of one local user, or of the active account when none is given. */
|
||||
export async function getLink(userId?: string): Promise<SyncLink | null> {
|
||||
const owner = userId ?? (await getActiveUserId());
|
||||
if (!owner) return null;
|
||||
const [row] = await db()
|
||||
.select()
|
||||
.from(syncLink)
|
||||
.where(eq(syncLink.userId, owner))
|
||||
.limit(1);
|
||||
export async function getLink(): Promise<SyncLink | null> {
|
||||
const [row] = await db().select().from(syncLink).limit(1);
|
||||
return row ? fromRow(row) : null;
|
||||
}
|
||||
|
||||
@@ -165,40 +128,12 @@ export interface NewLink {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
}
|
||||
|
||||
/** Throws when the PFX is unreadable or the passphrase is wrong. */
|
||||
export function assertClientCert(cert: ClientCert): void {
|
||||
try {
|
||||
createSecureContext({
|
||||
pfx: Buffer.from(cert.pfx, "base64"),
|
||||
passphrase: cert.passphrase,
|
||||
});
|
||||
} catch {
|
||||
throw new Error("The client certificate or its passphrase is not valid");
|
||||
}
|
||||
}
|
||||
|
||||
async function sealScope(
|
||||
disabled: string[],
|
||||
options: Record<string, unknown>,
|
||||
clientCert: ClientCert | null,
|
||||
): Promise<string> {
|
||||
return JSON.stringify({
|
||||
disabled,
|
||||
options,
|
||||
...(clientCert
|
||||
? { clientCert: await encryptSystemSecret(JSON.stringify(clientCert)) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
if (link.clientCert) assertClientCert(link.clientCert);
|
||||
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
|
||||
await db().delete(syncLink);
|
||||
await db()
|
||||
.insert(syncLink)
|
||||
.values({
|
||||
@@ -214,7 +149,6 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
|
||||
: null,
|
||||
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
||||
scope: link.clientCert ? await sealScope([], {}, link.clientCert) : null,
|
||||
remoteUserId: link.remoteUserId ?? null,
|
||||
remoteUsername: link.remoteUsername ?? null,
|
||||
cursor: 0,
|
||||
@@ -223,7 +157,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
linkedAt: new Date().toISOString(),
|
||||
});
|
||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||
return (await getLink(link.userId))!;
|
||||
return (await getLink())!;
|
||||
}
|
||||
|
||||
export interface LinkPatch {
|
||||
@@ -233,12 +167,10 @@ export interface LinkPatch {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
account?: LinkAccount | null;
|
||||
disabledTypes?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
knownTypes?: string[];
|
||||
cursor?: number;
|
||||
status?: LinkStatus;
|
||||
@@ -246,13 +178,9 @@ export interface LinkPatch {
|
||||
lastSyncAt?: string | null;
|
||||
}
|
||||
|
||||
export async function updateLink(
|
||||
patch: LinkPatch,
|
||||
userId?: string,
|
||||
): Promise<SyncLink | null> {
|
||||
const current = await getLink(userId);
|
||||
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
||||
const current = await getLink();
|
||||
if (!current) return null;
|
||||
if (patch.clientCert) assertClientCert(patch.clientCert);
|
||||
const set: Partial<Row> = {};
|
||||
if (patch.serverName !== undefined) set.serverName = patch.serverName;
|
||||
if (patch.serverVersion !== undefined)
|
||||
@@ -282,16 +210,8 @@ export async function updateLink(
|
||||
if (patch.account !== undefined) {
|
||||
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
||||
}
|
||||
if (
|
||||
patch.disabledTypes !== undefined ||
|
||||
patch.options !== undefined ||
|
||||
patch.clientCert !== undefined
|
||||
) {
|
||||
set.scope = await sealScope(
|
||||
patch.disabledTypes ?? current.disabledTypes,
|
||||
patch.options ?? current.options,
|
||||
patch.clientCert === undefined ? current.clientCert : patch.clientCert,
|
||||
);
|
||||
if (patch.disabledTypes !== undefined) {
|
||||
set.scope = JSON.stringify({ disabled: patch.disabledTypes });
|
||||
}
|
||||
if (patch.knownTypes !== undefined) {
|
||||
set.knownTypes = JSON.stringify(patch.knownTypes);
|
||||
@@ -304,12 +224,10 @@ export async function updateLink(
|
||||
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
|
||||
DatabaseSaveTrigger.triggerSave("sync_link");
|
||||
}
|
||||
return getLink(current.userId);
|
||||
return getLink();
|
||||
}
|
||||
|
||||
export async function deleteLink(userId?: string): Promise<void> {
|
||||
const owner = userId ?? (await getActiveUserId());
|
||||
if (!owner) return;
|
||||
await db().delete(syncLink).where(eq(syncLink.userId, owner));
|
||||
export async function deleteLink(): Promise<void> {
|
||||
await db().delete(syncLink);
|
||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
/**
|
||||
* The desktop's own sync routes, under /sync/link. They only exist in the
|
||||
* embedded desktop backend; on a server they answer 404.
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { hosts, sshCredentials } from "../../database/db/schema.js";
|
||||
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
listStoredRows,
|
||||
regenerateSyncIds,
|
||||
writeWireRow,
|
||||
} from "../store.js";
|
||||
import {
|
||||
clearRecordErrors,
|
||||
deleteConflict,
|
||||
deleteRecordsForUser,
|
||||
getConflict,
|
||||
listConflicts,
|
||||
listRecordErrors,
|
||||
} from "../records.js";
|
||||
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
|
||||
import {
|
||||
createLink,
|
||||
deleteLink,
|
||||
getLink,
|
||||
updateLink,
|
||||
type BasicAuth,
|
||||
type ProxyHeader,
|
||||
type SyncLink,
|
||||
} from "./link-store.js";
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
RemoteError,
|
||||
remoteJson,
|
||||
type RemoteTarget,
|
||||
} from "./http.js";
|
||||
import { defaultDeviceName, getSyncEngine } from "./engine.js";
|
||||
import { applyElectronProxyConfig } from "./electron-proxy.js";
|
||||
import { lastRemotePlugins } from "./plugins.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
function desktopOnly(_req: Request, res: Response, next: NextFunction) {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
const guard = [desktopOnly, authenticateJWT];
|
||||
|
||||
function userOf(req: Request): string {
|
||||
return (req as AuthenticatedRequest).userId;
|
||||
}
|
||||
|
||||
function readHeaders(value: unknown): ProxyHeader[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(
|
||||
(header): header is ProxyHeader =>
|
||||
!!header &&
|
||||
typeof header.name === "string" &&
|
||||
typeof header.value === "string" &&
|
||||
/^[A-Za-z0-9-]{1,100}$/.test(header.name.trim()),
|
||||
)
|
||||
.map((header) => ({ name: header.name.trim(), value: header.value }))
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function readBasicAuth(value: unknown): BasicAuth | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const { username, password } = value as Record<string, unknown>;
|
||||
if (typeof username !== "string" || !username) return null;
|
||||
return {
|
||||
username,
|
||||
password: typeof password === "string" ? password : "",
|
||||
};
|
||||
}
|
||||
|
||||
function readTarget(body: Record<string, unknown>): RemoteTarget | null {
|
||||
const serverUrl =
|
||||
typeof body.serverUrl === "string"
|
||||
? normalizeServerUrl(body.serverUrl)
|
||||
: null;
|
||||
if (!serverUrl) return null;
|
||||
return {
|
||||
serverUrl,
|
||||
customHeaders: readHeaders(body.customHeaders),
|
||||
basicAuth: readBasicAuth(body.basicAuth),
|
||||
allowInvalidCertificate: body.allowInvalidCertificate === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the sync panel can show about each entity type. */
|
||||
function describeEntities(link: SyncLink | null) {
|
||||
registerCoreSyncEntities();
|
||||
const names = new Map<string, string>();
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
names.set(plugin.id, plugin.manifest.name);
|
||||
}
|
||||
for (const plugin of lastRemotePlugins()) {
|
||||
if (!names.has(plugin.id)) names.set(plugin.id, plugin.name);
|
||||
}
|
||||
const serverTypes = getSyncEngine().state.serverTypes;
|
||||
const seen = new Set<string>();
|
||||
const entries = [
|
||||
...listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
...serverTypes,
|
||||
];
|
||||
return entries
|
||||
.filter((entry) => {
|
||||
if (seen.has(entry.type) || entry.type === "accountProfile") return false;
|
||||
seen.add(entry.type);
|
||||
return true;
|
||||
})
|
||||
.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
ownerName:
|
||||
entry.owner === "core" ? null : (names.get(entry.owner) ?? entry.owner),
|
||||
readOnly: entry.readOnly,
|
||||
onServer: serverTypes.some((server) => server.type === entry.type),
|
||||
onDevice: !!getEntity(entry.type),
|
||||
enabled: !link?.disabledTypes.includes(entry.type),
|
||||
}));
|
||||
}
|
||||
|
||||
async function statusBody() {
|
||||
const link = await getLink();
|
||||
const engine = getSyncEngine();
|
||||
if (!link) {
|
||||
return { linked: false, entities: describeEntities(null) };
|
||||
}
|
||||
return {
|
||||
linked: true,
|
||||
serverUrl: link.serverUrl,
|
||||
serverName: link.serverName,
|
||||
serverVersion: link.serverVersion,
|
||||
account: link.account ?? {
|
||||
username: link.remoteUsername ?? undefined,
|
||||
},
|
||||
status: engine.state.running ? "syncing" : link.status,
|
||||
lastError: link.lastError,
|
||||
linkedAt: link.linkedAt,
|
||||
lastSyncAt: link.lastSyncAt,
|
||||
pending: engine.state.pending,
|
||||
conflicts: (await listConflicts(link.userId)).length,
|
||||
errors: (await listRecordErrors(link.userId)).length,
|
||||
firstSyncDone: link.knownTypes.length > 0,
|
||||
allowInvalidCertificate: link.allowInvalidCertificate,
|
||||
customHeaders: link.customHeaders.map((header) => ({
|
||||
name: header.name,
|
||||
value: "",
|
||||
set: true,
|
||||
})),
|
||||
basicAuth: link.basicAuth
|
||||
? { username: link.basicAuth.username, password: "", set: true }
|
||||
: null,
|
||||
entities: describeEntities(link),
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes read-only copies of what other users shared with the account. */
|
||||
async function removeSharedCopies(userId: string): Promise<void> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
const sharedHosts = await db
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
|
||||
const { deleteOwnedHost } = await import("../../hosts/delete-host.js");
|
||||
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
|
||||
|
||||
const sharedCredentials = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
isNotNull(sshCredentials.sharedSource),
|
||||
),
|
||||
);
|
||||
const { deleteOwnedCredential } =
|
||||
await import("../../hosts/delete-credential.js");
|
||||
for (const credential of sharedCredentials) {
|
||||
await deleteOwnedCredential(userId, credential.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every row that syncs, keeping local-only ones. */
|
||||
async function wipeSyncedData(userId: string): Promise<void> {
|
||||
const entities = [...listEntities()].reverse();
|
||||
for (const entity of entities) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
for (const row of await entity.load(userId)) {
|
||||
await entity.erase?.(userId, String(row.syncId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entity.table || entity.singleton) continue;
|
||||
for (const row of await listStoredRows(entity, userId)) {
|
||||
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
|
||||
if (typeof row.syncId !== "string") continue;
|
||||
await deleteStoredRow(entity, userId, row.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not clear synced data", {
|
||||
operation: "sync_wipe",
|
||||
entityType: entity.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function problemFor(error: unknown) {
|
||||
if (!(error instanceof RemoteError)) {
|
||||
return { problem: "unreachable", message: String(error) };
|
||||
}
|
||||
switch (error.kind) {
|
||||
case "tls":
|
||||
return { problem: "tls_untrusted", message: error.message };
|
||||
case "basic_auth":
|
||||
return { problem: "basic_auth_required", message: error.message };
|
||||
case "proxy":
|
||||
return error.status === 404
|
||||
? { problem: "not_termix", message: error.message }
|
||||
: { problem: "proxy_login", message: error.message };
|
||||
case "signed_out":
|
||||
case "server":
|
||||
// A Termix older than 2.9 has no /sync/v2 and answers with an error.
|
||||
return { problem: "version_mismatch", message: error.message };
|
||||
default:
|
||||
return { problem: "unreachable", message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/status:
|
||||
* get:
|
||||
* summary: This desktop's link to a server
|
||||
* description: Desktop only. Whether the app is linked, to which server and account, how syncing is going, and which kinds of data sync.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The link status.
|
||||
*/
|
||||
router.get("/link/status", ...guard, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read sync status", error, {
|
||||
operation: "sync_status",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read sync status" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/probe:
|
||||
* post:
|
||||
* summary: Check a server address before linking
|
||||
* description: Desktop only. Tries the server with the given proxy settings and says what is in the way, if anything. On success the proxy settings are also applied to the desktop's browser session so the sign-in page loads.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The server's name and version, or the problem found.
|
||||
*/
|
||||
router.post("/link/probe", ...guard, async (req: Request, res: Response) => {
|
||||
const target = readTarget(req.body ?? {});
|
||||
if (!target) return res.json({ ok: false, problem: "invalid_url" });
|
||||
try {
|
||||
const info = await remoteJson<{
|
||||
termix?: boolean;
|
||||
name?: string;
|
||||
version?: string;
|
||||
protocol?: number;
|
||||
}>(target, "/sync/v2/info", { timeoutMs: 15_000 });
|
||||
if (!info?.termix) return res.json({ ok: false, problem: "not_termix" });
|
||||
if (info.protocol !== SYNC_PROTOCOL_VERSION) {
|
||||
return res.json({
|
||||
ok: false,
|
||||
problem: "version_mismatch",
|
||||
version: info.version,
|
||||
});
|
||||
}
|
||||
await applyElectronProxyConfig({
|
||||
serverUrl: target.serverUrl,
|
||||
customHeaders: target.customHeaders ?? [],
|
||||
basicAuth: target.basicAuth ?? null,
|
||||
allowInvalidCertificate: !!target.allowInvalidCertificate,
|
||||
}).catch(() => {});
|
||||
res.json({
|
||||
ok: true,
|
||||
serverUrl: target.serverUrl,
|
||||
name: info.name ?? "Termix",
|
||||
version: info.version ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({ ok: false, ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/preview:
|
||||
* get:
|
||||
* summary: How much this desktop has that would sync
|
||||
* description: Desktop only. Row counts per kind of data, shown before choosing whether to merge this desktop's data into the account or replace it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Counts per entity type.
|
||||
*/
|
||||
router.get("/link/preview", ...guard, async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
registerCoreSyncEntities();
|
||||
const counts: Record<string, number> = {};
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
counts[entity.type] = (await entity.load(userId)).length;
|
||||
continue;
|
||||
}
|
||||
if (!entity.table) continue;
|
||||
counts[entity.type] = (await listStoredRows(entity, userId)).filter(
|
||||
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
|
||||
).length;
|
||||
} catch {
|
||||
counts[entity.type] = 0;
|
||||
}
|
||||
}
|
||||
res.json({ counts });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/complete:
|
||||
* post:
|
||||
* summary: Link this desktop to a server account
|
||||
* description: Desktop only. Takes the session from signing in to the server, trades it for a long-lived desktop session, and starts syncing. "merge" uploads this desktop's data into the account; "replace" deletes it here first and takes the account's.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Linked; the new status.
|
||||
* 400:
|
||||
* description: Missing server or session.
|
||||
* 502:
|
||||
* description: The server refused the session.
|
||||
*/
|
||||
router.post("/link/complete", ...guard, async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const body = req.body ?? {};
|
||||
const target = readTarget(body);
|
||||
const token = typeof body.token === "string" ? body.token : "";
|
||||
const mode = body.mode === "replace" ? "replace" : "merge";
|
||||
if (!target || !token) {
|
||||
return res.status(400).json({ error: "Missing server or session" });
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceName =
|
||||
typeof body.deviceName === "string" && body.deviceName.trim()
|
||||
? body.deviceName.trim()
|
||||
: defaultDeviceName();
|
||||
const linked = await remoteJson<{
|
||||
token: string;
|
||||
user: { id: string; username: string };
|
||||
server?: { version?: string | null };
|
||||
}>({ ...target, sessionToken: token }, "/sync/v2/link", {
|
||||
method: "POST",
|
||||
body: { deviceName },
|
||||
});
|
||||
|
||||
const previous = await getLink();
|
||||
if (previous) {
|
||||
await removeSharedCopies(previous.userId);
|
||||
await deleteRecordsForUser(previous.userId);
|
||||
}
|
||||
if (mode === "replace") {
|
||||
await wipeSyncedData(userId);
|
||||
}
|
||||
await deleteRecordsForUser(userId);
|
||||
|
||||
const link = await createLink({
|
||||
userId,
|
||||
serverUrl: target.serverUrl,
|
||||
serverName: typeof body.serverName === "string" ? body.serverName : null,
|
||||
serverVersion: linked.server?.version ?? null,
|
||||
sessionToken: linked.token,
|
||||
customHeaders: target.customHeaders,
|
||||
basicAuth: target.basicAuth,
|
||||
allowInvalidCertificate: target.allowInvalidCertificate,
|
||||
remoteUserId: linked.user.id,
|
||||
remoteUsername: linked.user.username,
|
||||
});
|
||||
const engine = getSyncEngine();
|
||||
engine.reset();
|
||||
await applyElectronProxyConfig(link).catch(() => {});
|
||||
engine.request(0);
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.warn("Linking to a server failed", {
|
||||
operation: "sync_link_complete",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(502).json({ ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/relogin:
|
||||
* post:
|
||||
* summary: Sign this desktop back in after its session ended
|
||||
* description: Desktop only. Takes a new sign-in session for the same account and resumes syncing where it stopped.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Signed in again; the new status.
|
||||
* 409:
|
||||
* description: The sign-in was for a different account.
|
||||
*/
|
||||
router.post("/link/relogin", ...guard, async (req: Request, res: Response) => {
|
||||
const token = typeof req.body?.token === "string" ? req.body.token : "";
|
||||
const link = await getLink();
|
||||
if (!link || !token) return res.status(400).json({ error: "Not linked" });
|
||||
try {
|
||||
const linked = await remoteJson<{
|
||||
token: string;
|
||||
user: { id: string; username: string };
|
||||
}>({ ...link, sessionToken: token }, "/sync/v2/link", {
|
||||
method: "POST",
|
||||
body: { deviceName: defaultDeviceName() },
|
||||
});
|
||||
if (link.remoteUserId && linked.user.id !== link.remoteUserId) {
|
||||
return res.status(409).json({ error: "different_account" });
|
||||
}
|
||||
await updateLink({
|
||||
sessionToken: linked.token,
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
});
|
||||
getSyncEngine().request(0);
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
res.status(502).json({ ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/unlink:
|
||||
* post:
|
||||
* summary: Unlink this desktop from its server
|
||||
* description: Desktop only. Ends the desktop's session on the server when it can be reached. With keepData the synced data stays on this device as its own; otherwise it is deleted. Copies of what others shared with the account are always removed.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Unlinked.
|
||||
*/
|
||||
router.post("/link/unlink", ...guard, async (req: Request, res: Response) => {
|
||||
const keepData = req.body?.keepData !== false;
|
||||
const link = await getLink();
|
||||
if (!link) return res.json(await statusBody());
|
||||
const userId = link.userId;
|
||||
try {
|
||||
await remoteJson(link, "/sync/v2/unlink", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
timeoutMs: 10_000,
|
||||
}).catch(() => {});
|
||||
|
||||
const engine = getSyncEngine();
|
||||
engine.reset();
|
||||
await removeSharedCopies(userId);
|
||||
if (keepData) {
|
||||
// New syncIds so this data reads as new if it is ever linked again.
|
||||
for (const entity of listEntities()) {
|
||||
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
|
||||
}
|
||||
} else {
|
||||
await wipeSyncedData(userId);
|
||||
}
|
||||
await deleteRecordsForUser(userId);
|
||||
await deleteLink();
|
||||
await applyElectronProxyConfig(null).catch(() => {});
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.error("Unlinking failed", error, { operation: "sync_unlink" });
|
||||
res.status(500).json({ error: "Failed to unlink" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/sync:
|
||||
* post:
|
||||
* summary: Sync now
|
||||
* description: Desktop only. Runs a sync pass and waits for it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The status after the pass.
|
||||
*/
|
||||
router.post("/link/sync", ...guard, async (_req: Request, res: Response) => {
|
||||
await getSyncEngine().syncNow();
|
||||
res.json(await statusBody());
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/settings:
|
||||
* put:
|
||||
* summary: Change what syncs and how the server is reached
|
||||
* description: Desktop only. disabledTypes lists the kinds of data that stay on this device. Proxy headers and basic auth left blank keep their saved values.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new status.
|
||||
* 400:
|
||||
* description: Not linked.
|
||||
*/
|
||||
router.put("/link/settings", ...guard, async (req: Request, res: Response) => {
|
||||
const link = await getLink();
|
||||
if (!link) return res.status(400).json({ error: "Not linked" });
|
||||
const body = req.body ?? {};
|
||||
const patch: Parameters<typeof updateLink>[0] = {};
|
||||
|
||||
if (Array.isArray(body.disabledTypes)) {
|
||||
const disabled = body.disabledTypes.filter(
|
||||
(type: unknown): type is string =>
|
||||
typeof type === "string" && type !== "accountProfile",
|
||||
);
|
||||
const changed = new Set([
|
||||
...disabled.filter((type: string) => !link.disabledTypes.includes(type)),
|
||||
...link.disabledTypes.filter((type) => !disabled.includes(type)),
|
||||
]);
|
||||
patch.disabledTypes = disabled;
|
||||
// A type switched back on is pulled again in full.
|
||||
patch.knownTypes = link.knownTypes.filter((type) => !changed.has(type));
|
||||
}
|
||||
if (Array.isArray(body.customHeaders)) {
|
||||
const saved = new Map(link.customHeaders.map((h) => [h.name, h.value]));
|
||||
patch.customHeaders = (body.customHeaders as unknown[])
|
||||
.filter(
|
||||
(header): header is { name: string; value?: string } =>
|
||||
!!header && typeof (header as { name?: unknown }).name === "string",
|
||||
)
|
||||
.map((header) => ({
|
||||
name: header.name,
|
||||
value: header.value || saved.get(header.name) || "",
|
||||
}))
|
||||
.filter((header) => header.value);
|
||||
patch.customHeaders = readHeaders(patch.customHeaders);
|
||||
}
|
||||
if (body.basicAuth === null) {
|
||||
patch.basicAuth = null;
|
||||
} else if (body.basicAuth && typeof body.basicAuth === "object") {
|
||||
const next = readBasicAuth(body.basicAuth);
|
||||
if (next && !next.password && link.basicAuth) {
|
||||
next.password = link.basicAuth.password;
|
||||
}
|
||||
patch.basicAuth = next;
|
||||
}
|
||||
if (typeof body.allowInvalidCertificate === "boolean") {
|
||||
patch.allowInvalidCertificate = body.allowInvalidCertificate;
|
||||
}
|
||||
|
||||
const updated = await updateLink(patch);
|
||||
await applyElectronProxyConfig(updated).catch(() => {});
|
||||
getSyncEngine().request(0);
|
||||
res.json(await statusBody());
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/conflicts:
|
||||
* get:
|
||||
* summary: Local edits that lost to a newer edit on the server
|
||||
* description: Desktop only. The server's version was kept; each entry holds this device's version so it can be restored.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The conflicts, oldest first.
|
||||
*/
|
||||
router.get("/link/conflicts", ...guard, async (req: Request, res: Response) => {
|
||||
const conflicts = await listConflicts(userOf(req));
|
||||
res.json({
|
||||
conflicts: conflicts.map((conflict) => {
|
||||
let row: SyncRow = {};
|
||||
try {
|
||||
row = JSON.parse(conflict.localRow);
|
||||
} catch {
|
||||
row = {};
|
||||
}
|
||||
return {
|
||||
id: conflict.id,
|
||||
entityType: conflict.entityType,
|
||||
syncId: conflict.syncId,
|
||||
name: row.name ?? row.title ?? row.label ?? row.ip ?? conflict.syncId,
|
||||
createdAt: conflict.createdAt,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/conflicts/{id}:
|
||||
* post:
|
||||
* summary: Settle a conflict
|
||||
* description: Desktop only. keep "mine" puts this device's version back, and the next sync sends it to the server. keep "server" drops it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Settled.
|
||||
* 404:
|
||||
* description: No such conflict.
|
||||
*/
|
||||
router.post(
|
||||
"/link/conflicts/:id",
|
||||
...guard,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const conflict = await getConflict(userId, Number(req.params.id));
|
||||
if (!conflict) return res.status(404).json({ error: "Not found" });
|
||||
try {
|
||||
if (req.body?.keep === "mine") {
|
||||
const entity = getEntity(conflict.entityType);
|
||||
if (entity && !entity.readOnly) {
|
||||
const row = JSON.parse(conflict.localRow) as SyncRow;
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...row, syncId: conflict.syncId },
|
||||
createResolvers(userId),
|
||||
);
|
||||
}
|
||||
}
|
||||
await deleteConflict(userId, conflict.id);
|
||||
getSyncEngine().request(0);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not settle a sync conflict", {
|
||||
operation: "sync_conflict",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to settle conflict" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/errors:
|
||||
* get:
|
||||
* summary: Changes the server refused
|
||||
* description: Desktop only. Each entry names the record and why the server refused it. A refused change is not sent again until it changes or is retried.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The refused changes.
|
||||
*/
|
||||
router.get("/link/errors", ...guard, async (req: Request, res: Response) => {
|
||||
const errors = await listRecordErrors(userOf(req));
|
||||
res.json({
|
||||
errors: errors.map((record) => ({
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
reason: record.error,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/errors/retry:
|
||||
* post:
|
||||
* summary: Send refused changes again
|
||||
* description: Desktop only. Clears every refusal and syncs.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The status after the pass.
|
||||
*/
|
||||
router.post(
|
||||
"/link/errors/retry",
|
||||
...guard,
|
||||
async (req: Request, res: Response) => {
|
||||
await clearRecordErrors(userOf(req));
|
||||
await getSyncEngine().syncNow();
|
||||
res.json(await statusBody());
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/session:
|
||||
* get:
|
||||
* summary: The linked server and session, for connecting to it directly
|
||||
* description: Desktop only. Used when a host is set to connect through the server rather than from this device.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The server address and session token, or nulls when not linked.
|
||||
*/
|
||||
router.get("/link/session", ...guard, async (_req: Request, res: Response) => {
|
||||
const link = await getLink();
|
||||
if (!link?.sessionToken || link.status === "signed_out") {
|
||||
return res.json({ serverUrl: null, token: null });
|
||||
}
|
||||
res.json({ serverUrl: link.serverUrl, token: link.sessionToken });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,544 +0,0 @@
|
||||
/**
|
||||
* What the sync provider plugin gets from core through ctx.sync.host: the
|
||||
* replica store, the change feed, the linked server registry, sessions and
|
||||
* a few helpers. Core stays neutral; the plugin decides what to do with it.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import * as tar from "tar";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { PluginSyncHost, SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import { sendCoreAlert } from "../notify/core-notify.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import {
|
||||
getEntity,
|
||||
listEntities,
|
||||
onEntitiesChanged,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../plugins/sync-registry.js";
|
||||
import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
createCurrentPluginRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { resolveBrandingSettings } from "../database/routes/branding-settings.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
listStoredRows,
|
||||
loadWireRow,
|
||||
loadWireRows,
|
||||
regenerateSyncIds,
|
||||
writeWireRow,
|
||||
type Resolvers,
|
||||
} from "./store.js";
|
||||
import {
|
||||
clearRecordErrors,
|
||||
deleteConflict,
|
||||
deleteRecordsForUser,
|
||||
getConflict,
|
||||
listChanges,
|
||||
listConflicts,
|
||||
listRecordErrors,
|
||||
listRecords,
|
||||
putRecord,
|
||||
recordKey,
|
||||
saveConflict,
|
||||
setRecordError,
|
||||
} from "./records.js";
|
||||
import { orderSelfReferences } from "./wire.js";
|
||||
import { onLocalWrite } from "./server/write-listeners.js";
|
||||
import {
|
||||
createLink,
|
||||
deleteLink,
|
||||
getLink,
|
||||
listLinks,
|
||||
updateLink,
|
||||
} from "./client/link-store.js";
|
||||
import {
|
||||
createAccount,
|
||||
getActiveUserId,
|
||||
listAccounts,
|
||||
removeAccount,
|
||||
setActiveUserId,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
RemoteError,
|
||||
remoteFetch,
|
||||
remoteJson,
|
||||
} from "./client/http.js";
|
||||
import { applyElectronProxyConfig } from "./client/electron-proxy.js";
|
||||
import { lastRemotePlugins, mirrorPlugins } from "./client/plugins.js";
|
||||
import { defaultDeviceName } from "./client/device.js";
|
||||
|
||||
function entityOf(type: string): RegisteredSyncEntity {
|
||||
const entity = getEntity(type);
|
||||
if (!entity) throw new Error(`Unknown sync entity "${type}"`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
function info(entity: RegisteredSyncEntity) {
|
||||
return {
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
covers: entity.covers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deletes every row that syncs, keeping local-only ones. */
|
||||
async function wipeSyncedData(userId: string): Promise<void> {
|
||||
const entities = [...listEntities()].reverse();
|
||||
for (const entity of entities) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
for (const row of await entity.load(userId)) {
|
||||
await entity.erase?.(userId, String(row.syncId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entity.table || entity.singleton) continue;
|
||||
for (const row of await listStoredRows(entity, userId)) {
|
||||
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
|
||||
if (typeof row.syncId !== "string") continue;
|
||||
await deleteStoredRow(entity, userId, row.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not clear synced data", {
|
||||
operation: "sync_wipe",
|
||||
entityType: entity.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes read-only copies of what other users shared with the account. */
|
||||
async function removeSharedCopies(userId: string): Promise<void> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
const sharedHosts = await db
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
|
||||
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
||||
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
|
||||
|
||||
const sharedCredentials = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
isNotNull(sshCredentials.sharedSource),
|
||||
),
|
||||
);
|
||||
const { deleteOwnedCredential } =
|
||||
await import("../hosts/delete-credential.js");
|
||||
for (const credential of sharedCredentials) {
|
||||
await deleteOwnedCredential(userId, credential.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCoreEntities(): Promise<void> {
|
||||
const { registerCoreSyncEntities } = await import("./entities.js");
|
||||
registerCoreSyncEntities();
|
||||
}
|
||||
|
||||
async function counts(userId: string): Promise<Record<string, number>> {
|
||||
await ensureCoreEntities();
|
||||
const result: Record<string, number> = {};
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
result[entity.type] = (await entity.load(userId)).length;
|
||||
continue;
|
||||
}
|
||||
if (!entity.table) continue;
|
||||
result[entity.type] = (await listStoredRows(entity, userId)).filter(
|
||||
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
|
||||
).length;
|
||||
} catch {
|
||||
result[entity.type] = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listPluginsForDesktop() {
|
||||
const records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const grants = createCurrentPluginPermissionGrantRepository();
|
||||
const plugins = [];
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
// A bundled plugin updated from a signed .tmxplug can be mirrored.
|
||||
artifact: !!plugin.artifact,
|
||||
enabled: records.get(plugin.id)?.state === "enabled",
|
||||
active: plugin.state === "active",
|
||||
desktop: resolvePluginDesktopMode(plugin.manifest),
|
||||
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
|
||||
granted: (await grants.listByPlugin(plugin.id)).map(
|
||||
(grant) => grant.capability,
|
||||
),
|
||||
});
|
||||
}
|
||||
const { customRegistryKeys } = await import("../plugins/registries.js");
|
||||
return { plugins, registryKeys: await customRegistryKeys() };
|
||||
}
|
||||
|
||||
async function sendPackage(
|
||||
pluginId: string,
|
||||
target: unknown,
|
||||
): Promise<boolean> {
|
||||
const res = target as Response;
|
||||
const plugin = getPluginRuntime()
|
||||
.loader.list()
|
||||
.find((candidate) => candidate.id === pluginId);
|
||||
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) return false;
|
||||
const artifact = plugin.artifact ?? "";
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
if (fs.existsSync(artifact)) {
|
||||
const sig = `${artifact}.sig`;
|
||||
if (fs.existsSync(sig)) {
|
||||
res.setHeader(
|
||||
"X-Termix-Signature",
|
||||
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString("base64"),
|
||||
);
|
||||
}
|
||||
fs.createReadStream(artifact).pipe(res);
|
||||
return true;
|
||||
}
|
||||
const temp = path.join(
|
||||
os.tmpdir(),
|
||||
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
|
||||
);
|
||||
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
|
||||
const stream = fs.createReadStream(temp);
|
||||
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
|
||||
stream.pipe(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createSyncHost(): PluginSyncHost {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
return {
|
||||
embedded: process.env.ELECTRON_EMBEDDED === "true",
|
||||
info: {
|
||||
appName: async () => {
|
||||
try {
|
||||
return (
|
||||
await resolveBrandingSettings(createCurrentSettingsRepository())
|
||||
).appName;
|
||||
} catch {
|
||||
return "Termix";
|
||||
}
|
||||
},
|
||||
version: () => getLocalVersion(),
|
||||
},
|
||||
|
||||
entities: {
|
||||
list: () => listEntities().map(info),
|
||||
get: (type) => {
|
||||
const entity = getEntity(type);
|
||||
return entity ? info(entity) : undefined;
|
||||
},
|
||||
ensureCore: () => ensureCoreEntities(),
|
||||
onChanged: (listener) => onEntitiesChanged(listener),
|
||||
onLocalWrite: (listener) => onLocalWrite(listener),
|
||||
},
|
||||
|
||||
replica: {
|
||||
resolvers: (userId, onMissing) => createResolvers(userId, onMissing),
|
||||
loadRows: async (type, userId, resolvers) => {
|
||||
const result = await loadWireRows(
|
||||
entityOf(type),
|
||||
userId,
|
||||
resolvers as Resolvers,
|
||||
);
|
||||
return { rows: result.rows, failed: result.failed };
|
||||
},
|
||||
loadRow: (type, userId, syncId, resolvers) =>
|
||||
loadWireRow(entityOf(type), userId, syncId, resolvers as Resolvers),
|
||||
writeRow: async (type, userId, wire, resolvers) => {
|
||||
await writeWireRow(
|
||||
entityOf(type),
|
||||
userId,
|
||||
wire,
|
||||
resolvers as Resolvers,
|
||||
);
|
||||
},
|
||||
deleteRow: async (type, userId, syncId) => {
|
||||
await deleteStoredRow(entityOf(type), userId, syncId);
|
||||
},
|
||||
orderSelfReferences: (type, rows) =>
|
||||
orderSelfReferences(entityOf(type), rows as SyncRow[]),
|
||||
counts: (userId) => counts(userId),
|
||||
wipe: (userId) => wipeSyncedData(userId),
|
||||
regenerateSyncIds: async (userId) => {
|
||||
for (const entity of listEntities()) {
|
||||
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
|
||||
}
|
||||
},
|
||||
removeSharedCopies: (userId) => removeSharedCopies(userId),
|
||||
records: {
|
||||
list: (userId) => listRecords(userId) as never,
|
||||
put: (userId, type, syncId, values) =>
|
||||
putRecord(userId, type, syncId, values),
|
||||
setError: (userId, type, syncId, reason, hash) =>
|
||||
setRecordError(userId, type, syncId, reason, hash),
|
||||
clearErrors: (userId) => clearRecordErrors(userId),
|
||||
listErrors: (userId) => listRecordErrors(userId) as never,
|
||||
deleteForUser: (userId) => deleteRecordsForUser(userId),
|
||||
key: (type, syncId) => recordKey(type, syncId),
|
||||
},
|
||||
conflicts: {
|
||||
save: (userId, type, syncId, row, revision) =>
|
||||
saveConflict(userId, type, syncId, row, revision),
|
||||
list: (userId) => listConflicts(userId) as never,
|
||||
get: (userId, id) => getConflict(userId, id) as never,
|
||||
delete: (userId, id) => deleteConflict(userId, id),
|
||||
},
|
||||
},
|
||||
|
||||
feed: {
|
||||
withUserLock: async (userId, run) => {
|
||||
const { withUserLock } = await import("./server/feed.js");
|
||||
return withUserLock(userId, run);
|
||||
},
|
||||
changes: async (userId, cursor, limit, types) => {
|
||||
const { withUserLock, reconcileUser } =
|
||||
await import("./server/feed.js");
|
||||
const page = await withUserLock(userId, async () => {
|
||||
const { wire } = await reconcileUser(userId);
|
||||
const records = await listChanges(userId, cursor, limit + 1, types);
|
||||
return { wire, records };
|
||||
});
|
||||
const hasMore = page.records.length > limit;
|
||||
const records = [];
|
||||
for (const record of page.records.slice(0, limit)) {
|
||||
if (!getEntity(record.entityType)) continue;
|
||||
const row = record.deleted
|
||||
? null
|
||||
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
|
||||
null);
|
||||
// A live record with no row failed to serialize; it comes again once
|
||||
// it changes, rather than arriving as a delete.
|
||||
if (!record.deleted && !row) continue;
|
||||
records.push({
|
||||
seq: record.seq,
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
revision: record.revision,
|
||||
deleted: !!record.deleted,
|
||||
row,
|
||||
});
|
||||
}
|
||||
const slice = page.records.slice(0, limit);
|
||||
return {
|
||||
records,
|
||||
hasMore,
|
||||
nextCursor: slice.length ? slice[slice.length - 1].seq : cursor,
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
} as never;
|
||||
},
|
||||
parsePushOps: async (body) => {
|
||||
const { parsePushOps } = await import("./server/push.js");
|
||||
return parsePushOps(body) as never;
|
||||
},
|
||||
applyPush: async (userId, ops) => {
|
||||
const { withUserLock } = await import("./server/feed.js");
|
||||
const { applyPush } = await import("./server/push.js");
|
||||
return withUserLock(userId, () =>
|
||||
applyPush(userId, ops as never),
|
||||
) as never;
|
||||
},
|
||||
listen: async (userId, res) => {
|
||||
const { addListener } = await import("./server/feed.js");
|
||||
return addListener(userId, res as Response);
|
||||
},
|
||||
},
|
||||
|
||||
links: {
|
||||
list: () => listLinks() as never,
|
||||
get: (userId) => getLink(userId) as never,
|
||||
create: (link) => createLink(link) as never,
|
||||
update: (patch, userId) => updateLink(patch, userId) as never,
|
||||
remove: (userId) => deleteLink(userId),
|
||||
},
|
||||
|
||||
accounts: {
|
||||
list: () => listAccounts(),
|
||||
create: () => createAccount(),
|
||||
setActive: (userId) => setActiveUserId(userId),
|
||||
activeId: () => getActiveUserId(),
|
||||
remove: (userId) => removeAccount(userId),
|
||||
},
|
||||
|
||||
http: {
|
||||
json: (target, pathname, request) =>
|
||||
remoteJson(target, pathname, request),
|
||||
fetch: ((target, pathname, request) =>
|
||||
remoteFetch(target, pathname, request)) as never,
|
||||
normalizeServerUrl,
|
||||
isRemoteError: ((error: unknown) =>
|
||||
error instanceof RemoteError) as never,
|
||||
},
|
||||
|
||||
sessions: {
|
||||
issueDesktop: (userId, options) =>
|
||||
authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${options.days}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: options.deviceInfo,
|
||||
}),
|
||||
revoke: async (sessionId) => {
|
||||
await authManager.revokeSession(sessionId);
|
||||
},
|
||||
find: async (sessionId) => {
|
||||
const session =
|
||||
await createCurrentSessionRepository().findById(sessionId);
|
||||
return session
|
||||
? { expiresAt: session.expiresAt, deviceInfo: session.deviceInfo }
|
||||
: null;
|
||||
},
|
||||
findUser: async (userId) => {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return user ? { id: user.id, username: user.username } : null;
|
||||
},
|
||||
verify: async (token) => {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
return payload
|
||||
? { userId: payload.userId, sessionId: payload.sessionId }
|
||||
: null;
|
||||
},
|
||||
setCookie: (req, res, value, options) => {
|
||||
(res as Response).cookie(
|
||||
options?.name ?? "jwt",
|
||||
value,
|
||||
authManager.getSecureCookieOptions(
|
||||
req as Request,
|
||||
(options?.days ?? 30) * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
);
|
||||
},
|
||||
clearCookie: (req, res, name) => {
|
||||
(res as Response).clearCookie(
|
||||
name ?? "jwt",
|
||||
authManager.getClearCookieOptions(req as Request),
|
||||
);
|
||||
},
|
||||
middleware: () => authManager.createAuthMiddleware(),
|
||||
desktopOnly: () => (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
},
|
||||
|
||||
hosts: {
|
||||
findForSync: async (userId, syncId) => {
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncId(
|
||||
syncId,
|
||||
);
|
||||
if (hostId === null) return null;
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) return null;
|
||||
const row = await createCurrentHostRepository().findById(hostId);
|
||||
return { id: hostId, name: row?.name ?? row?.ip ?? null };
|
||||
},
|
||||
statusBySyncId: async (userId) => {
|
||||
const { hostStatus } =
|
||||
await import("../plugins/host-status-provider.js");
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const statuses = await hostStatus.statusesFor(userId, null);
|
||||
const entries = [...statuses.entries()];
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
entries.map(([id]) => id),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [id, entry] of entries) {
|
||||
if (!allowed.has(id)) continue;
|
||||
const row = await hostRepository.findById(id);
|
||||
if (row?.syncId) result[row.syncId] = entry;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
listForDesktop: listPluginsForDesktop as never,
|
||||
sendPackage,
|
||||
mirror: (link) => mirrorPlugins(link as never),
|
||||
lastRemote: () => lastRemotePlugins() as never,
|
||||
localNames: () => {
|
||||
const names = new Map<string, string>();
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
names.set(plugin.id, plugin.manifest.name);
|
||||
}
|
||||
return names;
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
applyProxy: (link) => applyElectronProxyConfig(link as never),
|
||||
deviceName: defaultDeviceName,
|
||||
},
|
||||
|
||||
alerts: {
|
||||
send: (alert) =>
|
||||
sendCoreAlert({
|
||||
title: alert.title,
|
||||
body: alert.body,
|
||||
severity: alert.severity,
|
||||
category: alert.category,
|
||||
dedupeKey: alert.dedupeKey,
|
||||
audience: "admins",
|
||||
link: alert.link,
|
||||
} as never),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Bumped when a desktop and server can no longer understand each other. */
|
||||
export const SYNC_PROTOCOL_VERSION = 1;
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* The sync provider: a plugin that answers /sync/v2 and /sync/link through
|
||||
* ctx.sync.provide. Core only forwards. With no provider the paths answer 404,
|
||||
* so a server without it looks like one that never had sync.
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
import type { PluginSyncProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginSyncProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export function provideSync(
|
||||
pluginId: string,
|
||||
provider: PluginSyncProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearSyncProviders(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
export function hasSyncProvider(): boolean {
|
||||
return providers.size > 0;
|
||||
}
|
||||
|
||||
/** Sends the request to the first provider that does not pass. */
|
||||
export const syncForwarder: RequestHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const entries = [...providers.values()];
|
||||
const run = (index: number): void => {
|
||||
const entry = entries[index];
|
||||
if (!entry) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const pass = (error?: unknown): void => {
|
||||
if (error) next(error);
|
||||
else run(index + 1);
|
||||
};
|
||||
try {
|
||||
const result = entry.provider.handle(req, res, pass);
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch((error: unknown) => fail(entry, error, res, next));
|
||||
}
|
||||
} catch (error) {
|
||||
fail(entry, error, res, next);
|
||||
}
|
||||
};
|
||||
run(0);
|
||||
};
|
||||
|
||||
function fail(
|
||||
entry: Entry,
|
||||
error: unknown,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
syncLogger.warn("Sync provider failed", {
|
||||
operation: "sync_provider",
|
||||
pluginId: entry.pluginId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (res.headersSent) next(error);
|
||||
else res.status(500).json({ error: "Sync is not available right now" });
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { markChanged } from "./feed.js";
|
||||
import { notifyLocalWrite } from "./write-listeners.js";
|
||||
|
||||
const READS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
const writeListeners = new Set<() => void>();
|
||||
|
||||
/** Called after every successful write request, for a linked desktop's engine. */
|
||||
export function onLocalWrite(listener: () => void): () => void {
|
||||
writeListeners.add(listener);
|
||||
return () => writeListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Any successful write may change what a linked desktop should have, so it
|
||||
@@ -17,7 +23,7 @@ export function syncChangeWatcher(
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode >= 400) return;
|
||||
markChanged();
|
||||
notifyLocalWrite();
|
||||
for (const listener of writeListeners) listener();
|
||||
});
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import * as tar from "tar";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { getLocalVersion } from "../../utils/app-version.js";
|
||||
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { resolveBrandingSettings } from "../../database/routes/branding-settings.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import { listChanges, recordKey } from "../records.js";
|
||||
import { addListener, reconcileUser, withUserLock } from "./feed.js";
|
||||
import { applyPush, parsePushOps } from "./push.js";
|
||||
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
|
||||
|
||||
const SESSION_DAYS = 365;
|
||||
const REFRESH_WITHIN_MS = 60 * 24 * 60 * 60 * 1000;
|
||||
const MAX_PAGE = 1000;
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
registerCoreSyncEntities();
|
||||
|
||||
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:
|
||||
* get:
|
||||
* summary: Describe this server to a desktop that wants to link to it
|
||||
* description: Public. Lets the desktop app confirm it reached Termix and not a proxy's login page, and that the sync protocol matches.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server name, version and sync protocol version.
|
||||
*/
|
||||
router.get("/v2/info", async (_req: Request, res: Response) => {
|
||||
let name = "Termix";
|
||||
try {
|
||||
name = (await resolveBrandingSettings(createCurrentSettingsRepository()))
|
||||
.appName;
|
||||
} catch {
|
||||
// The default name is fine.
|
||||
}
|
||||
res.setHeader("X-Termix-Sync", String(SYNC_PROTOCOL_VERSION));
|
||||
res.json({
|
||||
termix: true,
|
||||
name,
|
||||
version: getLocalVersion(),
|
||||
protocol: SYNC_PROTOCOL_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/link:
|
||||
* post:
|
||||
* summary: Link a desktop to this account
|
||||
* description: Trades the short login session the desktop just signed in with for a long-lived desktop session, which shows in the account's sessions list and is revoked like any other. The login session is ended.
|
||||
* tags:
|
||||
* - Sync
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* deviceName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The desktop session token and the account it belongs to.
|
||||
* 401:
|
||||
* description: Not signed in.
|
||||
* 403:
|
||||
* description: API keys cannot link a desktop.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/link",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
if ((req as AuthenticatedRequest).apiKeyId) {
|
||||
return res.status(403).json({ error: "API keys cannot link a desktop" });
|
||||
}
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) return res.status(401).json({ error: "User not found" });
|
||||
const rawName =
|
||||
typeof req.body?.deviceName === "string" ? req.body.deviceName : "";
|
||||
const deviceName = rawName.trim().slice(0, 100) || "Termix Desktop";
|
||||
const token = await authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${SESSION_DAYS}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: `${deviceName} (sync)`,
|
||||
});
|
||||
const loginSession = (req as AuthenticatedRequest).sessionId;
|
||||
if (loginSession) await authManager.revokeSession(loginSession);
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, username: user.username },
|
||||
server: { version: getLocalVersion() },
|
||||
});
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to link a desktop", error, {
|
||||
operation: "sync_link",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to link" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/session/refresh:
|
||||
* post:
|
||||
* summary: Keep a linked desktop's session from expiring
|
||||
* description: Returns a fresh desktop session when the current one ends within 60 days, and ends the old one. Otherwise returns null and the desktop keeps its token.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new token, or null.
|
||||
* 401:
|
||||
* description: Session revoked or expired.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/session/refresh",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
try {
|
||||
if (!sessionId) return res.json({ token: null });
|
||||
const session =
|
||||
await createCurrentSessionRepository().findById(sessionId);
|
||||
if (!session) return res.status(401).json({ error: "Session not found" });
|
||||
const remaining = new Date(session.expiresAt).getTime() - Date.now();
|
||||
if (remaining > REFRESH_WITHIN_MS) return res.json({ token: null });
|
||||
const token = await authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${SESSION_DAYS}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: session.deviceInfo,
|
||||
});
|
||||
await authManager.revokeSession(sessionId);
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to refresh a desktop session", error, {
|
||||
operation: "sync_session_refresh",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to refresh session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/unlink:
|
||||
* post:
|
||||
* summary: End a linked desktop's session
|
||||
* description: Called by a desktop when it unlinks, so its session stops working here right away.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The session was ended.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/unlink",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
try {
|
||||
if (sessionId) await authManager.revokeSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to end a desktop session", error, {
|
||||
operation: "sync_unlink",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unlink" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/changes:
|
||||
* get:
|
||||
* summary: Pull what changed for this account after a cursor
|
||||
* description: Returns records in change order. Each has the entity type, syncId, revision, whether it is deleted, and the row when it is not. Records of entity types this server does not currently run are skipped but still move the cursor.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: cursor
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: types
|
||||
* description: Comma-separated entity types, to backfill only those.
|
||||
* schema:
|
||||
* type: string
|
||||
* 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.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/changes",
|
||||
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 types =
|
||||
typeof req.query.types === "string" && req.query.types
|
||||
? req.query.types.split(",").filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const page = await withUserLock(userId, async () => {
|
||||
const { wire } = await reconcileUser(userId);
|
||||
const records = await listChanges(userId, cursor, limit + 1, types);
|
||||
return { wire, records };
|
||||
});
|
||||
const hasMore = page.records.length > limit;
|
||||
const records = page.records.slice(0, limit);
|
||||
const changes = [];
|
||||
for (const record of records) {
|
||||
if (!getEntity(record.entityType)) continue;
|
||||
const row = record.deleted
|
||||
? null
|
||||
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
|
||||
null);
|
||||
// A live record with no row failed to serialize; it comes again once it
|
||||
// changes, rather than arriving as a delete.
|
||||
if (!record.deleted && !row) continue;
|
||||
changes.push({
|
||||
seq: record.seq,
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
revision: record.revision,
|
||||
deleted: !!record.deleted,
|
||||
row,
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
changes,
|
||||
nextCursor: records.length ? records[records.length - 1].seq : cursor,
|
||||
hasMore,
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read sync changes", error, {
|
||||
operation: "sync_changes",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read changes" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/push:
|
||||
* post:
|
||||
* summary: Push a desktop's changes
|
||||
* description: Each op carries the revision the desktop last saw. Ops are applied in dependency order and each gets its own result, ok with the new revision, conflict with the server's version, or rejected with a reason.
|
||||
* tags:
|
||||
* - Sync
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ops:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: One result per op, in the order sent.
|
||||
* 400:
|
||||
* description: Malformed ops, or more than 500.
|
||||
* 500:
|
||||
* description: Failed to apply changes.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/push",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const ops = parsePushOps(req.body);
|
||||
if (!ops) return res.status(400).json({ error: "Invalid ops" });
|
||||
try {
|
||||
const results = await withUserLock(userId, () => applyPush(userId, ops));
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to apply pushed changes", error, {
|
||||
operation: "sync_push",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply changes" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/events:
|
||||
* get:
|
||||
* summary: Stream a notice whenever this account's data changes
|
||||
* description: Server-sent events. A "change" event means there is something new to pull. Comments are sent every 25 seconds to keep proxies from closing the stream.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: An event stream.
|
||||
*/
|
||||
router.get("/v2/events", authenticateJWT, (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
res.write(`event: ready\ndata: {}\n\n`);
|
||||
const remove = addListener(userId, res);
|
||||
req.on("close", remove);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/hosts/{syncId}:
|
||||
* get:
|
||||
* summary: This server's id for a host, by its sync id
|
||||
* description: For a linked desktop that asks the server to act on a host (a tunnel through it), which needs the server's own id. Only for hosts the caller can connect to.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: syncId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The host's id and name.
|
||||
* 404:
|
||||
* description: No such host the caller can connect to.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/hosts/:syncId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
try {
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncId(
|
||||
String(req.params.syncId),
|
||||
);
|
||||
if (hostId === null) return res.status(404).json({ error: "Not found" });
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess)
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const row = await createCurrentHostRepository().findById(hostId);
|
||||
res.json({ id: hostId, name: row?.name ?? row?.ip ?? null });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to look up a host by sync id", error, {
|
||||
operation: "sync_host_lookup",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to look up host" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/host-status:
|
||||
* get:
|
||||
* summary: Status of the hosts this account can see, keyed by sync id
|
||||
* description: For a linked desktop whose hosts connect through this server, so it can show their status against its own copies.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Status entries by host sync id.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/host-status",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
try {
|
||||
const { hostStatus } =
|
||||
await import("../../plugins/host-status-provider.js");
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const statuses = await hostStatus.statusesFor(userId, null);
|
||||
const entries = [...statuses.entries()];
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
entries.map(([id]) => id),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [id, entry] of entries) {
|
||||
if (!allowed.has(id)) continue;
|
||||
const row = await hostRepository.findById(id);
|
||||
if (row?.syncId) result[row.syncId] = entry;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read host status for sync", error, {
|
||||
operation: "sync_host_status",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read host status" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function listPluginsForDesktop() {
|
||||
return getPluginRuntime().loader.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/plugins:
|
||||
* get:
|
||||
* summary: The features this server runs, for a linked desktop to mirror
|
||||
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. registryKeys lists the signing keys of the custom registries an admin added, so plugins from them load on the desktop.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin list.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/plugins",
|
||||
authenticateJWT,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const grants = createCurrentPluginPermissionGrantRepository();
|
||||
const plugins = [];
|
||||
for (const plugin of await listPluginsForDesktop()) {
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
// A bundled plugin updated from a signed .tmxplug can be mirrored.
|
||||
artifact: !!plugin.artifact,
|
||||
enabled: records.get(plugin.id)?.state === "enabled",
|
||||
active: plugin.state === "active",
|
||||
desktop: resolvePluginDesktopMode(plugin.manifest),
|
||||
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
|
||||
granted: (await grants.listByPlugin(plugin.id)).map(
|
||||
(grant) => grant.capability,
|
||||
),
|
||||
});
|
||||
}
|
||||
const { customRegistryKeys } =
|
||||
await import("../../plugins/registries.js");
|
||||
res.json({ plugins, registryKeys: await customRegistryKeys() });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to list plugins for sync", error, {
|
||||
operation: "sync_plugins",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list plugins" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/plugins/{id}/package:
|
||||
* get:
|
||||
* summary: Download an installed plugin so a linked desktop can install it
|
||||
* description: Only for plugins installed on this server, or bundled ones replaced by a signed update. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin archive.
|
||||
* 404:
|
||||
* description: No installed plugin with that id.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/plugins/:id/package",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
try {
|
||||
const plugin = (await listPluginsForDesktop()).find(
|
||||
(candidate) => candidate.id === pluginId,
|
||||
);
|
||||
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) {
|
||||
return res.status(404).json({ error: "Plugin not found" });
|
||||
}
|
||||
const artifact = plugin.artifact ?? "";
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
if (fs.existsSync(artifact)) {
|
||||
const sig = `${artifact}.sig`;
|
||||
if (fs.existsSync(sig)) {
|
||||
res.setHeader(
|
||||
"X-Termix-Signature",
|
||||
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString(
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
}
|
||||
return fs.createReadStream(artifact).pipe(res);
|
||||
}
|
||||
const temp = path.join(
|
||||
os.tmpdir(),
|
||||
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
|
||||
);
|
||||
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
|
||||
const stream = fs.createReadStream(temp);
|
||||
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to package a plugin for sync", error, {
|
||||
operation: "sync_plugin_package",
|
||||
pluginId,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Failed to package plugin" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,11 +0,0 @@
|
||||
const writeListeners = new Set<() => void>();
|
||||
|
||||
/** Called after every successful write request, for a linked desktop's engine. */
|
||||
export function onLocalWrite(listener: () => void): () => void {
|
||||
writeListeners.add(listener);
|
||||
return () => writeListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function notifyLocalWrite(): void {
|
||||
for (const listener of writeListeners) listener();
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* On a desktop linked to a server, the account it is signed in to there.
|
||||
* That account is who the user is; the local profile only mirrors it.
|
||||
*/
|
||||
export async function describeDesktopLink(userId: string) {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
|
||||
try {
|
||||
const { getLink } = await import("./client/link-store.js");
|
||||
const link = await getLink(userId);
|
||||
if (!link) return null;
|
||||
return {
|
||||
serverUrl: link.serverUrl,
|
||||
serverName: link.serverName,
|
||||
username: link.account?.username ?? link.remoteUsername,
|
||||
isAdmin: !!link.account?.isAdmin,
|
||||
roles: link.account?.roles ?? [],
|
||||
permissions: link.account?.permissions ?? [],
|
||||
status: link.status,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the app shows. On a desktop linked to a server that is the account it
|
||||
* is signed in to there, not the local user that holds the copy of its data.
|
||||
*/
|
||||
export async function shownIdentity(user: {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean | null;
|
||||
}) {
|
||||
const linked = await describeDesktopLink(user.id);
|
||||
return {
|
||||
linked,
|
||||
username: linked?.username || user.username,
|
||||
isAdmin: linked ? linked.isAdmin : !!user.isAdmin,
|
||||
};
|
||||
}
|
||||
@@ -136,22 +136,6 @@ describe("isNativeTokenExportRequest", () => {
|
||||
});
|
||||
|
||||
describe("resolveDesktopAutoSessionUser", () => {
|
||||
it("returns the account the user switched to, while it exists", () => {
|
||||
const admin = makeUser({ id: "admin", isAdmin: true });
|
||||
const chosen = makeUser({ id: "chosen" });
|
||||
expect(resolveDesktopAutoSessionUser([admin, chosen], "chosen")).toBe(
|
||||
chosen,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the usual pick when the chosen account is gone", () => {
|
||||
const admin = makeUser({ id: "admin", isAdmin: true });
|
||||
const other = makeUser({ id: "other" });
|
||||
expect(resolveDesktopAutoSessionUser([admin, other], "deleted")).toBe(
|
||||
admin,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the sole local user regardless of having a real password", () => {
|
||||
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
|
||||
@@ -25,10 +25,6 @@ const issueSocketTicket = vi.fn(
|
||||
async (userId: string, sessionId: string) => `ticket:${userId}:${sessionId}`,
|
||||
);
|
||||
|
||||
const issueApiKeySocketTicket = vi.fn(
|
||||
async (userId: string, keyId: string) => `key-ticket:${userId}:${keyId}`,
|
||||
);
|
||||
|
||||
const authenticate: RequestHandler = (req, _res, next) => {
|
||||
Object.assign(req, {
|
||||
userId: "u1",
|
||||
@@ -42,10 +38,7 @@ function app() {
|
||||
const router = express.Router();
|
||||
registerUserSocketTicketRoutes(router, {
|
||||
authenticateJWT: authenticate,
|
||||
authManager: {
|
||||
issueSocketTicket,
|
||||
issueApiKeySocketTicket,
|
||||
} as unknown as AuthManager,
|
||||
authManager: { issueSocketTicket } as unknown as AuthManager,
|
||||
});
|
||||
const server = express();
|
||||
server.use(cookieParser());
|
||||
@@ -57,7 +50,6 @@ beforeEach(() => {
|
||||
state.sessionId = "s1";
|
||||
state.apiKeyId = undefined;
|
||||
issueSocketTicket.mockClear();
|
||||
issueApiKeySocketTicket.mockClear();
|
||||
});
|
||||
|
||||
describe("POST /users/ws-ticket", () => {
|
||||
@@ -106,23 +98,3 @@ describe("POST /users/ws-ticket", () => {
|
||||
expect(issueSocketTicket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /users/ws-ticket/api-key", () => {
|
||||
it("gives an API key a ticket as its user", async () => {
|
||||
state.sessionId = undefined;
|
||||
state.apiKeyId = "key-1";
|
||||
|
||||
const res = await request(app()).post("/users/ws-ticket/api-key");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ticket).toBe("key-ticket:u1:key-1");
|
||||
expect(res.headers["cache-control"]).toBe("no-store");
|
||||
});
|
||||
|
||||
it("refuses a login session, which has its own ticket", async () => {
|
||||
const res = await request(app()).post("/users/ws-ticket/api-key");
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(issueApiKeySocketTicket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createEmbeddedViews, cleanBounds } = createRequire(import.meta.url)(
|
||||
"../../../../electron/embedded-view.cjs",
|
||||
);
|
||||
|
||||
const views: FakeView[] = [];
|
||||
|
||||
class FakeView {
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
loadURL: vi.fn().mockResolvedValue(undefined),
|
||||
isDestroyed: () => false,
|
||||
close: vi.fn(),
|
||||
});
|
||||
setBounds = vi.fn();
|
||||
setVisible = vi.fn();
|
||||
constructor(public options: { webPreferences: Record<string, unknown> }) {
|
||||
views.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = { x: 10.4, y: 20.6, width: 300, height: 200 };
|
||||
let contentView: {
|
||||
addChildView: ReturnType<typeof vi.fn>;
|
||||
removeChildView: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let session: EventEmitter & Record<string, ReturnType<typeof vi.fn>>;
|
||||
let manager: ReturnType<typeof createEmbeddedViews>;
|
||||
|
||||
beforeEach(() => {
|
||||
views.length = 0;
|
||||
contentView = { addChildView: vi.fn(), removeChildView: vi.fn() };
|
||||
session = Object.assign(new EventEmitter(), {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined),
|
||||
closeAllConnections: vi.fn().mockResolvedValue(undefined),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
});
|
||||
manager = createEmbeddedViews({
|
||||
WebContentsView: FakeView,
|
||||
session: { fromPartition: vi.fn(() => session) },
|
||||
getMainWindow: () => ({ contentView, isDestroyed: () => false }),
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded views", () => {
|
||||
it("opens a locked down view at the given bounds", () => {
|
||||
manager.open({ id: "a", url: "https://pve.example:8006/", bounds });
|
||||
const [view] = views;
|
||||
expect(view.options.webPreferences).toMatchObject({
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
});
|
||||
expect(contentView.addChildView).toHaveBeenCalledWith(view);
|
||||
expect(view.setBounds).toHaveBeenCalledWith({
|
||||
x: 10,
|
||||
y: 21,
|
||||
width: 300,
|
||||
height: 200,
|
||||
});
|
||||
expect(view.webContents.loadURL).toHaveBeenCalledWith(
|
||||
"https://pve.example:8006/",
|
||||
);
|
||||
const handler = session.setPermissionRequestHandler.mock.calls[0][0];
|
||||
const allow = vi.fn();
|
||||
handler(null, "media", allow);
|
||||
expect(allow).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("refuses urls that are not plain http or https", () => {
|
||||
expect(() =>
|
||||
manager.open({ id: "a", url: "file:///etc/passwd", bounds }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
manager.open({ id: "a", url: "https://u:p@host/", bounds }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("moves and hides an open view, and closes it", () => {
|
||||
manager.open({ id: "a", url: "https://x.example/", bounds });
|
||||
manager.setBounds("a", { x: 1, y: 2, width: 3, height: 4 });
|
||||
manager.setVisible("a", false);
|
||||
const [view] = views;
|
||||
expect(view.setBounds).toHaveBeenLastCalledWith({
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
});
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false);
|
||||
manager.destroy("a");
|
||||
expect(contentView.removeChildView).toHaveBeenCalledWith(view);
|
||||
expect(view.webContents.close).toHaveBeenCalled();
|
||||
expect(session.clearStorageData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses a view for the same url and replaces it for a new one", () => {
|
||||
manager.open({ id: "a", url: "https://x.example/", bounds });
|
||||
manager.open({ id: "a", url: "https://x.example/", bounds });
|
||||
expect(views).toHaveLength(1);
|
||||
manager.open({ id: "a", url: "https://y.example/", bounds });
|
||||
expect(views).toHaveLength(2);
|
||||
expect(contentView.removeChildView).toHaveBeenCalledWith(views[0]);
|
||||
});
|
||||
|
||||
it("accepts an invalid certificate only for its own origin and only when asked", () => {
|
||||
manager.open({
|
||||
id: "a",
|
||||
url: "https://x.example/",
|
||||
ignoreCert: true,
|
||||
bounds,
|
||||
});
|
||||
manager.open({ id: "b", url: "https://z.example/", bounds });
|
||||
const check = (index: number, url: string) => {
|
||||
const event = { preventDefault: vi.fn() };
|
||||
const callback = vi.fn();
|
||||
const handled = manager.handleCertificateError(
|
||||
event,
|
||||
views[index].webContents,
|
||||
url,
|
||||
callback,
|
||||
);
|
||||
return { handled, allowed: callback.mock.calls[0]?.[0] };
|
||||
};
|
||||
expect(check(0, "https://x.example/page")).toEqual({
|
||||
handled: true,
|
||||
allowed: true,
|
||||
});
|
||||
expect(check(0, "https://other.example/")).toEqual({
|
||||
handled: true,
|
||||
allowed: false,
|
||||
});
|
||||
expect(check(1, "https://z.example/")).toEqual({
|
||||
handled: true,
|
||||
allowed: false,
|
||||
});
|
||||
expect(
|
||||
manager.handleCertificateError({}, {}, "https://x.example/", vi.fn()),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("closes everything", () => {
|
||||
manager.open({ id: "a", url: "https://x.example/", bounds });
|
||||
manager.open({ id: "b", url: "https://y.example/", bounds });
|
||||
manager.destroyAll();
|
||||
expect(contentView.removeChildView).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps bounds as whole, non negative numbers", () => {
|
||||
expect(cleanBounds({ x: -5, y: "x", width: 9.5, height: 1 })).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { describe, expect, it } from "vitest";
|
||||
const require = createRequire(import.meta.url);
|
||||
const linkedServer = require("../../../../electron/linked-server.cjs") as {
|
||||
setLinkedServer: (config: unknown) => void;
|
||||
clientCertOptionsFor: (url: string) => Record<string, unknown>;
|
||||
};
|
||||
|
||||
const source = fs.readFileSync(
|
||||
@@ -60,26 +59,3 @@ describe("linked server certificate exception", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("linked server client certificate", () => {
|
||||
const clientCert = {
|
||||
pfx: Buffer.from("pfx-bytes").toString("base64"),
|
||||
passphrase: "pw",
|
||||
};
|
||||
|
||||
it("hands the PFX to TLS made for the linked origin only", () => {
|
||||
linkedServer.setLinkedServer({ origin: ORIGIN, clientCert });
|
||||
expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({
|
||||
pfx: Buffer.from("pfx-bytes"),
|
||||
passphrase: "pw",
|
||||
});
|
||||
expect(
|
||||
linkedServer.clientCertOptionsFor("https://elsewhere.example/"),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("offers nothing when the link has no certificate", () => {
|
||||
linkedServer.setLinkedServer({ origin: ORIGIN });
|
||||
expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
performPortKnocking: vi.fn(),
|
||||
createJumpHostChain: vi.fn(),
|
||||
createSocks5Connection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { logger: log, sshLogger: log };
|
||||
});
|
||||
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
|
||||
performPortKnocking: mocks.performPortKnocking,
|
||||
}));
|
||||
vi.mock("../../../hosts/jump-host-chain.js", () => ({
|
||||
createJumpHostChain: mocks.createJumpHostChain,
|
||||
}));
|
||||
vi.mock("../../../utils/socks5-helper.js", () => ({
|
||||
createSocks5Connection: mocks.createSocks5Connection,
|
||||
}));
|
||||
vi.mock("../../../hosts/ssh-dns.js", () => ({
|
||||
resolveSshConnectConfigHost: async (config: unknown) => config,
|
||||
}));
|
||||
|
||||
const { clearSshTransports, openProvidedTransport, provideSshTransport } =
|
||||
await import("../../../hosts/connect/transport-providers.js");
|
||||
const { openSshTransport } =
|
||||
await import("../../../hosts/connect/transport.js");
|
||||
|
||||
const host = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
|
||||
|
||||
afterEach(() => {
|
||||
clearSshTransports();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ssh transport providers", () => {
|
||||
it("opens nothing when no provider claims the host", async () => {
|
||||
provideSshTransport("a", { claims: () => false, open: vi.fn() });
|
||||
expect(await openProvidedTransport(host)).toBeNull();
|
||||
});
|
||||
|
||||
it("hands back the claiming provider's stream", async () => {
|
||||
const stream = new PassThrough();
|
||||
const open = vi.fn(async () => stream);
|
||||
provideSshTransport("a", { claims: (id) => id === 7, open });
|
||||
expect(await openProvidedTransport(host)).toBe(stream);
|
||||
expect(open).toHaveBeenCalledWith(host);
|
||||
expect(await openProvidedTransport({ ...host, id: 8 })).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a provider that throws while claiming as not claiming", async () => {
|
||||
provideSshTransport("a", {
|
||||
claims: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
open: vi.fn(),
|
||||
});
|
||||
expect(await openProvidedTransport(host)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops using a provider once it is removed", async () => {
|
||||
const remove = provideSshTransport("a", {
|
||||
claims: () => true,
|
||||
open: async () => new PassThrough(),
|
||||
});
|
||||
remove();
|
||||
expect(await openProvidedTransport(host)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openSshTransport with a provider", () => {
|
||||
it("uses the provided stream and skips knocking, jump hosts and proxies", async () => {
|
||||
const stream = new PassThrough();
|
||||
provideSshTransport("a", { claims: () => true, open: async () => stream });
|
||||
const config: Record<string, unknown> = {};
|
||||
const result = await openSshTransport(
|
||||
{
|
||||
...host,
|
||||
portKnockSequence: [{ port: 1 }],
|
||||
jumpHosts: [{ hostId: 1 }],
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
userId: "u1",
|
||||
} as never,
|
||||
config as never,
|
||||
);
|
||||
expect(result).toEqual({ jumpClient: null, via: "provided" });
|
||||
expect(config.sock).toBe(stream);
|
||||
expect(mocks.performPortKnocking).not.toHaveBeenCalled();
|
||||
expect(mocks.createJumpHostChain).not.toHaveBeenCalled();
|
||||
expect(mocks.createSocks5Connection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connects directly when nothing claims the host", async () => {
|
||||
const config: Record<string, unknown> = {};
|
||||
const result = await openSshTransport(host as never, config as never);
|
||||
expect(result.via).toBe("direct");
|
||||
expect(config.sock).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import net from "node:net";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import http from "node:http";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveHostById: vi.fn(),
|
||||
openSshTransport: vi.fn(),
|
||||
handler: null as null | ((connection: unknown) => Promise<void>),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { sshLogger: log, logger: log };
|
||||
});
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: mocks.resolveHostById,
|
||||
}));
|
||||
vi.mock("../../hosts/connect/transport.js", () => ({
|
||||
openSshTransport: mocks.openSshTransport,
|
||||
}));
|
||||
vi.mock("../../plugins/ws.js", () => ({
|
||||
registerCoreWsRoute: (_path: string, handler: never) => {
|
||||
mocks.handler = handler;
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const { registerSshRelay } = await import("../../hosts/ssh-relay.js");
|
||||
|
||||
let echo: net.Server;
|
||||
let echoPort = 0;
|
||||
let relayServer: http.Server;
|
||||
let wss: WebSocketServer;
|
||||
let relayUrl = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
registerSshRelay();
|
||||
|
||||
echo = net.createServer((socket) => socket.pipe(socket));
|
||||
await new Promise<void>((resolve) => echo.listen(0, "127.0.0.1", resolve));
|
||||
echoPort = (echo.address() as AddressInfo).port;
|
||||
|
||||
relayServer = http.createServer();
|
||||
wss = new WebSocketServer({ server: relayServer });
|
||||
wss.on("connection", (socket, request) => {
|
||||
void mocks.handler?.({ userId: "user-1", request, socket });
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
relayServer.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
relayUrl = `ws://127.0.0.1:${(relayServer.address() as AddressInfo).port}`;
|
||||
|
||||
mocks.resolveHostById.mockResolvedValue({
|
||||
id: 5,
|
||||
ip: "127.0.0.1",
|
||||
port: echoPort,
|
||||
});
|
||||
mocks.openSshTransport.mockResolvedValue({ jumpClient: null, via: "direct" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
wss.close();
|
||||
await new Promise((resolve) => relayServer.close(resolve));
|
||||
await new Promise((resolve) => echo.close(resolve));
|
||||
});
|
||||
|
||||
function open(query: string): Promise<{
|
||||
socket: WebSocket;
|
||||
closed: Promise<{ code: number; reason: string }>;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new WebSocket(`${relayUrl}/${query}`);
|
||||
const closed = new Promise<{ code: number; reason: string }>((done) =>
|
||||
socket.once("close", (code, reason) =>
|
||||
done({ code, reason: reason.toString() }),
|
||||
),
|
||||
);
|
||||
socket.once("open", () => resolve({ socket, closed }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("ssh relay", () => {
|
||||
it("carries bytes to the host and back", async () => {
|
||||
const { socket } = await open("?hostId=5");
|
||||
const received = new Promise<string>((resolve) =>
|
||||
socket.once("message", (data) => resolve(data.toString())),
|
||||
);
|
||||
socket.send(Buffer.from("SSH-2.0-test\r\n"));
|
||||
expect(await received).toBe("SSH-2.0-test\r\n");
|
||||
expect(mocks.resolveHostById).toHaveBeenCalledWith(5, "user-1");
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("uses the stream the transport made, such as a jump host channel", async () => {
|
||||
const channel = new PassThrough();
|
||||
const end = vi.fn();
|
||||
mocks.openSshTransport.mockImplementation(
|
||||
async (_host: unknown, config: { sock?: unknown }) => {
|
||||
config.sock = channel;
|
||||
return { jumpClient: { end }, via: "jump" };
|
||||
},
|
||||
);
|
||||
const { socket } = await open("?hostId=5");
|
||||
const received = new Promise<string>((resolve) =>
|
||||
socket.once("message", (data) => resolve(data.toString())),
|
||||
);
|
||||
socket.send("ping");
|
||||
expect(await received).toBe("ping");
|
||||
socket.close();
|
||||
await vi.waitFor(() => expect(end).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("refuses a host the user can't connect to", async () => {
|
||||
mocks.resolveHostById.mockResolvedValue(null);
|
||||
const { closed } = await open("?hostId=5");
|
||||
expect(await closed).toMatchObject({
|
||||
code: 1008,
|
||||
reason: "Host not found or access denied",
|
||||
});
|
||||
expect(mocks.openSshTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("needs a host id", async () => {
|
||||
const { closed } = await open("");
|
||||
expect((await closed).code).toBe(1008);
|
||||
expect(mocks.resolveHostById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the socket when the host can't be reached", async () => {
|
||||
mocks.openSshTransport.mockRejectedValue(new Error("no route"));
|
||||
const { closed } = await open("?hostId=5");
|
||||
expect((await closed).code).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { expect, it } from "vitest";
|
||||
const require = createRequire(import.meta.url);
|
||||
const ssh2 = require("ssh2");
|
||||
const {
|
||||
patchKeyParserSource,
|
||||
patchAgentSource,
|
||||
patchProtocolSource,
|
||||
} = require("../../../../scripts/patch-ssh2-sk.cjs");
|
||||
|
||||
const SK_ED = "sk-ssh-ed25519@openssh.com";
|
||||
|
||||
function str(value: Buffer | string): Buffer {
|
||||
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(buf.length);
|
||||
return Buffer.concat([len, buf]);
|
||||
}
|
||||
|
||||
function skPublicBlob(): Buffer {
|
||||
const { publicKey } = generateKeyPairSync("ed25519");
|
||||
const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32);
|
||||
return Buffer.concat([str(SK_ED), str(raw), str("ssh:")]);
|
||||
}
|
||||
|
||||
it("patches are idempotent on the installed dependency", () => {
|
||||
const files: Array<[string, (s: string) => string]> = [
|
||||
["ssh2/lib/protocol/keyParser.js", patchKeyParserSource],
|
||||
["ssh2/lib/agent.js", patchAgentSource],
|
||||
["ssh2/lib/protocol/Protocol.js", patchProtocolSource],
|
||||
];
|
||||
for (const [id, patch] of files) {
|
||||
const source = readFileSync(require.resolve(id), "utf8");
|
||||
const result = patch(source);
|
||||
expect(patch(result)).toBe(result);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails explicitly when the dependency changes", () => {
|
||||
expect(() => patchKeyParserSource("changed")).toThrow("review");
|
||||
expect(() => patchAgentSource("changed")).toThrow("review");
|
||||
expect(() => patchProtocolSource("changed")).toThrow("review");
|
||||
});
|
||||
|
||||
it("parses a security key public blob and keeps it byte for byte", () => {
|
||||
const blob = skPublicBlob();
|
||||
const key = ssh2.utils.parseKey(blob);
|
||||
expect(key).not.toBeInstanceOf(Error);
|
||||
expect(key.type).toBe(SK_ED);
|
||||
expect(Buffer.compare(key.getPublicSSH(), blob)).toBe(0);
|
||||
});
|
||||
|
||||
it("sends flags and counter after the signature in publickey auth", async () => {
|
||||
const blob = skPublicBlob();
|
||||
const key = ssh2.utils.parseKey(blob);
|
||||
const trailer = Buffer.from([0x01, 0, 0, 0, 7]);
|
||||
const signature = Buffer.alloc(64, 9);
|
||||
(signature as Buffer & { skTrailer?: Buffer }).skTrailer = trailer;
|
||||
|
||||
class FakeAgent extends ssh2.BaseAgent {
|
||||
getIdentities(cb: (err: Error | null, keys?: unknown[]) => void) {
|
||||
cb(null, [key]);
|
||||
}
|
||||
sign(
|
||||
_key: unknown,
|
||||
_data: Buffer,
|
||||
_opts: unknown,
|
||||
cb: (err: Error | null, sig?: Buffer) => void,
|
||||
) {
|
||||
cb(null, signature);
|
||||
}
|
||||
}
|
||||
|
||||
const hostKey = ssh2.utils.generateKeyPairSync("ed25519").private;
|
||||
const received = await new Promise<Buffer>((resolve) => {
|
||||
const server = new ssh2.Server({ hostKeys: [hostKey] }, (client: any) => {
|
||||
client.on("authentication", (ctx: any) => {
|
||||
if (ctx.method !== "publickey") return ctx.reject(["publickey"]);
|
||||
if (!ctx.signature) return ctx.accept();
|
||||
resolve(
|
||||
Buffer.concat([
|
||||
Buffer.from(ctx.key.data),
|
||||
Buffer.from(ctx.signature),
|
||||
]),
|
||||
);
|
||||
ctx.reject();
|
||||
});
|
||||
client.on("error", () => {});
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const conn = new ssh2.Client();
|
||||
conn.on("error", () => {});
|
||||
conn.on("close", () => server.close());
|
||||
conn.connect({
|
||||
host: "127.0.0.1",
|
||||
port: server.address().port,
|
||||
username: "u",
|
||||
agent: new FakeAgent(),
|
||||
readyTimeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(
|
||||
Buffer.compare(
|
||||
received,
|
||||
Buffer.concat([blob, Buffer.alloc(64, 9), trailer]),
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
@@ -30,8 +30,6 @@ const h = vi.hoisted(() => ({
|
||||
existingAccess: [] as Array<{ id: number }>,
|
||||
revoked: [] as number[],
|
||||
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
|
||||
folderCalls: [] as Array<unknown[]>,
|
||||
folderEmpty: true,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -98,15 +96,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => h.users,
|
||||
}),
|
||||
createCurrentHostFolderRepository: () => ({
|
||||
upsertMetadata: async (...args: unknown[]) => {
|
||||
h.folderCalls.push(["ensure", ...args]);
|
||||
},
|
||||
deleteEmptyFolderRecords: async (...args: unknown[]) => {
|
||||
h.folderCalls.push(["remove", ...args]);
|
||||
return h.folderEmpty;
|
||||
},
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
createEncryptedForUser: async (
|
||||
userId: string,
|
||||
@@ -972,45 +961,3 @@ describe("ctx.hosts.locks", () => {
|
||||
expect(h.updates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.folders", () => {
|
||||
beforeEach(() => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.actor = "user-1";
|
||||
h.folderCalls = [];
|
||||
h.folderEmpty = true;
|
||||
});
|
||||
|
||||
it("needs hosts:write", async () => {
|
||||
h.granted = new Set();
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.folders.ensure("Site")).rejects.toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the folder row for the acting user with its style", async () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await hosts.folders.ensure("Site", { icon: "cloud-cog" });
|
||||
expect(h.folderCalls).toEqual([
|
||||
["ensure", "user-1", "Site", null, "cloud-cog"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes only an empty folder", async () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.folders.removeEmpty("Site")).toBe(true);
|
||||
h.folderEmpty = false;
|
||||
expect(await hosts.folders.removeEmpty("Site")).toBe(false);
|
||||
expect(h.folderCalls[0]).toEqual(["remove", "user-1", "Site"]);
|
||||
});
|
||||
});
|
||||
@@ -155,30 +155,6 @@ describe("plugin user data", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exports a table with a text primary key", async () => {
|
||||
const credentials = defineTable("credential", {
|
||||
id: varchar(64).primaryKey(),
|
||||
userId: refUser().notNull(),
|
||||
label: text(),
|
||||
});
|
||||
for (const statement of createTableSql("sqlite", "fixture", credentials)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
registerTable("fixture", credentials);
|
||||
sqlite.exec(
|
||||
"INSERT INTO p_fixture_credential (id, user_id, label) VALUES ('abc', 'u1', 'key')",
|
||||
);
|
||||
|
||||
const file = new Database(":memory:");
|
||||
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
file.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
await writeUserPluginTables(file, "u1");
|
||||
expect(file.prepare("SELECT * FROM p_fixture_credential").all()).toEqual([
|
||||
{ id: "abc", user_id: "u1", label: "key" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes large tables in chunks and lets the event loop run between them", async () => {
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
|
||||
|
||||
@@ -154,26 +154,6 @@ describe("ctx.ws routing", () => {
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("serves a socket core owns without any plugin grant", async () => {
|
||||
state.granted = new Set();
|
||||
const seen: string[] = [];
|
||||
ws.registerCoreWsRoute("/relay", ({ userId }) => {
|
||||
seen.push(userId);
|
||||
});
|
||||
|
||||
const base = await startServer();
|
||||
const signedIn = await connect(
|
||||
`${base}/plugin-ws/core/relay`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
const anonymous = await connect(`${base}/plugin-ws/core/relay`);
|
||||
|
||||
expect(signedIn.outcome).toBe("open");
|
||||
expect(anonymous.outcome).toBe(401);
|
||||
expect(seen).toEqual([state.userId]);
|
||||
if (signedIn.outcome === "open") signedIn.socket.close();
|
||||
});
|
||||
|
||||
it("404s a path nothing registered", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
users: [] as Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
registeredAt: string;
|
||||
}>,
|
||||
saved: null as string | null,
|
||||
create: vi.fn(),
|
||||
assignRole: vi.fn(),
|
||||
registerUser: vi.fn(),
|
||||
deleteUserAndRelatedData: vi.fn(),
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => mocks.users,
|
||||
findById: async (id: string) =>
|
||||
mocks.users.find((u) => u.id === id) ?? null,
|
||||
create: mocks.create,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
assignRoleNameToUser: mocks.assignRole,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async () => mocks.saved,
|
||||
set: async (key: string, value: string) => {
|
||||
mocks.set(key, value);
|
||||
mocks.saved = value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => ({ registerUser: mocks.registerUser }) },
|
||||
}));
|
||||
vi.mock("../../database/routes/delete-user-data.js", () => ({
|
||||
deleteUserAndRelatedData: mocks.deleteUserAndRelatedData,
|
||||
}));
|
||||
|
||||
const {
|
||||
createAccount,
|
||||
getActiveUserId,
|
||||
listAccounts,
|
||||
removeAccount,
|
||||
setActiveUserId,
|
||||
} = await import("../../sync/accounts.js");
|
||||
|
||||
function user(
|
||||
id: string,
|
||||
isAdmin = false,
|
||||
registeredAt = "2026-01-01T00:00:00Z",
|
||||
) {
|
||||
return { id, username: id, isAdmin, registeredAt };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.saved = null;
|
||||
mocks.users = [
|
||||
user("local", true),
|
||||
user("work", false, "2026-02-01T00:00:00Z"),
|
||||
];
|
||||
});
|
||||
|
||||
describe("desktop accounts", () => {
|
||||
it("signs in as the usual user until one is chosen", async () => {
|
||||
expect(await getActiveUserId()).toBe("local");
|
||||
await setActiveUserId("work");
|
||||
expect(await getActiveUserId()).toBe("work");
|
||||
});
|
||||
|
||||
it("will not switch to an account that does not exist", async () => {
|
||||
await expect(setActiveUserId("nope")).rejects.toThrow("No such account");
|
||||
expect(mocks.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the accounts with the active one marked", async () => {
|
||||
await setActiveUserId("work");
|
||||
expect(await listAccounts()).toEqual([
|
||||
expect.objectContaining({ id: "local", active: false }),
|
||||
expect.objectContaining({ id: "work", active: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes an empty admin account with its own data key", async () => {
|
||||
const id = await createAccount();
|
||||
expect(mocks.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id, isAdmin: true, passwordHash: "" }),
|
||||
);
|
||||
expect(mocks.assignRole).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: id, roleName: "admin" }),
|
||||
);
|
||||
expect(mocks.registerUser).toHaveBeenCalledWith(id, expect.any(String));
|
||||
});
|
||||
|
||||
it("removes an account and its data", async () => {
|
||||
await removeAccount("work");
|
||||
expect(mocks.deleteUserAndRelatedData).toHaveBeenCalledWith("work");
|
||||
});
|
||||
|
||||
it("moves to another account when the active one is removed", async () => {
|
||||
await setActiveUserId("work");
|
||||
mocks.set.mockClear();
|
||||
await removeAccount("work");
|
||||
expect(mocks.set).toHaveBeenCalledWith("desktop_active_user", "local");
|
||||
});
|
||||
|
||||
it("keeps the last account", async () => {
|
||||
mocks.users = [user("local", true)];
|
||||
await expect(removeAccount("local")).rejects.toThrow("last account");
|
||||
expect(mocks.deleteUserAndRelatedData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import https from "node:https";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ drizzle: null as unknown }));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => state.drizzle,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({ getEncryptionKey: async () => Buffer.alloc(32, 5) }),
|
||||
},
|
||||
}));
|
||||
|
||||
const { TestSqliteDatabase } =
|
||||
await import("../../database/repositories/test-support.js");
|
||||
const { createLink, getLink, updateLink } =
|
||||
await import("../../../sync/client/link-store.js");
|
||||
const { RemoteError, remoteJson } =
|
||||
await import("../../../sync/client/http.js");
|
||||
|
||||
function hasOpenssl(): boolean {
|
||||
try {
|
||||
execFileSync("openssl", ["version"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const openssl = hasOpenssl();
|
||||
const PASSPHRASE = "pfx-pass";
|
||||
let dir = "";
|
||||
let pfx = "";
|
||||
let serverKey = "";
|
||||
let serverCert = "";
|
||||
let clientCert = "";
|
||||
|
||||
function run(args: string[]) {
|
||||
execFileSync("openssl", args, { cwd: dir, stdio: "ignore" });
|
||||
}
|
||||
|
||||
function selfSigned(name: string) {
|
||||
run([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-days",
|
||||
"2",
|
||||
"-keyout",
|
||||
`${name}.key`,
|
||||
"-out",
|
||||
`${name}.crt`,
|
||||
"-subj",
|
||||
`/CN=${name}`,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (!openssl) return;
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-mtls-"));
|
||||
selfSigned("server");
|
||||
selfSigned("client");
|
||||
run([
|
||||
"pkcs12",
|
||||
"-export",
|
||||
"-inkey",
|
||||
"client.key",
|
||||
"-in",
|
||||
"client.crt",
|
||||
"-out",
|
||||
"client.p12",
|
||||
"-passout",
|
||||
`pass:${PASSPHRASE}`,
|
||||
]);
|
||||
pfx = fs.readFileSync(path.join(dir, "client.p12")).toString("base64");
|
||||
serverKey = fs.readFileSync(path.join(dir, "server.key"), "utf8");
|
||||
serverCert = fs.readFileSync(path.join(dir, "server.crt"), "utf8");
|
||||
clientCert = fs.readFileSync(path.join(dir, "client.crt"), "utf8");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe.skipIf(!openssl)("client certificates", () => {
|
||||
let server: https.Server;
|
||||
let base: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = https.createServer(
|
||||
{
|
||||
key: serverKey,
|
||||
cert: serverCert,
|
||||
ca: [clientCert],
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
(_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
base = `https://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
it("presents the PFX to a server that requires one", async () => {
|
||||
const result = await remoteJson<{ ok: boolean }>(
|
||||
{
|
||||
serverUrl: base,
|
||||
allowInvalidCertificate: true,
|
||||
clientCert: { pfx, passphrase: PASSPHRASE, name: "client.p12" },
|
||||
},
|
||||
"/x",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a missing client certificate as its own error", async () => {
|
||||
const error = await remoteJson(
|
||||
{ serverUrl: base, allowInvalidCertificate: true },
|
||||
"/x",
|
||||
).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(RemoteError);
|
||||
expect((error as InstanceType<typeof RemoteError>).kind).toBe(
|
||||
"client_cert",
|
||||
);
|
||||
});
|
||||
|
||||
describe("on the link", () => {
|
||||
let db: InstanceType<typeof TestSqliteDatabase>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new TestSqliteDatabase("sqlite");
|
||||
state.drizzle = (await db.connect()).drizzle;
|
||||
await db.run(
|
||||
sql`INSERT INTO users (id, username, password_hash) VALUES ('local', 'local', '')`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it("stores the PFX encrypted and keeps it through other updates", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://termix.example",
|
||||
sessionToken: "t",
|
||||
clientCert: { pfx, passphrase: PASSPHRASE, name: "client.p12" },
|
||||
});
|
||||
const raw = await db.query(sql`SELECT scope FROM sync_link`);
|
||||
expect(JSON.stringify(raw)).not.toContain(pfx.slice(0, 40));
|
||||
|
||||
await updateLink({ disabledTypes: ["hosts"] }, "local");
|
||||
const link = await getLink("local");
|
||||
expect(link?.clientCert?.pfx).toBe(pfx);
|
||||
expect(link?.disabledTypes).toEqual(["hosts"]);
|
||||
|
||||
await updateLink({ clientCert: null }, "local");
|
||||
expect((await getLink("local"))?.clientCert).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a PFX with the wrong passphrase", async () => {
|
||||
await expect(
|
||||
createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://termix.example",
|
||||
sessionToken: "t",
|
||||
clientCert: { pfx, passphrase: "nope", name: null },
|
||||
}),
|
||||
).rejects.toThrow(/not valid/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,8 @@ vi.mock("../../../utils/system-crypto.js", () => ({
|
||||
|
||||
const { TestSqliteDatabase } =
|
||||
await import("../../database/repositories/test-support.js");
|
||||
const { createLink, deleteLink, getLink, listLinks, updateLink } =
|
||||
const { createLink, deleteLink, getLink, updateLink } =
|
||||
await import("../../../sync/client/link-store.js");
|
||||
const { setActiveUserId } = await import("../../../sync/accounts.js");
|
||||
|
||||
let db: InstanceType<typeof TestSqliteDatabase>;
|
||||
|
||||
@@ -97,79 +96,3 @@ describe("the desktop's link", () => {
|
||||
expect(await updateLink({ cursor: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("several links", () => {
|
||||
beforeEach(async () => {
|
||||
await db.run(sql`UPDATE users SET is_admin = 1 WHERE id = 'local'`);
|
||||
await db.run(
|
||||
sql`INSERT INTO users (id, username, password_hash) VALUES ('second', 'second', '')`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps one link per local user", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await createLink({
|
||||
userId: "second",
|
||||
serverUrl: "https://b",
|
||||
sessionToken: "b",
|
||||
});
|
||||
expect((await listLinks()).map((link) => link.serverUrl).sort()).toEqual([
|
||||
"https://a",
|
||||
"https://b",
|
||||
]);
|
||||
expect((await getLink("second"))?.serverUrl).toBe("https://b");
|
||||
expect((await getLink("local"))?.serverUrl).toBe("https://a");
|
||||
});
|
||||
|
||||
it("answers for the active account when no user is given", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await createLink({
|
||||
userId: "second",
|
||||
serverUrl: "https://b",
|
||||
sessionToken: "b",
|
||||
});
|
||||
expect((await getLink())?.serverUrl).toBe("https://a");
|
||||
await setActiveUserId("second");
|
||||
expect((await getLink())?.serverUrl).toBe("https://b");
|
||||
});
|
||||
|
||||
it("changes and removes only the link it is asked about", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await createLink({
|
||||
userId: "second",
|
||||
serverUrl: "https://b",
|
||||
sessionToken: "b",
|
||||
});
|
||||
await updateLink({ cursor: 9, status: "offline" }, "second");
|
||||
expect((await getLink("local"))?.cursor).toBe(0);
|
||||
expect((await getLink("second"))?.cursor).toBe(9);
|
||||
await deleteLink("second");
|
||||
expect(await getLink("second")).toBeNull();
|
||||
expect((await getLink("local"))?.serverUrl).toBe("https://a");
|
||||
});
|
||||
|
||||
it("stores connection options without touching the type switches", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await updateLink({ disabledTypes: ["hosts"] }, "local");
|
||||
await updateLink({ options: { clientCertificate: "work" } }, "local");
|
||||
const link = await getLink("local");
|
||||
expect(link?.options).toEqual({ clientCertificate: "work" });
|
||||
expect(link?.disabledTypes).toEqual(["hosts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({}));
|
||||
|
||||
import { registerCoreSyncEntities } from "../../sync/entities.js";
|
||||
import {
|
||||
listEntityTypes,
|
||||
resetSyncRegistry,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
|
||||
afterEach(() => resetSyncRegistry());
|
||||
|
||||
describe("core sync entities", () => {
|
||||
it("each has a label in the Sync panel", () => {
|
||||
registerCoreSyncEntities();
|
||||
const en = JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL("../../../ui/locales/en.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const missing = listEntityTypes().filter((type) => !en.sync.entities[type]);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listChanges: vi.fn(),
|
||||
findHostIdBySyncId: vi.fn(),
|
||||
canAccessHost: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
getLink: vi.fn(),
|
||||
generateJWTToken: vi.fn(),
|
||||
revokeSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware: () => vi.fn(),
|
||||
generateJWTToken: mocks.generateJWTToken,
|
||||
revokeSession: mocks.revokeSession,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
syncLogger: { error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../utils/app-version.js", () => ({
|
||||
getLocalVersion: () => "26.10.3",
|
||||
}));
|
||||
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: 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 }],
|
||||
onEntitiesChanged: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => ({ findById: mocks.findById }),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostIdBySyncId: mocks.findHostIdBySyncId,
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: vi.fn(),
|
||||
createCurrentPluginRepository: vi.fn(),
|
||||
createCurrentRepositoryContext: vi.fn(),
|
||||
createCurrentSessionRepository: vi.fn(),
|
||||
createCurrentSettingsRepository: vi.fn(),
|
||||
createCurrentUserRepository: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../database/routes/branding-settings.js", () => ({
|
||||
resolveBrandingSettings: async () => ({ appName: "Termix" }),
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({ canAccessHost: mocks.canAccessHost }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../sync/entities.js", () => ({
|
||||
registerCoreSyncEntities: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/store.js", () => ({}));
|
||||
vi.mock("../../sync/wire.js", () => ({}));
|
||||
vi.mock("../../sync/records.js", () => ({
|
||||
listChanges: mocks.listChanges,
|
||||
recordKey: (type: string, id: string) => `${type}\u0000${id}`,
|
||||
}));
|
||||
vi.mock("../../sync/server/write-listeners.js", () => ({
|
||||
onLocalWrite: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/accounts.js", () => ({
|
||||
createAccount: vi.fn(),
|
||||
getActiveUserId: vi.fn(),
|
||||
listAccounts: vi.fn(),
|
||||
removeAccount: vi.fn(),
|
||||
setActiveUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/server/feed.js", () => ({
|
||||
withUserLock: (_userId: string, fn: () => unknown) => fn(),
|
||||
reconcileUser: async () => ({ wire: new Map() }),
|
||||
addListener: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/server/push.js", () => ({}));
|
||||
vi.mock("../../sync/client/link-store.js", () => ({
|
||||
getLink: mocks.getLink,
|
||||
listLinks: vi.fn(),
|
||||
createLink: vi.fn(),
|
||||
updateLink: vi.fn(),
|
||||
deleteLink: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/client/http.js", () => ({
|
||||
normalizeServerUrl: vi.fn(),
|
||||
RemoteError: class RemoteError extends Error {},
|
||||
remoteFetch: vi.fn(),
|
||||
remoteJson: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/client/electron-proxy.js", () => ({
|
||||
applyElectronProxyConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/client/plugins.js", () => ({
|
||||
lastRemotePlugins: () => [],
|
||||
mirrorPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
const { createSyncHost } = await import("../../sync/host.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listChanges.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("host.feed.changes", () => {
|
||||
it("answers an empty page with the entity types past the newest change", async () => {
|
||||
const cursor = Number.MAX_SAFE_INTEGER;
|
||||
const page = await createSyncHost().feed.changes("user-1", cursor, 1000);
|
||||
expect(page).toEqual({
|
||||
records: [],
|
||||
hasMore: false,
|
||||
nextCursor: cursor,
|
||||
entityTypes: [
|
||||
{ type: "hosts", owner: "core", order: 10, readOnly: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for one more row than the limit to know if more remain", async () => {
|
||||
await createSyncHost().feed.changes("user-1", 0, 1000);
|
||||
expect(mocks.listChanges).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
0,
|
||||
1001,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out records of types this server does not run", async () => {
|
||||
mocks.listChanges.mockResolvedValue([
|
||||
{ seq: 5, entityType: "gone", syncId: "a", revision: 1, deleted: false },
|
||||
]);
|
||||
const page = await createSyncHost().feed.changes("user-1", 0, 10);
|
||||
expect(page.records).toEqual([]);
|
||||
expect(page.nextCursor).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.hosts.findForSync", () => {
|
||||
beforeEach(() => {
|
||||
mocks.findHostIdBySyncId.mockResolvedValue(41);
|
||||
mocks.canAccessHost.mockImplementation(async (_user, _host, action) => ({
|
||||
hasAccess: action === "connect",
|
||||
}));
|
||||
mocks.findById.mockResolvedValue({ name: "Shared host", ip: "10.0.0.1" });
|
||||
});
|
||||
|
||||
it("resolves the server ID for a connect only recipient", async () => {
|
||||
const found = await createSyncHost().hosts.findForSync(
|
||||
"recipient",
|
||||
"host-sync-id",
|
||||
);
|
||||
expect(mocks.canAccessHost).toHaveBeenCalledWith(
|
||||
"recipient",
|
||||
41,
|
||||
"connect",
|
||||
);
|
||||
expect(found).toEqual({ id: 41, name: "Shared host" });
|
||||
});
|
||||
|
||||
it("does not disclose a host the caller cannot connect to", async () => {
|
||||
mocks.canAccessHost.mockResolvedValue({ hasAccess: false });
|
||||
expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing sync ID", async () => {
|
||||
mocks.findHostIdBySyncId.mockResolvedValue(null);
|
||||
expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull();
|
||||
expect(mocks.canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.sessions", () => {
|
||||
it("issues a long desktop session that shows in the sessions list", async () => {
|
||||
mocks.generateJWTToken.mockResolvedValue("token");
|
||||
const token = await createSyncHost().sessions.issueDesktop("u1", {
|
||||
days: 365,
|
||||
deviceInfo: "Laptop (sync)",
|
||||
});
|
||||
expect(token).toBe("token");
|
||||
expect(mocks.generateJWTToken).toHaveBeenCalledWith("u1", {
|
||||
expiresIn: "365d",
|
||||
deviceType: "desktop",
|
||||
deviceInfo: "Laptop (sync)",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 404 outside the desktop app", () => {
|
||||
const previous = process.env.ELECTRON_EMBEDDED;
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
const next = vi.fn();
|
||||
(
|
||||
createSyncHost().sessions.desktopOnly() as (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
next: () => void,
|
||||
) => void
|
||||
)({}, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
if (previous !== undefined) process.env.ELECTRON_EMBEDDED = previous;
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.links", () => {
|
||||
it("reads the linked server registry", async () => {
|
||||
mocks.getLink.mockResolvedValue({ id: 1, serverUrl: "https://a.example" });
|
||||
expect(await createSyncHost().links.get()).toEqual({
|
||||
id: 1,
|
||||
serverUrl: "https://a.example",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
syncLogger: { warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { clearSyncProviders, hasSyncProvider, provideSync, syncForwarder } =
|
||||
await import("../../sync/provider.js");
|
||||
|
||||
function response() {
|
||||
const res = {
|
||||
headersSent: false,
|
||||
status: vi.fn(() => res),
|
||||
json: vi.fn(() => res),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => clearSyncProviders());
|
||||
|
||||
describe("sync forwarder", () => {
|
||||
it("passes through when nothing provides sync", () => {
|
||||
const next = vi.fn();
|
||||
syncForwarder({} as never, response() as never, next);
|
||||
expect(hasSyncProvider()).toBe(false);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("hands the request to the provider", () => {
|
||||
const handle = vi.fn();
|
||||
provideSync("a", { handle });
|
||||
const next = vi.fn();
|
||||
syncForwarder({} as never, response() as never, next);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves on when the provider calls next", () => {
|
||||
provideSync("a", { handle: (_req, _res, pass) => pass() });
|
||||
const second = vi.fn();
|
||||
provideSync("b", { handle: second });
|
||||
syncForwarder({} as never, response() as never, vi.fn());
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("answers 500 when the provider throws", async () => {
|
||||
provideSync("a", {
|
||||
handle: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
const res = response();
|
||||
syncForwarder({} as never, res as never, vi.fn());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it("stops forwarding after the provider is removed", () => {
|
||||
const remove = provideSync("a", { handle: vi.fn() });
|
||||
remove();
|
||||
const next = vi.fn();
|
||||
syncForwarder({} as never, response() as never, next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { findHostIdBySyncId, canAccessHost } = vi.hoisted(() => ({
|
||||
findHostIdBySyncId: vi.fn(),
|
||||
canAccessHost: 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("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({ findHostIdBySyncId }),
|
||||
createCurrentHostRepository: () => ({
|
||||
findById: async () => ({ name: "Shared host" }),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: { getInstance: () => ({ canAccessHost }) },
|
||||
}));
|
||||
vi.mock("../../../sync/entities.js", () => ({
|
||||
registerCoreSyncEntities: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../sync/records.js", () => ({}));
|
||||
vi.mock("../../../sync/server/feed.js", () => ({}));
|
||||
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/hosts/:syncId",
|
||||
)!.route!;
|
||||
const handler = route.stack[route.stack.length - 1].handle;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findHostIdBySyncId.mockResolvedValue(41);
|
||||
canAccessHost.mockImplementation(async (_user, _host, action) => ({
|
||||
hasAccess: action === "connect",
|
||||
}));
|
||||
});
|
||||
|
||||
async function lookup() {
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
await handler(
|
||||
{ userId: "recipient", params: { syncId: "host-sync-id" } } as never,
|
||||
res as never,
|
||||
vi.fn(),
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
it("resolves the server ID for a connect-only recipient", async () => {
|
||||
const res = await lookup();
|
||||
expect(findHostIdBySyncId).toHaveBeenCalledWith("host-sync-id");
|
||||
expect(canAccessHost).toHaveBeenCalledWith("recipient", 41, "connect");
|
||||
expect(res.json).toHaveBeenCalledWith({ id: 41, name: "Shared host" });
|
||||
});
|
||||
|
||||
it("does not disclose a host the caller cannot connect to", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: false });
|
||||
const res = await lookup();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Not found" });
|
||||
});
|
||||
|
||||
it("returns 404 for a missing sync ID", async () => {
|
||||
findHostIdBySyncId.mockResolvedValue(null);
|
||||
const res = await lookup();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getLink = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../sync/client/link-store.js", () => ({ getLink }));
|
||||
|
||||
const { shownIdentity } = await import("../../sync/shown-identity.js");
|
||||
|
||||
const local = { id: "u1", username: "server-ab12", isAdmin: true };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
getLink.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
});
|
||||
|
||||
describe("who the app shows", () => {
|
||||
it("shows the local user when nothing is linked", async () => {
|
||||
getLink.mockResolvedValue(null);
|
||||
expect(await shownIdentity(local)).toMatchObject({
|
||||
username: "server-ab12",
|
||||
isAdmin: true,
|
||||
linked: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the account on the server once linked, with its own admin flag", async () => {
|
||||
getLink.mockResolvedValue({
|
||||
serverUrl: "https://home.example",
|
||||
serverName: "Home",
|
||||
remoteUsername: "luke",
|
||||
account: { username: "luke", isAdmin: false },
|
||||
status: "idle",
|
||||
});
|
||||
expect(await shownIdentity(local)).toMatchObject({
|
||||
username: "luke",
|
||||
isAdmin: false,
|
||||
});
|
||||
expect(getLink).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
it("shows the sign in name until the account has synced", async () => {
|
||||
getLink.mockResolvedValue({
|
||||
serverUrl: "https://home.example",
|
||||
remoteUsername: "luke",
|
||||
account: null,
|
||||
status: "syncing",
|
||||
});
|
||||
expect((await shownIdentity(local)).username).toBe("luke");
|
||||
});
|
||||
|
||||
it("is the local user on a server", async () => {
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
expect((await shownIdentity(local)).username).toBe("server-ab12");
|
||||
expect(getLink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* A server and linked desktops, each with its own database, syncing through
|
||||
* the real engine and the real /sync/v2 routes. Only the network, auth and
|
||||
* the crypto keys are faked.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, 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(() => ({
|
||||
current: null as null | { drizzle: unknown },
|
||||
server: null as null | { drizzle: unknown },
|
||||
allow: true,
|
||||
sharedHostIds: new Set<number>(),
|
||||
paths: [] as string[],
|
||||
}));
|
||||
|
||||
const SERVER_USER = "srv-user";
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => state.current!.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),
|
||||
decryptRecord: (_table: string, record: unknown) => record,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async () => state.allow,
|
||||
canAccessHost: async (_userId: string, hostId: number) => ({
|
||||
hasAccess: state.sharedHostIds.has(hostId),
|
||||
}),
|
||||
getUserPermissions: async () => [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
resyncHost: async () => {},
|
||||
resyncHostsForCredential: async () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ resyncCredential: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/delete-host.js", async () => {
|
||||
const { hosts } = await import("../../database/db/schema.js");
|
||||
const { eq: equals } = await import("drizzle-orm");
|
||||
return {
|
||||
deleteOwnedHost: async (_userId: string, id: number) => {
|
||||
await (state.current!.drizzle as DatabaseContext["drizzle"])
|
||||
.delete(hosts)
|
||||
.where(equals(hosts.id, id));
|
||||
return { id, name: "" };
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../hosts/delete-credential.js", async () => {
|
||||
const { sshCredentials } = await import("../../database/db/schema.js");
|
||||
const { eq: equals } = await import("drizzle-orm");
|
||||
return {
|
||||
deleteOwnedCredential: async (_userId: string, id: number) => {
|
||||
await (state.current!.drizzle as DatabaseContext["drizzle"])
|
||||
.delete(sshCredentials)
|
||||
.where(equals(sshCredentials.id, id));
|
||||
return { name: null };
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
|
||||
req.userId = SERVER_USER;
|
||||
next();
|
||||
},
|
||||
generateJWTToken: async () => "desktop-session",
|
||||
revokeSession: async () => true,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
|
||||
vi.mock("../../sync/client/plugins.js", () => ({
|
||||
mirrorPlugins: async () => {},
|
||||
lastRemotePlugins: () => [],
|
||||
isServerManaged: () => false,
|
||||
}));
|
||||
vi.mock("../../sync/client/http.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../sync/client/http.js")>();
|
||||
return {
|
||||
...actual,
|
||||
// The event stream is not under test.
|
||||
remoteFetch: () => new Promise(() => {}),
|
||||
remoteJson: async (
|
||||
_target: unknown,
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown } = {},
|
||||
) => {
|
||||
state.paths.push(path);
|
||||
const previous = state.current;
|
||||
state.current = state.server;
|
||||
try {
|
||||
const agent = request(app);
|
||||
const method = (init.method ?? "GET").toLowerCase() as "get" | "post";
|
||||
const response = await agent[method](path).send(
|
||||
init.body as object | undefined,
|
||||
);
|
||||
if (response.status >= 400) {
|
||||
throw new actual.RemoteError(
|
||||
"server",
|
||||
response.body?.error ?? "error",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return response.body;
|
||||
} finally {
|
||||
state.current = previous;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { TestSqliteDatabase } =
|
||||
await import("../database/repositories/test-support.js");
|
||||
const schema = await import("../../database/db/schema.js");
|
||||
const { default: syncRouter } = await import("../../sync/server/routes.js");
|
||||
const { SyncEngine } = await import("../../sync/client/engine.js");
|
||||
const { createLink, getLink, updateLink } =
|
||||
await import("../../sync/client/link-store.js");
|
||||
const { markChanged, reconcileUser, resetFeed } =
|
||||
await import("../../sync/server/feed.js");
|
||||
const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } =
|
||||
await import("../../sync/records.js");
|
||||
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" }));
|
||||
app.use("/sync", syncRouter);
|
||||
|
||||
type Db = InstanceType<typeof TestSqliteDatabase>;
|
||||
interface Side {
|
||||
db: Db;
|
||||
ctx: DatabaseContext;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
let server: Side;
|
||||
let desktops: Side[] = [];
|
||||
|
||||
async function side(userId: string): Promise<Side> {
|
||||
const db = new TestSqliteDatabase("sqlite");
|
||||
const ctx = await db.connect();
|
||||
await db.run(
|
||||
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${userId}, ${userId}, '', 1)`,
|
||||
);
|
||||
return { db, ctx, userId };
|
||||
}
|
||||
|
||||
async function on<T>(where: Side, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = state.current;
|
||||
state.current = where.ctx;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
state.current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function db(where: Side) {
|
||||
return where.ctx.drizzle;
|
||||
}
|
||||
|
||||
async function addHost(
|
||||
where: Side,
|
||||
values: Partial<typeof schema.hosts.$inferInsert> & { name: string },
|
||||
) {
|
||||
await db(where)
|
||||
.insert(schema.hosts)
|
||||
.values({
|
||||
userId: where.userId,
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
syncId: `host-${values.name}`,
|
||||
...values,
|
||||
});
|
||||
// Stands in for the change watcher a write request would trip.
|
||||
if (where === server) markChanged();
|
||||
}
|
||||
|
||||
async function hostsOf(where: Side) {
|
||||
return db(where)
|
||||
.select()
|
||||
.from(schema.hosts)
|
||||
.where(eq(schema.hosts.userId, where.userId));
|
||||
}
|
||||
|
||||
async function linkDesktop(
|
||||
desktop: Side,
|
||||
): Promise<InstanceType<typeof SyncEngine>> {
|
||||
await on(desktop, () =>
|
||||
createLink({
|
||||
userId: desktop.userId,
|
||||
serverUrl: "https://termix.example",
|
||||
sessionToken: "desktop-session",
|
||||
}),
|
||||
);
|
||||
return new SyncEngine();
|
||||
}
|
||||
|
||||
async function pass(desktop: Side, engine: InstanceType<typeof SyncEngine>) {
|
||||
await on(desktop, () => engine.syncNow());
|
||||
const link = await on(desktop, () => getLink());
|
||||
expect(link?.lastError ?? null).toBeNull();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetFeed();
|
||||
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")];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of [server, ...desktops]) await s.db.close();
|
||||
});
|
||||
|
||||
describe("linking a desktop", () => {
|
||||
it("merges both sides, translating references and keeping secrets", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(desk, async () => {
|
||||
await db(desk).insert(schema.sshCredentials).values({
|
||||
userId: desk.userId,
|
||||
name: "deploy key",
|
||||
authType: "password",
|
||||
username: "deploy",
|
||||
syncId: "cred-1",
|
||||
});
|
||||
const [cred] = await db(desk).select().from(schema.sshCredentials);
|
||||
await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
{
|
||||
syncId: "host-desk",
|
||||
name: "desk",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "credential",
|
||||
password: "s3cret",
|
||||
credentialSyncId: cred.syncId,
|
||||
},
|
||||
createResolvers(desk.userId),
|
||||
);
|
||||
});
|
||||
await on(server, () => addHost(server, { name: "srv" }));
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const serverHosts = await on(server, () => hostsOf(server));
|
||||
expect(serverHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]);
|
||||
const pushed = serverHosts.find((h) => h.name === "desk")!;
|
||||
const [serverCred] = await db(server).select().from(schema.sshCredentials);
|
||||
expect(serverCred.syncId).toBe("cred-1");
|
||||
expect(pushed.credentialId).toBe(serverCred.id);
|
||||
// Stored encrypted, never as plaintext.
|
||||
expect(pushed.password).not.toBe("s3cret");
|
||||
expect(pushed.password).toContain('"iv"');
|
||||
|
||||
const deskHosts = await on(desk, () => hostsOf(desk));
|
||||
expect(deskHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]);
|
||||
});
|
||||
|
||||
it("adopts the server's version of rows both sides already have", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(desk, () => addHost(desk, { name: "same", ip: "old" }));
|
||||
await on(server, () => addHost(server, { name: "same", ip: "new" }));
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const [host] = await on(desk, () => hostsOf(desk));
|
||||
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", () => {
|
||||
it("sends edits and deletes both ways", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "a" }));
|
||||
await on(server, () => addHost(server, { name: "b" }));
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, async () => {
|
||||
await db(desk)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "10.9.9.9" })
|
||||
.where(eq(schema.hosts.syncId, "host-a"));
|
||||
});
|
||||
await on(server, async () => {
|
||||
await db(server)
|
||||
.delete(schema.hosts)
|
||||
.where(eq(schema.hosts.syncId, "host-b"));
|
||||
markChanged();
|
||||
});
|
||||
await pass(desk, engine);
|
||||
|
||||
const serverHosts = await on(server, () => hostsOf(server));
|
||||
expect(serverHosts.map((h) => [h.name, h.ip])).toEqual([["a", "10.9.9.9"]]);
|
||||
const deskHosts = await on(desk, () => hostsOf(desk));
|
||||
expect(deskHosts.map((h) => h.name)).toEqual(["a"]);
|
||||
|
||||
await on(desk, async () => {
|
||||
await db(desk)
|
||||
.delete(schema.hosts)
|
||||
.where(eq(schema.hosts.syncId, "host-a"));
|
||||
});
|
||||
await pass(desk, engine);
|
||||
expect(await on(server, () => hostsOf(server))).toEqual([]);
|
||||
});
|
||||
|
||||
it("never sends a host kept on this device only", async () => {
|
||||
const [desk] = desktops;
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
await on(desk, () => addHost(desk, { name: "mine", localOnly: true }));
|
||||
await pass(desk, engine);
|
||||
|
||||
expect(await on(server, () => hostsOf(server))).toEqual([]);
|
||||
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
|
||||
"mine",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a local edit that lost to a newer server edit, and can restore it", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "a" }));
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, () =>
|
||||
db(desk)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "desk-edit" })
|
||||
.where(eq(schema.hosts.syncId, "host-a")),
|
||||
);
|
||||
await on(server, async () => {
|
||||
await db(server)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "server-edit" })
|
||||
.where(eq(schema.hosts.syncId, "host-a"));
|
||||
markChanged();
|
||||
});
|
||||
await pass(desk, engine);
|
||||
|
||||
const [local] = await on(desk, () => hostsOf(desk));
|
||||
expect(local.ip).toBe("server-edit");
|
||||
const conflicts = await on(desk, () => listConflicts(desk.userId));
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(JSON.parse(conflicts[0].localRow).ip).toBe("desk-edit");
|
||||
|
||||
// Keep mine: put it back, and the next pass sends it.
|
||||
await on(desk, async () => {
|
||||
const conflict = (await getConflict(desk.userId, conflicts[0].id))!;
|
||||
await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
{ ...JSON.parse(conflict.localRow), syncId: conflict.syncId },
|
||||
createResolvers(desk.userId),
|
||||
);
|
||||
await deleteConflict(desk.userId, conflict.id);
|
||||
});
|
||||
await pass(desk, engine);
|
||||
const [serverHost] = await on(server, () => hostsOf(server));
|
||||
expect(serverHost.ip).toBe("desk-edit");
|
||||
});
|
||||
|
||||
it("does not let one refused change hold up the rest", async () => {
|
||||
const [desk] = desktops;
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, async () => {
|
||||
await addHost(desk, { name: "one" });
|
||||
await db(desk).insert(schema.sshCredentials).values({
|
||||
userId: desk.userId,
|
||||
name: "cred",
|
||||
authType: "password",
|
||||
syncId: "cred-x",
|
||||
});
|
||||
});
|
||||
// The server refuses host changes only.
|
||||
const hostPermissions = getEntity("hosts")!.permissions;
|
||||
const credentialPermissions = getEntity("sshCredentials")!.permissions;
|
||||
getEntity("sshCredentials")!.permissions = undefined;
|
||||
state.allow = false;
|
||||
try {
|
||||
await pass(desk, engine);
|
||||
} finally {
|
||||
getEntity("hosts")!.permissions = hostPermissions;
|
||||
getEntity("sshCredentials")!.permissions = credentialPermissions;
|
||||
}
|
||||
|
||||
expect(await on(server, () => hostsOf(server))).toEqual([]);
|
||||
const creds = await db(server).select().from(schema.sshCredentials);
|
||||
expect(creds.map((c) => c.name)).toEqual(["cred"]);
|
||||
const [record] = await on(desk, () =>
|
||||
db(desk)
|
||||
.select()
|
||||
.from(schema.syncRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.syncRecords.entityType, "hosts"),
|
||||
eq(schema.syncRecords.syncId, "host-one"),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(record.error).toBe("permission");
|
||||
});
|
||||
|
||||
it("leaves a switched-off kind of data alone, and catches up when it is back on", async () => {
|
||||
const [desk] = desktops;
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const link = (await on(desk, () => getLink()))!;
|
||||
await on(desk, () =>
|
||||
updateLink({
|
||||
disabledTypes: ["hosts"],
|
||||
knownTypes: link.knownTypes.filter((type) => type !== "hosts"),
|
||||
}),
|
||||
);
|
||||
await on(server, () => addHost(server, { name: "later" }));
|
||||
await on(desk, () => addHost(desk, { name: "private" }));
|
||||
await pass(desk, engine);
|
||||
|
||||
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
|
||||
"private",
|
||||
]);
|
||||
expect(
|
||||
(await on(server, () => hostsOf(server))).map((h) => h.name),
|
||||
).toEqual(["later"]);
|
||||
|
||||
await on(desk, () => updateLink({ disabledTypes: [] }));
|
||||
await pass(desk, engine);
|
||||
await pass(desk, engine);
|
||||
const names = (await on(desk, () => hostsOf(desk)))
|
||||
.map((h) => h.name)
|
||||
.sort();
|
||||
expect(names).toEqual(["later", "private"]);
|
||||
expect(
|
||||
(await on(server, () => hostsOf(server))).map((h) => h.name).sort(),
|
||||
).toEqual(["later", "private"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a server row that cannot be read", () => {
|
||||
async function recordOf(where: Side, syncId: string) {
|
||||
const [record] = await on(where, () =>
|
||||
db(where)
|
||||
.select()
|
||||
.from(schema.syncRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.syncRecords.entityType, "hosts"),
|
||||
eq(schema.syncRecords.syncId, syncId),
|
||||
),
|
||||
),
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
it.each(["upsert", "delete"] as const)(
|
||||
"retries a local %s after the server row becomes readable",
|
||||
async (op) => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "a" }));
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, () =>
|
||||
(op === "delete"
|
||||
? db(desk).delete(schema.hosts)
|
||||
: db(desk).update(schema.hosts).set({ ip: "desk-edit" })
|
||||
).where(eq(schema.hosts.syncId, "host-a")),
|
||||
);
|
||||
await on(server, async () => {
|
||||
await db(server)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "server-edit" })
|
||||
.where(eq(schema.hosts.syncId, "host-a"));
|
||||
await reconcileUser(SERVER_USER, { force: true });
|
||||
// Then its secret stops decrypting, as under a different key.
|
||||
const [host] = await hostsOf(server);
|
||||
await db(server)
|
||||
.update(schema.hosts)
|
||||
.set({
|
||||
password: FieldCrypto.encryptField(
|
||||
"s3cret",
|
||||
Buffer.alloc(32, 9),
|
||||
String(host.id),
|
||||
"password",
|
||||
),
|
||||
})
|
||||
.where(eq(schema.hosts.id, host.id));
|
||||
});
|
||||
|
||||
const warn = vi.spyOn(syncLogger, "warn");
|
||||
try {
|
||||
await pass(desk, engine);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
entityType: "hosts",
|
||||
syncId: "host-a",
|
||||
error: expect.any(String),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
const [local] = await on(desk, () => hostsOf(desk));
|
||||
expect(local?.ip).toBe(op === "upsert" ? "desk-edit" : undefined);
|
||||
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
||||
expect((await recordOf(desk, "host-a")).error).toBe("unavailable");
|
||||
|
||||
// Restore the same server content without another local edit or a new
|
||||
// server revision. The failed operation must still be retried.
|
||||
await on(server, () =>
|
||||
db(server)
|
||||
.update(schema.hosts)
|
||||
.set({ password: null })
|
||||
.where(eq(schema.hosts.syncId, "host-a")),
|
||||
);
|
||||
await on(server, () => reconcileUser(SERVER_USER, { force: true }));
|
||||
await pass(desk, engine);
|
||||
const [after] = await on(desk, () => hostsOf(desk));
|
||||
expect(after?.ip).toBe("server-edit");
|
||||
expect((await recordOf(desk, "host-a")).error).toBeNull();
|
||||
const conflicts = await on(desk, () => listConflicts(desk.userId));
|
||||
expect(conflicts.map((c) => JSON.parse(c.localRow).ip)).toEqual(
|
||||
op === "upsert" ? ["desk-edit"] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the local row when a conflict carries no row", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "a" }));
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, async () => {
|
||||
const link = (await getLink())!;
|
||||
const settle = (
|
||||
engine as unknown as {
|
||||
settle: (...args: unknown[]) => Promise<void>;
|
||||
}
|
||||
).settle.bind(engine);
|
||||
await settle(
|
||||
link,
|
||||
{
|
||||
entityType: "hosts",
|
||||
syncId: "host-a",
|
||||
baseRevision: 1,
|
||||
op: "upsert",
|
||||
row: { syncId: "host-a", name: "a" },
|
||||
},
|
||||
{
|
||||
entityType: "hosts",
|
||||
syncId: "host-a",
|
||||
status: "conflict",
|
||||
revision: 2,
|
||||
deleted: false,
|
||||
row: null,
|
||||
},
|
||||
"hash",
|
||||
);
|
||||
});
|
||||
|
||||
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
|
||||
"a",
|
||||
]);
|
||||
const record = await recordOf(desk, "host-a");
|
||||
expect(record.deleted).toBe(false);
|
||||
expect(record.revision).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hosts shared with the account", () => {
|
||||
it("arrive on the desktop with their text columns as stored", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, async () => {
|
||||
await server.db.run(
|
||||
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES ('owner', 'owner', '', 0)`,
|
||||
);
|
||||
await db(server).insert(schema.hosts).values({
|
||||
userId: "owner",
|
||||
name: "theirs",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "none",
|
||||
forceKeyboardInteractive: "true",
|
||||
syncId: "host-theirs",
|
||||
});
|
||||
const [host] = await db(server).select().from(schema.hosts);
|
||||
await db(server).insert(schema.hostAccess).values({
|
||||
hostId: host.id,
|
||||
userId: SERVER_USER,
|
||||
grantedBy: "owner",
|
||||
permissionLevel: "view",
|
||||
});
|
||||
state.sharedHostIds.add(host.id);
|
||||
markChanged();
|
||||
});
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const [copy] = await on(desk, () => hostsOf(desk));
|
||||
expect(copy?.name).toBe("theirs");
|
||||
expect(copy.forceKeyboardInteractive).toBe("true");
|
||||
expect(JSON.parse(copy.sharedSource!)).toMatchObject({
|
||||
owner: "owner",
|
||||
permissionLevel: "view",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("two desktops on one account", () => {
|
||||
it("converge on edits, creates and deletes made offline on each", async () => {
|
||||
const [a, b] = desktops;
|
||||
await on(server, () => addHost(server, { name: "shared-one" }));
|
||||
await on(server, () => addHost(server, { name: "shared-two" }));
|
||||
const engineA = await linkDesktop(a);
|
||||
const engineB = await linkDesktop(b);
|
||||
await pass(a, engineA);
|
||||
await pass(b, engineB);
|
||||
|
||||
// Offline on both.
|
||||
await on(a, () => addHost(a, { name: "from-a" }));
|
||||
await on(a, () =>
|
||||
db(a)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "10.1.1.1" })
|
||||
.where(eq(schema.hosts.syncId, "host-shared-one")),
|
||||
);
|
||||
await on(b, () =>
|
||||
db(b)
|
||||
.delete(schema.hosts)
|
||||
.where(eq(schema.hosts.syncId, "host-shared-two")),
|
||||
);
|
||||
await on(b, () => addHost(b, { name: "from-b" }));
|
||||
|
||||
await pass(a, engineA);
|
||||
await pass(b, engineB);
|
||||
await pass(a, engineA);
|
||||
|
||||
const view = async (where: Side) =>
|
||||
(await on(where, () => hostsOf(where)))
|
||||
.map((h) => `${h.name}:${h.ip}`)
|
||||
.sort();
|
||||
const expected = [
|
||||
"from-a:10.0.0.1",
|
||||
"from-b:10.0.0.1",
|
||||
"shared-one:10.1.1.1",
|
||||
];
|
||||
expect(await view(server)).toEqual(expected);
|
||||
expect(await view(a)).toEqual(expected);
|
||||
expect(await view(b)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -214,30 +214,6 @@ describe("AuthManager token handling", () => {
|
||||
expect(payload?.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("gives an API key a ticket on a short session that only a socket takes", async () => {
|
||||
mocks.created.length = 0;
|
||||
const ticket = await authManager.issueApiKeySocketTicket(
|
||||
"user-1",
|
||||
"key-12345678",
|
||||
);
|
||||
const payload = jwt.decode(ticket) as Record<string, number | string>;
|
||||
|
||||
expect(payload.userId).toBe("user-1");
|
||||
expect(payload.purpose).toBe("ws");
|
||||
expect(Number(payload.exp) - Number(payload.iat)).toBe(60);
|
||||
|
||||
const [session] = mocks.created;
|
||||
expect(session.userId).toBe("user-1");
|
||||
expect(session.id).toBe(payload.sessionId);
|
||||
expect(session.deviceInfo).toBe("api-key: socket key-1234");
|
||||
const lifetime =
|
||||
new Date(String(session.expiresAt)).getTime() -
|
||||
new Date(String(session.createdAt)).getTime();
|
||||
expect(lifetime).toBe(120_000);
|
||||
|
||||
expect(await authManager.verifyJWTToken(ticket)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a socket ticket whose session has expired", async () => {
|
||||
const ticket = await authManager.issueSocketTicket("user-1", "expired");
|
||||
expect(
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
isPrivateKeyPassphraseError,
|
||||
getFriendlyKeyTypeName,
|
||||
validateKeyPair,
|
||||
isSecurityKeyFile,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
|
||||
const { parsePrivateKey } = sshpk;
|
||||
@@ -225,43 +224,3 @@ describe("PKCS#8 runtime key normalization", () => {
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("security key files", () => {
|
||||
const str = (v: string | Buffer) => {
|
||||
const b = Buffer.from(v);
|
||||
const l = Buffer.alloc(4);
|
||||
l.writeUInt32BE(b.length);
|
||||
return Buffer.concat([l, b]);
|
||||
};
|
||||
const skFile = (type: string) => {
|
||||
const body = Buffer.concat([
|
||||
Buffer.from("openssh-key-v1\u0000"),
|
||||
str("none"),
|
||||
str("none"),
|
||||
str(""),
|
||||
Buffer.from([0, 0, 0, 1]),
|
||||
str(Buffer.concat([str(type), str(Buffer.alloc(32, 1)), str("ssh:")])),
|
||||
str(Buffer.alloc(16)),
|
||||
]).toString("base64");
|
||||
return `-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
${body}
|
||||
-----END OPENSSH PRIVATE KEY-----`;
|
||||
};
|
||||
|
||||
it("detects sk key files", () => {
|
||||
expect(isSecurityKeyFile(skFile("sk-ssh-ed25519@openssh.com"))).toBe(true);
|
||||
expect(
|
||||
isSecurityKeyFile(skFile("sk-ecdsa-sha2-nistp256@openssh.com")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores normal keys", () => {
|
||||
expect(isSecurityKeyFile(ED25519_PRIVATE)).toBe(false);
|
||||
});
|
||||
|
||||
it("explains how to use a security key instead of failing to decode", () => {
|
||||
expect(() =>
|
||||
preparePrivateKeyForSSH2(skFile("sk-ssh-ed25519@openssh.com")),
|
||||
).toThrow("ssh-add");
|
||||
});
|
||||
});
|
||||
@@ -48,8 +48,8 @@ export const runPluginDataMigrations = DatabaseSaveTrigger.batched(
|
||||
*/
|
||||
async function applyHostDefaults(): Promise<void> {
|
||||
try {
|
||||
const { listLinks } = await import("../sync/client/link-store.js");
|
||||
if ((await listLinks().catch(() => [])).length > 0) return;
|
||||
const { getLink } = await import("../sync/client/link-store.js");
|
||||
if (await getLink().catch(() => null)) return;
|
||||
const { recomputeEverything } =
|
||||
await import("../hosts/defaults/recompute.js");
|
||||
recomputeEverything("plugins started");
|
||||
|
||||
@@ -472,24 +472,6 @@ class AuthManager {
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* A one minute socket ticket for an API key. The key has no login session,
|
||||
* so a two minute session a plugin could have minted carries the ticket. It
|
||||
* can open plugin sockets and call nothing else.
|
||||
*/
|
||||
async issueApiKeySocketTicket(
|
||||
userId: string,
|
||||
keyId: string,
|
||||
): Promise<string> {
|
||||
const { token } = await this.issuePluginSession(userId, {
|
||||
pluginId: "api-key",
|
||||
purpose: `socket ${keyId.slice(0, 8)}`,
|
||||
ttlSeconds: 120,
|
||||
});
|
||||
const sessionId = (jwt.decode(token) as JWTPayload).sessionId!;
|
||||
return this.issueSocketTicket(userId, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -245,30 +245,10 @@ function getUnsupportedPrivateKeyError(privateKeyData: string): string {
|
||||
return "Unsupported private key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.";
|
||||
}
|
||||
|
||||
const SECURITY_KEY_ERROR =
|
||||
"This is a security key (FIDO2) key file. It only holds a handle, so the key itself must sign on your device. Load it with ssh-add and use SSH agent authentication in the desktop app.";
|
||||
|
||||
/** An OpenSSH private key file for a FIDO2 security key (sk-ssh-ed25519, sk-ecdsa). */
|
||||
export function isSecurityKeyFile(privateKeyData: string): boolean {
|
||||
const match = privateKeyData.match(
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/,
|
||||
);
|
||||
if (!match) return false;
|
||||
try {
|
||||
const body = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
|
||||
return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test(
|
||||
body.toString("latin1"),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function preparePrivateKeyForSSH2(
|
||||
privateKeyData: string,
|
||||
passphrase?: string,
|
||||
): Buffer {
|
||||
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
|
||||
let cleanKey = normalizePrivateKeyText(privateKeyData);
|
||||
if (
|
||||
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
|
||||
|
||||
+3
-30
@@ -1,10 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { prepareClientCacheVersion } from "@/lib/client-cache-version";
|
||||
import {
|
||||
TitleBar,
|
||||
TITLE_BAR_OFFSET,
|
||||
useTitleBarOverlayColors,
|
||||
} from "@/shell/TitleBar";
|
||||
import { StrictMode, Suspense, lazy, useState, useRef, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./ui/index.css";
|
||||
@@ -19,7 +14,6 @@ import {
|
||||
Auth,
|
||||
getStoredAuth,
|
||||
clearStoredAuth,
|
||||
storeAuth,
|
||||
markDesktopManualLogout,
|
||||
clearDesktopManualLogout,
|
||||
} from "@/auth/Auth";
|
||||
@@ -165,8 +159,6 @@ function FullscreenAppGate() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
// Keeps the window controls the system draws in the colors of the theme.
|
||||
useTitleBarOverlayColors();
|
||||
const stored = getStoredAuth();
|
||||
const [phase, setPhase] = useState<Phase>(
|
||||
stored?.loggedIn ? "verifying" : "idle-auth",
|
||||
@@ -203,12 +195,7 @@ function App() {
|
||||
if (phase !== "verifying") return;
|
||||
appReadyPromise
|
||||
.then(() => getUserInfo())
|
||||
.then(async (info) => {
|
||||
// The stored name can be an earlier account, after a switch or rename.
|
||||
if (info.username) {
|
||||
setAuthUsername(info.username);
|
||||
storeAuth(info.username);
|
||||
}
|
||||
.then(async () => {
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const token = await getCurrentToken();
|
||||
@@ -377,17 +364,10 @@ function App() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
const isTransitioning = phase === "fading-in" || phase === "fading-out";
|
||||
// The desktop app draws a title bar on top of the window, so everything
|
||||
// below it starts lower. The bar sets the offset only while it shows.
|
||||
const titleBarOffset = TITLE_BAR_OFFSET;
|
||||
|
||||
if (phase === "verifying" || phase === "loading-app") {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-background"
|
||||
style={{ paddingTop: titleBarOffset }}
|
||||
>
|
||||
<TitleBar />
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
@@ -398,13 +378,8 @@ function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleBar />
|
||||
|
||||
{isTransitioning && (
|
||||
<div
|
||||
className="fixed inset-0 z-0 flex items-center justify-center bg-background"
|
||||
style={{ paddingTop: titleBarOffset }}
|
||||
>
|
||||
<div className="fixed inset-0 z-0 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -435,7 +410,6 @@ function App() {
|
||||
style={{
|
||||
opacity: appOpacity,
|
||||
pointerEvents: phase === "idle-app" ? "auto" : "none",
|
||||
paddingTop: titleBarOffset,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
@@ -452,7 +426,6 @@ function App() {
|
||||
style={{
|
||||
opacity: authOpacity,
|
||||
pointerEvents: phase === "idle-auth" ? "auto" : "none",
|
||||
paddingTop: titleBarOffset,
|
||||
}}
|
||||
>
|
||||
<Auth onLogin={handleLogin} />
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
export const CORE_ONBOARDING_STEPS = {
|
||||
plugins: 1,
|
||||
"desktop-sync": 1,
|
||||
"desktop-updates": 1,
|
||||
security: 1,
|
||||
} as const;
|
||||
@@ -23,6 +24,7 @@ export type CoreOnboardingStepKey = keyof typeof CORE_ONBOARDING_STEPS;
|
||||
*/
|
||||
export const LEGACY_SEEN: Readonly<Record<string, number>> = Object.freeze({
|
||||
plugins: 1,
|
||||
"desktop-sync": 1,
|
||||
preset: 1,
|
||||
appearance: 1,
|
||||
security: 1,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
|
||||
export type SyncState = "idle" | "syncing" | "offline" | "error" | "signed_out";
|
||||
|
||||
export interface SyncProxyHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
/** A saved header whose value is not sent back. */
|
||||
set?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncBasicAuth {
|
||||
username: string;
|
||||
password: string;
|
||||
set?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncEntityInfo {
|
||||
type: string;
|
||||
owner: string;
|
||||
ownerName: string | null;
|
||||
readOnly: boolean;
|
||||
onServer: boolean;
|
||||
onDevice: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
linked: boolean;
|
||||
serverUrl?: string;
|
||||
serverName?: string | null;
|
||||
serverVersion?: string | null;
|
||||
account?: {
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
};
|
||||
status?: SyncState;
|
||||
lastError?: string | null;
|
||||
linkedAt?: string;
|
||||
lastSyncAt?: string | null;
|
||||
pending?: number;
|
||||
conflicts?: number;
|
||||
errors?: number;
|
||||
firstSyncDone?: boolean;
|
||||
allowInvalidCertificate?: boolean;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
entities: SyncEntityInfo[];
|
||||
}
|
||||
|
||||
export type ProbeProblem =
|
||||
| "invalid_url"
|
||||
| "unreachable"
|
||||
| "tls_untrusted"
|
||||
| "basic_auth_required"
|
||||
| "proxy_login"
|
||||
| "not_termix"
|
||||
| "version_mismatch"
|
||||
| "different_account";
|
||||
|
||||
export interface ProbeResult {
|
||||
ok: boolean;
|
||||
serverUrl?: string;
|
||||
name?: string;
|
||||
version?: string | null;
|
||||
problem?: ProbeProblem;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ServerTarget {
|
||||
serverUrl: string;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncConflictItem {
|
||||
id: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SyncErrorItem {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export async function getSyncStatus(): Promise<SyncStatus> {
|
||||
return (await authApi.get("/sync/link/status")).data;
|
||||
}
|
||||
|
||||
export async function probeServer(target: ServerTarget): Promise<ProbeResult> {
|
||||
return (await authApi.post("/sync/link/probe", target)).data;
|
||||
}
|
||||
|
||||
export async function getLinkPreview(): Promise<Record<string, number>> {
|
||||
return (await authApi.get("/sync/link/preview")).data?.counts ?? {};
|
||||
}
|
||||
|
||||
export async function completeLink(
|
||||
input: ServerTarget & {
|
||||
token: string;
|
||||
mode: "merge" | "replace";
|
||||
serverName?: string;
|
||||
},
|
||||
): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/complete", input)).data;
|
||||
}
|
||||
|
||||
export async function reloginLink(token: string): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/relogin", { token })).data;
|
||||
}
|
||||
|
||||
export async function unlinkServer(keepData: boolean): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/unlink", { keepData })).data;
|
||||
}
|
||||
|
||||
export async function syncNow(): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/sync", {}, { timeout: 300_000 })).data;
|
||||
}
|
||||
|
||||
export async function updateSyncSettings(settings: {
|
||||
disabledTypes?: string[];
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
}): Promise<SyncStatus> {
|
||||
return (await authApi.put("/sync/link/settings", settings)).data;
|
||||
}
|
||||
|
||||
export async function getSyncConflicts(): Promise<SyncConflictItem[]> {
|
||||
return (await authApi.get("/sync/link/conflicts")).data?.conflicts ?? [];
|
||||
}
|
||||
|
||||
export async function settleSyncConflict(
|
||||
id: number,
|
||||
keep: "mine" | "server",
|
||||
): Promise<void> {
|
||||
await authApi.post(`/sync/link/conflicts/${id}`, { keep });
|
||||
}
|
||||
|
||||
export async function getSyncErrors(): Promise<SyncErrorItem[]> {
|
||||
return (await authApi.get("/sync/link/errors")).data?.errors ?? [];
|
||||
}
|
||||
|
||||
export async function retrySyncErrors(): Promise<SyncStatus> {
|
||||
return (
|
||||
await authApi.post("/sync/link/errors/retry", {}, { timeout: 300_000 })
|
||||
).data;
|
||||
}
|
||||
@@ -138,7 +138,7 @@ function hasDesktopManualLogout() {
|
||||
return localStorage.getItem(DESKTOP_MANUAL_LOGOUT_KEY) === "true";
|
||||
}
|
||||
|
||||
export function storeAuth(username: string) {
|
||||
function storeAuth(username: string) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ loggedIn: true, username }),
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
|
||||
interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: (token: string | null) => void | Promise<void>;
|
||||
onChangeServer: () => void;
|
||||
// "local" (default): the app's own login, the token goes to localStorage
|
||||
// like every other client. "link": signing in to a server to link this
|
||||
// desktop to it; the token is only handed to onAuthSuccess, which trades
|
||||
// it for the desktop's own session.
|
||||
targetPurpose?: "local" | "link";
|
||||
}
|
||||
|
||||
const AUTH_MESSAGE_SOURCES = new Set([
|
||||
"auth_component",
|
||||
"second_factor_auth_component",
|
||||
"method_auth_component",
|
||||
"oidc_callback",
|
||||
]);
|
||||
|
||||
export function ElectronLoginForm({
|
||||
serverUrl,
|
||||
onAuthSuccess,
|
||||
onChangeServer,
|
||||
targetPurpose = "local",
|
||||
}: ElectronLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const isAuthenticatingRef = useRef(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const hasAuthenticatedRef = useRef(false);
|
||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const onAuthSuccessRef = useRef(onAuthSuccess);
|
||||
useEffect(() => {
|
||||
onAuthSuccessRef.current = onAuthSuccess;
|
||||
}, [onAuthSuccess]);
|
||||
|
||||
const handleAuthSuccess = useCallback(
|
||||
async (token: string | null) => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
hasAuthenticatedRef.current = true;
|
||||
isAuthenticatingRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
if (targetPurpose === "link") {
|
||||
if (!token) {
|
||||
throw new Error(t("errors.authTokenMissing"));
|
||||
}
|
||||
} else if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
await onAuthSuccessRef.current(token);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("errors.authTokenSaveFailed"),
|
||||
);
|
||||
isAuthenticatingRef.current = false;
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t, targetPurpose],
|
||||
);
|
||||
|
||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||
// Only the server's own origin is trusted: the iframe can navigate away
|
||||
// (identity provider, redirects), and those pages must not hand us a token,
|
||||
// start a system browser login, or receive the replies.
|
||||
useEffect(() => {
|
||||
let serverOrigin: string;
|
||||
try {
|
||||
serverOrigin = new URL(serverUrl).origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
try {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.origin !== serverOrigin) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const {
|
||||
type,
|
||||
platform,
|
||||
source,
|
||||
token,
|
||||
authUrl,
|
||||
callbackPort,
|
||||
providerId,
|
||||
} = event.data;
|
||||
|
||||
if (
|
||||
type === "AUTH_SUCCESS" &&
|
||||
platform === "desktop" &&
|
||||
AUTH_MESSAGE_SOURCES.has(source)
|
||||
) {
|
||||
await handleAuthSuccess(token ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
// OIDC login requested from inside the iframe, open the system browser
|
||||
// so captcha stages (e.g. Cloudflare Turnstile) render correctly.
|
||||
if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) {
|
||||
const sendResultToIframe = (result: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
secondFactor?: boolean;
|
||||
tempToken?: string;
|
||||
factors?: string;
|
||||
}) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
source: "oidc_system_browser_auth",
|
||||
providerId,
|
||||
...result,
|
||||
},
|
||||
serverOrigin,
|
||||
);
|
||||
};
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
externalBrowserLogin?: (
|
||||
url: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
secondFactor?: boolean;
|
||||
tempToken?: string;
|
||||
factors?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (!electronAPI?.externalBrowserLogin) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: t("errors.failedExternalLogin"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await electronAPI.externalBrowserLogin(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
if (result.success && result.token) {
|
||||
await handleAuthSuccess(result.token);
|
||||
return;
|
||||
}
|
||||
// The login stopped for a second factor; the iframe asks for it.
|
||||
if (result.secondFactor) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
secondFactor: true,
|
||||
tempToken: result.tempToken,
|
||||
factors: result.factors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: result.error || t("errors.failedExternalLogin"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const providerId =
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
typeof event.data.providerId === "number"
|
||||
? event.data.providerId
|
||||
: undefined;
|
||||
const error = getErrorMessage(err, t("errors.failedExternalLogin"));
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
source: "oidc_system_browser_auth",
|
||||
providerId,
|
||||
success: false,
|
||||
error,
|
||||
},
|
||||
serverOrigin,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleAuthSuccess, serverUrl, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoading(false);
|
||||
hasLoadedOnce.current = true;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (iframe.contentWindow) {
|
||||
setCurrentUrl(iframe.contentWindow.location.href);
|
||||
}
|
||||
} catch {
|
||||
setCurrentUrl(serverUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setLoading(false);
|
||||
if (hasLoadedOnce.current) {
|
||||
setError(t("errors.failedToLoadServer"));
|
||||
}
|
||||
};
|
||||
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
iframe.addEventListener("error", handleError);
|
||||
return () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
iframe.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = serverUrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-background flex flex-col">
|
||||
{isAuthenticating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticating && (
|
||||
<div className="flex items-center justify-between p-4 pr-12 bg-background border-b border-border">
|
||||
<button
|
||||
onClick={onChangeServer}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("sync.wizard.back")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isAuthenticating && (
|
||||
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !isAuthenticating && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-background z-40"
|
||||
style={{ marginTop: "60px" }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">
|
||||
{t("auth.loadingServer")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ visibility: isAuthenticating ? "hidden" : "visible" }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={serverUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={t("auth.serverAuthentication")}
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads"
|
||||
allow="clipboard-read; clipboard-write; publickey-credentials-get; publickey-credentials-create"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { getSyncStatusSnapshot, subscribeSyncStatus } from "@/lib/sync-status";
|
||||
|
||||
/** The desktop's sync status, kept current while the component is mounted. */
|
||||
export function useSyncStatus() {
|
||||
return useSyncExternalStore(
|
||||
subscribeSyncStatus,
|
||||
getSyncStatusSnapshot,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
/** What needs the user's attention in Sync, for the rail badge. */
|
||||
export function useSyncAttentionCount(): number | null {
|
||||
const status = useSyncStatus();
|
||||
if (!status?.linked) return null;
|
||||
const count =
|
||||
(status.conflicts ?? 0) +
|
||||
(status.errors ?? 0) +
|
||||
(status.status === "signed_out" ? 1 : 0);
|
||||
return count || null;
|
||||
}
|
||||
@@ -555,28 +555,3 @@ html.fs-xl {
|
||||
background-color: color-mix(in oklab, var(--accent-brand) 35%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* The title bar the desktop app draws on top of its window. The window has
|
||||
* no native frame, so this strip is what moves the window around: the whole
|
||||
* bar drags, everything inside it that takes clicks does not.
|
||||
*/
|
||||
.termix-titlebar {
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.termix-titlebar button,
|
||||
.termix-titlebar a,
|
||||
.termix-titlebar input,
|
||||
.termix-titlebar [role="button"] {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/*
|
||||
* Layouts size themselves to the viewport with h-dvh. Under the app-drawn
|
||||
* title bar the usable height is shorter, so trim it while the bar shows.
|
||||
*/
|
||||
.h-dvh {
|
||||
height: calc(100dvh - var(--termix-titlebar-height, 0px));
|
||||
}
|
||||
@@ -16,20 +16,14 @@ export function isInMobileWebView(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// The desktop app's own user agent. The link window frames the server
|
||||
// cross-origin, and Electron does not carry webContents.setUserAgent into
|
||||
// such a frame, so there navigator.userAgent is Electron's default, which
|
||||
// still names the app: "... termix/26.10.2 Chrome/... Electron/43.7.7 ...".
|
||||
const DESKTOP_USER_AGENT = /Termix-Desktop\/|\btermix\/[\w.-]+ .*\bElectron\//i;
|
||||
|
||||
// Being framed alone is not enough: any site could frame this page and
|
||||
// receive the token posted to window.parent. The desktop app's user agent,
|
||||
// in either form above, is something a web page cannot fake in someone
|
||||
// else's browser.
|
||||
// receive the token posted to window.parent. The desktop app tags every
|
||||
// request and frame with its own user agent, which a web page cannot fake
|
||||
// in someone else's browser.
|
||||
export function isInElectronWebView(): boolean {
|
||||
if (isInMobileWebView()) return false;
|
||||
if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true;
|
||||
if (!DESKTOP_USER_AGENT.test(navigator.userAgent)) return false;
|
||||
if (!/Termix-Desktop\//.test(navigator.userAgent)) return false;
|
||||
try {
|
||||
if (window.self !== window.top) return true;
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { authApi } from "@/main-axios";
|
||||
import { SYNC_CHANGED_EVENT } from "@/lib/sync-events";
|
||||
@@ -49,22 +48,3 @@ export function notifySyncChanged(): void {
|
||||
cached = null;
|
||||
window.dispatchEvent(new CustomEvent(SYNC_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
/** Whether this desktop is linked to a server, kept current as it changes. */
|
||||
export function useDesktopLinked(): boolean {
|
||||
const [linked, setLinked] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const read = () =>
|
||||
void getLinkedSession().then((session) => {
|
||||
if (live) setLinked(!!session);
|
||||
});
|
||||
read();
|
||||
window.addEventListener(SYNC_CHANGED_EVENT, read);
|
||||
return () => {
|
||||
live = false;
|
||||
window.removeEventListener(SYNC_CHANGED_EVENT, read);
|
||||
};
|
||||
}, []);
|
||||
return linked;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getSyncStatus, type SyncStatus } from "@/api/sync-api";
|
||||
import { notifySyncChanged } from "@/lib/linked-server";
|
||||
|
||||
/**
|
||||
* The desktop's sync status, polled from the embedded backend while anything
|
||||
* shows it. When a pass finishes, the views that show synced data are told
|
||||
* to reload.
|
||||
*/
|
||||
|
||||
const IDLE_MS = 15_000;
|
||||
const BUSY_MS = 2_000;
|
||||
|
||||
let snapshot: SyncStatus | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastSyncAt: string | null | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function announce(): void {
|
||||
notifySyncChanged();
|
||||
window.dispatchEvent(new CustomEvent("hosts:refresh"));
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:plugins-changed"));
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
timer = null;
|
||||
try {
|
||||
const next = await getSyncStatus();
|
||||
const syncedAt = next.lastSyncAt ?? null;
|
||||
if (lastSyncAt !== undefined && syncedAt !== lastSyncAt) announce();
|
||||
if (snapshot && snapshot.linked !== next.linked) notifySyncChanged();
|
||||
lastSyncAt = syncedAt;
|
||||
snapshot = next;
|
||||
emit();
|
||||
} catch {
|
||||
// The backend may still be starting; the next poll tries again.
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule(delay?: number): void {
|
||||
if (listeners.size === 0 || timer) return;
|
||||
const busy = snapshot?.status === "syncing" || !!snapshot?.pending;
|
||||
timer = setTimeout(() => void poll(), delay ?? (busy ? BUSY_MS : IDLE_MS));
|
||||
}
|
||||
|
||||
export function subscribeSyncStatus(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (isElectron() && listeners.size === 1 && !timer) void poll();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getSyncStatusSnapshot(): SyncStatus | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Takes a status a request already returned, and checks again soon. */
|
||||
export function setSyncStatus(next: SyncStatus): void {
|
||||
snapshot = next;
|
||||
if (lastSyncAt !== undefined && (next.lastSyncAt ?? null) !== lastSyncAt) {
|
||||
announce();
|
||||
}
|
||||
lastSyncAt = next.lastSyncAt ?? null;
|
||||
emit();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
schedule(BUSY_MS);
|
||||
}
|
||||
|
||||
export function refreshSyncStatus(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
void poll();
|
||||
}
|
||||
+89
-13
@@ -215,6 +215,13 @@
|
||||
"pluginsLoadFailed": "The plugin list could not be loaded. You can choose plugins later from the Plugins tab.",
|
||||
"securityTitle": "Secure your account",
|
||||
"securityIntro": "Add a passkey or an authenticator app to your account. You can also do this later in Settings, under Security.",
|
||||
"desktopTitle": "How do you want to use Termix?",
|
||||
"desktopBody": "You can change this any time from Sync in the sidebar.",
|
||||
"desktopLocalTitle": "On this device only",
|
||||
"desktopLocalBody": "Everything is stored here. Nothing to set up.",
|
||||
"desktopLinkTitle": "With my Termix server",
|
||||
"desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.",
|
||||
"desktopLinked": "Linked to {{server}}",
|
||||
"pluginsDocsLink": "Read about each one"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +242,6 @@
|
||||
"ready": "Termix {{version}} is ready to install",
|
||||
"readyHint": "It installs the next time Termix restarts."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Close panel",
|
||||
"moveSide": "Move panel",
|
||||
"resize": "Resize panel"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"terminal": "Terminal",
|
||||
@@ -250,6 +252,7 @@
|
||||
"hosts": "Hosts",
|
||||
"credentials": "Credentials",
|
||||
"connections": "Connections",
|
||||
"sync": "Sync",
|
||||
"reconnectDisconnectedTerminals": "Reconnect all disconnected terminals",
|
||||
"reconnectingTerminals_one": "Reconnecting {{count}} terminal",
|
||||
"reconnectingTerminals_other": "Reconnecting {{count}} terminals",
|
||||
@@ -1148,14 +1151,6 @@
|
||||
"title": "Set sign in rules",
|
||||
"consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Choose how hosts are reached",
|
||||
"consequence": "It can carry the SSH connection to a host over its own route, so it sees everything sent to and from those hosts, passwords included."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Run sync",
|
||||
"consequence": "It can answer all sync requests, so it sees the data that devices and servers sync."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Manage certificates",
|
||||
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
|
||||
@@ -1624,6 +1619,87 @@
|
||||
"noData": "No data",
|
||||
"filter": "Filter..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "{{count}} minute ago",
|
||||
"minutesAgo_other": "{{count}} minutes ago",
|
||||
"hoursAgo_one": "{{count}} hour ago",
|
||||
"hoursAgo_other": "{{count}} hours ago",
|
||||
"pending_one": "{{count}} change waiting to sync",
|
||||
"pending_other": "{{count}} changes waiting to sync",
|
||||
"proxy": {
|
||||
"headersTitle": "Extra headers",
|
||||
"headersHint": "Sent with every request to the server, for example a Cloudflare Access service token.",
|
||||
"headerName": "Header name",
|
||||
"headerValue": "Value",
|
||||
"savedValue": "Saved, type to change",
|
||||
"addHeader": "Add header",
|
||||
"removeHeader": "Remove header",
|
||||
"basicAuthTitle": "Username and password",
|
||||
"basicAuthHint": "For a proxy that asks for a username and password before Termix.",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"allowInvalidCertificate": "Trust a self-signed certificate",
|
||||
"allowInvalidCertificateHint": "Only for your own server, when its certificate is not from a known authority."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "That does not look like a web address.",
|
||||
"unreachable": "Could not reach the server. Check the address and that the server is running.",
|
||||
"unreachableWithReason": "Could not reach the server: {{reason}}",
|
||||
"tlsUntrusted": "The server certificate is not trusted. If this is your own server with a self-signed certificate, turn on the option below.",
|
||||
"basicAuthRequired": "Something in front of the server asks for a username and password. Enter them below.",
|
||||
"proxyLogin": "A login page or proxy answered instead of Termix. Add the headers it needs below, or check the address.",
|
||||
"notTermix": "There is no Termix server at that address. If it runs under a path, include it, like https://example.com/termix.",
|
||||
"versionMismatch": "That server runs an older Termix. Update it to 2.9 or newer to link.",
|
||||
"differentAccount": "You signed in to a different account. Sign in to the account this device is linked to, or unlink first."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Link to a Termix server",
|
||||
"reloginTitle": "Sign in again",
|
||||
"savedServers": "Recent servers",
|
||||
"removeServer": "Remove from the list",
|
||||
"serverDescription": "Enter the address you use to open Termix in your browser.",
|
||||
"signinDescription": "Sign in to {{url}}",
|
||||
"chooseDescription": "This device already has data. What should happen to it?",
|
||||
"finishingDescription": "Linking this device.",
|
||||
"serverUrl": "Server address",
|
||||
"serverUrlHint": "For example https://termix.example.com",
|
||||
"showProxySettings": "Reverse proxy settings",
|
||||
"hideProxySettings": "Hide reverse proxy settings",
|
||||
"continue": "Continue",
|
||||
"back": "Back",
|
||||
"mergeTitle": "Add it to my account",
|
||||
"mergeBody_one": "The item on this device is added to your account. Items your account already has stay as they are on the server.",
|
||||
"mergeBody_other": "The {{count}} items on this device are added to your account. Items your account already has stay as they are on the server.",
|
||||
"replaceTitle": "Replace it with my account",
|
||||
"replaceBody_one": "The item on this device is removed, and this device takes everything from your account. Hosts kept on this device only stay.",
|
||||
"replaceBody_other": "The {{count}} items on this device are removed, and this device takes everything from your account. Hosts kept on this device only stay.",
|
||||
"link": "Link",
|
||||
"linking": "Linking and running the first sync...",
|
||||
"linked": "Linked. Your data is syncing now.",
|
||||
"signedInAgain": "Signed in. Sync is running again."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Hosts",
|
||||
"sshCredentials": "Credentials",
|
||||
"sshFolders": "Folders",
|
||||
"userPreferences": "Preferences",
|
||||
"pluginUserSettings": "Plugin settings",
|
||||
"sharedHosts": "Hosts shared with you",
|
||||
"sharedCredentials": "Credentials shared with you",
|
||||
"vaultProfiles": "Vault profiles",
|
||||
"snippetFolders": "Snippet folders",
|
||||
"snippets": "Snippets",
|
||||
"dashboardServiceLinks": "Service links",
|
||||
"homepageItems": "Homepage items",
|
||||
"networkTopology": "Network map",
|
||||
"workspaces": "Workspaces",
|
||||
"hostDefaults": "Host defaults",
|
||||
"hostDefaultsAdmin": "Server host defaults",
|
||||
"aiProviders": "AI providers",
|
||||
"homepageLayouts": "Homepage layouts",
|
||||
"accountProfile": "Account profile"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Folder {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "Sommige plugins kon nie verander word nie. Jy kan hulle later regstel vanaf die Plugins-oortjie. Druk Volgende om voort te gaan.",
|
||||
"pluginsApplyFailed": "Jou keuses kon nie gestoor word nie. Probeer weer, of gaan voort en verander hulle later vanaf die Plugins-oortjie.",
|
||||
"pluginsLoadFailed": "Die pluginlys kon nie gelaai word nie. Jy kan plugins later kies vanaf die Plugins-oortjie.",
|
||||
"presetTitle": "Hoeveel wil jy op die skerm hê?",
|
||||
"presetIntro": "Kies hoeveel op die skerm wys.",
|
||||
"presetChangeLater": "Jy kan later voorinstellings wissel of enige enkele instelling verander in Instellings, onder Koppelvlak.",
|
||||
"appearanceTitle": "Maak dit joune",
|
||||
"appearanceIntro": "Kies 'n tema, 'n aksentkleur, 'n lettertipe en jou taal. Veranderinge wys dadelik, en jy kan hulle later in Instellings verander.",
|
||||
"securityTitle": "Beveilig jou rekening",
|
||||
"securityIntro": "Voeg 'n passkey of 'n autentikator-toepassing by jou rekening. Jy kan dit ook later doen in Instellings, onder Beveiliging.",
|
||||
"desktopTitle": "Hoe wil jy Termix gebruik?",
|
||||
"desktopBody": "Jy kan dit op enige tyd verander vanaf Sinkroniseer in die sybalk.",
|
||||
"desktopLocalTitle": "Slegs op hierdie toestel",
|
||||
"desktopLocalBody": "Alles word hier gestoor. Niks om op te stel nie.",
|
||||
"desktopLinkTitle": "Met my Termix-bediener",
|
||||
"desktopLinkBody": "Teken in op jou bediener om dieselfde gasheer en instellings op elke toestel te gebruik.",
|
||||
"desktopLinked": "Gekoppel aan {{server}}",
|
||||
"pluginsDocsLink": "Lees oor elkeen"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Termix {{version}} is gereed om te installeer",
|
||||
"readyHint": "Dit word die volgende keer wat Termix herbegin geïnstalleer."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Maak paneel toe",
|
||||
"moveSide": "Skuif paneel",
|
||||
"resize": "Verander grootte van paneel"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Tuis",
|
||||
"terminal": "Terminaal",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "Gasheer",
|
||||
"credentials": "Aanmeldingsgegewens",
|
||||
"connections": "Verbindings",
|
||||
"sync": "Sinkroniseer",
|
||||
"reconnectDisconnectedTerminals": "Herstel al die ontkoppelde terminale",
|
||||
"reconnectingTerminals_one": "Herstel tans {{count}} terminaal",
|
||||
"reconnectingTerminals_other": "Herstel tans {{count}} terminale",
|
||||
@@ -271,7 +279,10 @@
|
||||
"retry": "Probeer weer",
|
||||
"refresh": "Verfris",
|
||||
"optional": "Opsioneel",
|
||||
"downloadSample": "Laai Voorbeeld Af",
|
||||
"failedToDeleteHost": "Kon nie {{name}} verwyder nie",
|
||||
"importSkipExisting": "Voer in (slaas bestaande oor)",
|
||||
"importSSHConfig": "Voer in vanaf SSH-konfigurasie",
|
||||
"connectionDetails": "Verbindingsbesonderhede",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -490,7 +501,10 @@
|
||||
"noFolder": "(Geen gids nie)",
|
||||
"deleteSelected": "Vee uit",
|
||||
"exitSelection": "Verlaat seleksie",
|
||||
"importSkip": "Voer in (slaag bestaande oor)",
|
||||
"importOverwrite": "Voer in (skryf oor)",
|
||||
"collapseBtn": "Vou in",
|
||||
"importExportBtn": "Voer in / Voer uit",
|
||||
"hostStatusesRefreshed": "Gasheer-statusse opgedateer",
|
||||
"failedToRefreshHosts": "Kon nie gasheer opdateer nie",
|
||||
"movedHostTo": "Het {{host}} na \"{{folder}}\" geskuif",
|
||||
@@ -545,9 +559,6 @@
|
||||
"deleteFolderConfirm_one": "Vee \"{{name}}\" en sy {{count}} gasheer uit? Dit kan nie ongedaan gemaak word nie.",
|
||||
"deleteFolderConfirm_other": "Vee \"{{name}}\" en sy {{count}} gasheer uit? Dit kan nie ongedaan gemaak word nie.",
|
||||
"failedToMoveHosts": "Kon nie gasheerbedieners skuif nie",
|
||||
"managedBanner": "Hierdie gasheer word bestuur deur {{plugin}}. Wysig dit daar. Jy kan dit steeds vassteek en van volgorde verander.",
|
||||
"managedTooltip": "Bestuur deur {{plugin}}",
|
||||
"addBlocked": "Gasheer word bestuur deur {{plugin}}, so nuwe gasheer kan nie hier bygevoeg word nie.",
|
||||
"failedToReorder": "Kon nie herorden nie",
|
||||
"expandAll": "Vou alle gidse uit",
|
||||
"collapseAll": "Vou alle gidse in",
|
||||
@@ -658,6 +669,35 @@
|
||||
"addCredentialBtn2": "Voeg Geloofsbriew By",
|
||||
"checkingHostStatuses": "Hoststatusse word nagegaan...",
|
||||
"pinnedSection": "Vastegemaak",
|
||||
"hostsExported": "Hosts is suksesvol geëksporteer",
|
||||
"export": {
|
||||
"menuItem": "Eksporteer...",
|
||||
"title": "Eksporteer hosts",
|
||||
"scope": "Reikwydte",
|
||||
"scopeAll": "Alles",
|
||||
"scopeSelected": "Geselekteer",
|
||||
"searchHosts": "Soek hosts...",
|
||||
"include": "Sluit in",
|
||||
"groupConnection": "Verbinding",
|
||||
"groupCredentials": "Aanmeldingsbesonderhede",
|
||||
"groupNotes": "Notas",
|
||||
"groupTags": "Etikette & vasmaak",
|
||||
"groupProxy": "Proxy",
|
||||
"groupJumpHosts": "Oorsprongshosts",
|
||||
"groupFeatureFlags": "Plugin-instellings",
|
||||
"groupAdvanced": "Gevorderde konfigurasie",
|
||||
"preview": "Voorskou",
|
||||
"moreHosts_one": "... {{count}} meer gasheer",
|
||||
"moreHosts_other": "... {{count}} meer gasheer",
|
||||
"summary": "{{selected}} van {{total}} hosts",
|
||||
"credentialsIncluded": "aanmeldingsbesonderhede ingesluit",
|
||||
"credentialsExcluded": "aanmeldingsbesonderhede uitgesluit",
|
||||
"noneSelected": "Geen hosts geselekteer nie",
|
||||
"cancel": "Kanselleer",
|
||||
"confirm": "Eksporteer",
|
||||
"fetchFailed": "Kon nie hosts laai vir eksport nie",
|
||||
"bulkButton": "Eksporteer"
|
||||
},
|
||||
"sampleDownloaded": "Voorbeeldlêer is afgelaai",
|
||||
"failedToDeleteCredential2": "Kon nie aanmeldingsbesonderhede verwyder nie",
|
||||
"noFolderOption": "(Geen gids nie)",
|
||||
@@ -820,7 +860,11 @@
|
||||
"editorUpdate": "Wysig Gasheer",
|
||||
"editorAdd": "Voeg Gasheer by",
|
||||
"speedSearch": "Soek gasheer. Pyltjies om te kies, Enter om te verbind, Esc om te sluit",
|
||||
"speedSearchList": "Gasheerlys. Tik of druk / om te soek."
|
||||
"speedSearchList": "Gasheerlys. Tik of druk / om te soek.",
|
||||
"importNoHosts": "Geen gasheer in die lêer gevind nie",
|
||||
"importTooMany": "Jy kan tot {{max}} gasheer gelyktydig invoer",
|
||||
"importSummary": "Invoer gedoen: {{imported}} ingevoer, {{updated}} opgedateer, {{failed}} misluk",
|
||||
"importHostsFailed": "Kon nie gasheer invoer nie"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Gesplete skerm",
|
||||
@@ -1071,57 +1115,17 @@
|
||||
"colorPickerTooltip": "Maak kleurkiezer oop",
|
||||
"themeSystem": "Stelsel",
|
||||
"themeLight": "Lig",
|
||||
"themeDark": "Donker"
|
||||
"themeDark": "Donker",
|
||||
"themeDracula": "Dracula",
|
||||
"themeCatppuccin": "Catppuccin",
|
||||
"themeNord": "Nord",
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "Hoe die hele app gelê is. Temas en lêe kom van plugins.",
|
||||
"defaultsLabel": "Verstek vir almal",
|
||||
"defaultsHint": "Wat mense sien totdat hulle hul eie lê of tema kies.",
|
||||
"followDefault": "Gebruik die verstek",
|
||||
"defaultSaved": "Verstek voorkoms gestoor",
|
||||
"saveFailed": "Kon nie die voorkoms stoor nie",
|
||||
"repair": {
|
||||
"title": "Hierdie lê kon nie gewys word nie",
|
||||
"safeTitle": "Veilige modus",
|
||||
"layout": "Lê",
|
||||
"plugin": "Plugin",
|
||||
"reason": "Rede",
|
||||
"error": "Fout",
|
||||
"noError": "Geen foutboodskap nie.",
|
||||
"copy": "Kopieer",
|
||||
"copied": "Gekopieer",
|
||||
"copyFailed": "Kon nie die besonderhede kopieer nie",
|
||||
"retry": "Probeer weer",
|
||||
"reset": "Stel terug na die verstek voorkoms",
|
||||
"resetEveryone": "Stel die verstek vir almal terug",
|
||||
"update": "Dateer {{name}} op na {{version}}",
|
||||
"disable": "Skakel {{name}} af",
|
||||
"install": "Vind lêe en temas",
|
||||
"installTitle": "Lêe en temas",
|
||||
"installButton": "Installeer",
|
||||
"nothingToInstall": "Daar is tans geen ander lêe of temas beskikbaar nie.",
|
||||
"registryUnreachable": "Die plugin-register kon nie bereik word nie.",
|
||||
"askAdmin": "Vra 'n admin om hierdie lê op te dateer of te herstel. Jy kan hieronder na 'n ander lê of tema skakel.",
|
||||
"switchLayout": "Wissel lê",
|
||||
"switchTheme": "Wissel tema",
|
||||
"saveFailed": "Kon nie jou keuse stoor nie",
|
||||
"reasons": {
|
||||
"safe-url": "Die adres het om veilige modus gevra. Kies 'n lê hieronder, of verwyder ?safe-layout=1 uit die adres.",
|
||||
"safe-env": "Die bediener is gestart met TERMIX_SAFE_LAYOUT, so almal sien hierdie skerm totdat dit verwyder word.",
|
||||
"missing": "Geen geïnstalleerde plugin verskaf hierdie lê nie.",
|
||||
"disabled": "Die plugin wat hierdie lê verskaf, is afgeskakel.",
|
||||
"blocked": "Die plugin wat hierdie lê verskaf, wag op iets wat dit nodig het.",
|
||||
"incompatible": "Die plugin wat hierdie lê verskaf, werk nie met hierdie weergawe van Termix nie.",
|
||||
"failed": "Die lê het misluk om te begin.",
|
||||
"crashed": "Die lê het twee keer gekraak terwyl die app geteken is."
|
||||
},
|
||||
"noLayout": "Geen lê is gestel nie",
|
||||
"restore": "Herstel die ingeboude {{name}}",
|
||||
"restoreFailed": "Kon nie die ingeboude kopie herstel nie"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1144,18 +1148,6 @@
|
||||
"title": "Hanteer aanmelding",
|
||||
"consequence": "Dit kan 'n aanmeldmetode, 'n tweede faktor of 'n SSH-aanmeldtipe byvoeg."
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "Stel aanmeldreëls",
|
||||
"consequence": "Dit kan mense uitteken wanneer hulle inaktief is, sessieduur beperk, \"onthou my\" afskakel en 'n tweede faktor vereis."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Kies hoe gasheer bereik word",
|
||||
"consequence": "Dit kan die SSH-verbinding na 'n gasheer oor sy eie roete dra, so dit sien alles wat na en van daardie gasheer gestuur word, insluitende wagwoorde."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Voer sinkronisering uit",
|
||||
"consequence": "Dit kan al die sinkronisversoek beantwoord, so dit sien die data wat toestelle en bedieners sinkroniseer."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Bestuur sertifikate",
|
||||
"consequence": "Dit kan die sertifikaat vervang wat Termix oor HTTPS verskaf en domeinkontroles van sertifikaatautoriteite beantwoord."
|
||||
@@ -1164,10 +1156,6 @@
|
||||
"title": "Toegang tot reeks- en USB-toestelle",
|
||||
"consequence": "Dit kan 'n fisiese reekspoort op die masjien wat Termix uitvoer, oopmaak."
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "Voer gasheer uit met hul geheime",
|
||||
"consequence": "Kan jou gasheer in sy geheel lees, insluitende gestoorde wagwoorde en sleutels, en dit uitdeel. Elke uitvoer word in die ouditering gelog."
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "Skep en verander bedieners",
|
||||
"consequence": "Dit kan bedieners byvoeg en die een wat jy reeds het, redigeer."
|
||||
@@ -1212,30 +1200,10 @@
|
||||
"title": "Lees die ouditlogboek",
|
||||
"consequence": "Dit kan sien wie wat gedoen het en wanneer."
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "Kyk of poorte antwoord",
|
||||
"consequence": "Kan 'n plat verbinding na enige adres vanaf die bediener oopmaak, om te sien of dit bereikbaar is."
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "Verskaf gasheerstatus",
|
||||
"consequence": "Word die bron van die aanlyn- en aflyn-punte vir elke gasheer."
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "Bewaar onlangse aktiwiteit",
|
||||
"consequence": "Word die bergplek vir die onlangse aktiwiteitslys van elke gebruiker."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "Bestuur gasheer as slegs-lees",
|
||||
"consequence": "Gasheer wat dit bestuur, kan nie deur iemand anders redigeer, verwyder of hergedeel word nie."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Maak burebladvensters oop",
|
||||
"consequence": "Slegs in die Termix-burebladtoepassing."
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "Gebruik hierdie rekenaar",
|
||||
"consequence": "Kan lêers lees en verander, 'n skul uitvoer en plaaslike poorte op hierdie rekenaar oopmaak. Slegs in die Termix-bureaubladprogram."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "Tree op as ander gebruikers",
|
||||
"consequence": "Voer agtergrondwerk uit met 'n ander gebruiker se toegang, soos geskeduleerde take. Elke gebruik word in die ouditlogboek geskryf."
|
||||
@@ -1275,14 +1243,6 @@
|
||||
"ui:surface": {
|
||||
"title": "Voeg sy eie skerm by",
|
||||
"consequence": "Oortjies, panele en instellings wat jy later kan verberg."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "Vervang die hele programlêer",
|
||||
"consequence": "Wanneer gekies, teken sy kode elke skerm, insluitend jou terminale en instellings."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "Voeg kleurtemas by",
|
||||
"consequence": "Slegs kleure en styl. Geen kode word vir 'n tema uitgevoer nie."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1412,7 +1372,6 @@
|
||||
"options": "Kon nie opsies vir {{name}} stoor nie: {{error}}",
|
||||
"deleteData": "Kon nie data vir {{name}} verwyder nie: {{error}}",
|
||||
"managedByServer": "Hierdie werkskerm is aan 'n bediener gekoppel, wat sy plugins bepaal.",
|
||||
"defaultLook": "{{name}} is die standaard voorkoms, so dit kan afgeskakel of vervang word, maar nie onteinstalleer nie.",
|
||||
"hasDependents": "{{name}} word deur {{list}} benodig. Skakel daardie eers af of deïnstalleer hulle.",
|
||||
"missingDependencies": "{{name}} het {{list}} nodig, wat nie geïnstalleer is nie.",
|
||||
"consentChanged": "Wat hierdie plugin vra, het verander sedert jy gekyk het. Kyk weer na die lys.",
|
||||
@@ -1482,6 +1441,7 @@
|
||||
"noServerAccess": "Geen toegang tot jou bedieners nie",
|
||||
"searchInstalled": "Soek geïnstalleerdes",
|
||||
"searchPlugins": "Soek plugins",
|
||||
"allCategories": "Almal",
|
||||
"nothingMatches": "Niets pas by nie.",
|
||||
"noneInstalled": "Nog geen plugins geïnstalleer nie. Blaai om sommige by te voeg.",
|
||||
"nothingHere": "Niets hier nie.",
|
||||
@@ -1571,44 +1531,7 @@
|
||||
"documentation": "Dokumentasie",
|
||||
"envVars": "Omgewingsveranderlikes",
|
||||
"envRequired": "Verpligtend",
|
||||
"envDefault": "Verstek: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "Indeling",
|
||||
"themes": "Tema's",
|
||||
"tabs": "Oortjies",
|
||||
"panels": "Sypaneelpanele",
|
||||
"dashboardCards": "Kontrolepaneelkaarte",
|
||||
"protocols": "Verbindingstipes",
|
||||
"auth": "Aanmeldmetodes",
|
||||
"actions": "Aksies",
|
||||
"hostCapability": "Gasheerinstellings"
|
||||
},
|
||||
"filters": {
|
||||
"type": "Tipe",
|
||||
"allTypes": "Alle tipes",
|
||||
"category": "Kategorie",
|
||||
"allCategories": "Alle kategorieë",
|
||||
"results_one": "{{count}} invoegprogram",
|
||||
"results_other": "{{count}} invoegprogramme",
|
||||
"remove": "Verwyder filter {{name}}",
|
||||
"clear": "Vee filters uit",
|
||||
"button": "Filters",
|
||||
"buttonCount": "Filters ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} van {{total}}",
|
||||
"page": "Bladsy {{page}} van {{pages}}",
|
||||
"previous": "Vorige bladsy",
|
||||
"next": "Volgende bladsy",
|
||||
"perPage": "{{count}} per bladsy",
|
||||
"perPageLabel": "Invoegprogramme per bladsy"
|
||||
},
|
||||
"showUnsupportedHint": "Hierdie word gelys, maar geen vrylasing loop nog op hierdie weergawe nie. Werk Termix op om dit te installeer.",
|
||||
"unsupported": "Benodig 'n nuwer Termix",
|
||||
"unsupportedHint": "Geen vrylasing van hierdie invoegprogram loop op hierdie weergawe van Termix nie. Werk Termix op om dit te installeer.",
|
||||
"needsTermix": "Benodig Termix {{range}}",
|
||||
"showUnsupported": "Wys nie-ondersteunde ({{count}})",
|
||||
"showMore": "Wys {{count}} meer ({{total}} oor)"
|
||||
"envDefault": "Verstek: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1624,6 +1547,87 @@
|
||||
"noData": "Geen data nie",
|
||||
"filter": "Filter..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "{{count}} minuut gelede",
|
||||
"minutesAgo_other": "{{count}} minute gelede",
|
||||
"hoursAgo_one": "{{count}} uur gelede",
|
||||
"hoursAgo_other": "{{count}} uur gelede",
|
||||
"pending_one": "{{count}} verandering wag om te sinkroniseer",
|
||||
"pending_other": "{{count}} veranderinge wag om te sinkroniseer",
|
||||
"proxy": {
|
||||
"headersTitle": "Bykomende koptekste",
|
||||
"headersHint": "Word saam met elke versoek na die bediener gestuur, byvoorbeeld 'n Cloudflare Access-diens token.",
|
||||
"headerName": "Koptekstnaam",
|
||||
"headerValue": "Waarde",
|
||||
"savedValue": "Opgeslaan, tik om te verander",
|
||||
"addHeader": "Voeg koptekst by",
|
||||
"removeHeader": "Verwyder koptekst",
|
||||
"basicAuthTitle": "Gebruikersnaam en wagwoord",
|
||||
"basicAuthHint": "Vir 'n proxy wat om 'n gebruikersnaam en wagwoord vra voordat Termix laai.",
|
||||
"username": "Gebruikersnaam",
|
||||
"password": "Wagwoord",
|
||||
"allowInvalidCertificate": "Vertrou 'n self-ondertekende sertifikaat",
|
||||
"allowInvalidCertificateHint": "Slegs vir jou eie bediener, wanneer sy sertifikaat nie van 'n bekende owerheid kom nie."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "Dit lyk nie soos 'n webadres nie.",
|
||||
"unreachable": "Kon nie die bediener bereik nie. Kontroleer die adres en dat die bediener loop.",
|
||||
"unreachableWithReason": "Kon nie die bediener bereik nie: {{reason}}",
|
||||
"tlsUntrusted": "Die bediener se sertifikaat word nie vertrou nie. As dit jou eie bediener is met 'n self-ondertekende sertifikaat, skakel die opsie hieronder aan.",
|
||||
"basicAuthRequired": "Iets voor die bediener vra vir 'n gebruikersnaam en wagwoord. Voer hulle hieronder in.",
|
||||
"proxyLogin": "'n Aanmeldbladsy of proxy het geantwoord in plaas van Termix. Voeg die koppe wat dit benodig hieronder by, of kontroleer die adres.",
|
||||
"notTermix": "Daar is geen Termix-bedienaar op daardie adres nie. As dit onder 'n pad loop, sluit dit in, soos https://voorbeeld.com/termix.",
|
||||
"versionMismatch": "Daardie bediener loop 'n ouer Termix. Werk dit op na 2.9 of nuwer om te koppel.",
|
||||
"differentAccount": "Jy het aangemeld by 'n ander rekening. Meld aan by die rekening waaraan hierdie toestel gekoppel is, of ontkoppel eers."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Koppel aan 'n Termix-bedienaar",
|
||||
"reloginTitle": "Meld weer aan",
|
||||
"savedServers": "Onlangse bedieners",
|
||||
"removeServer": "Verwyder van die lys",
|
||||
"serverDescription": "Voer die adres in wat jy gebruik om Termix in jou blaaier oop te maak.",
|
||||
"signinDescription": "Meld aan by {{url}}",
|
||||
"chooseDescription": "Hierdie toestel het reeds data. Wat moet daarmee gebeur?",
|
||||
"finishingDescription": "Koppel hierdie toestel.",
|
||||
"serverUrl": "Bedienaaradres",
|
||||
"serverUrlHint": "Byvoorbeeld https://termix.voorbeeld.com",
|
||||
"showProxySettings": "Omgekeerde proxy-instellings",
|
||||
"hideProxySettings": "Versteek omgekeerde proxy-instellings",
|
||||
"continue": "Gaan voort",
|
||||
"back": "Terug",
|
||||
"mergeTitle": "Voeg dit by my rekening",
|
||||
"mergeBody_one": "Die item op hierdie toestel word by jou rekening gevoeg. Items wat jou rekening reeds het, bly soos hulle op die bediener is.",
|
||||
"mergeBody_other": "Die {{count}} items op hierdie toestel word by jou rekening gevoeg. Items wat jou rekening reeds het, bly soos hulle op die bediener is.",
|
||||
"replaceTitle": "Vervang dit met my rekening",
|
||||
"replaceBody_one": "Die item op hierdie toestel word verwyder, en hierdie toestel neem alles van jou rekening. Hosts wat slegs op hierdie toestel gehou word, bly.",
|
||||
"replaceBody_other": "Die {{count}} items op hierdie toestel word verwyder, en hierdie toestel neem alles van jou rekening. Hosts wat slegs op hierdie toestel gehou word, bly.",
|
||||
"link": "Koppel",
|
||||
"linking": "Koppel en voer die eerste sinkronisering uit...",
|
||||
"linked": "Gekoppel. Jou data word nou gesinkroniseer.",
|
||||
"signedInAgain": "Aangemeld. Sinkronisering loop weer."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Bedieners",
|
||||
"sshCredentials": "Inlogbesonderhede",
|
||||
"sshFolders": "Vouers",
|
||||
"userPreferences": "Voorkeure",
|
||||
"pluginUserSettings": "Plugin-instellings",
|
||||
"sharedHosts": "Bedieners wat met jou gedeel is",
|
||||
"sharedCredentials": "Inlogbesonderhede wat met jou gedeel is",
|
||||
"vaultProfiles": "Vault-profile",
|
||||
"snippetFolders": "Snippet-vouers",
|
||||
"snippets": "Snippets",
|
||||
"dashboardServiceLinks": "Diensskakels",
|
||||
"homepageItems": "Tuisblad-items",
|
||||
"networkTopology": "Netwerkkaart",
|
||||
"workspaces": "Werkruimtes",
|
||||
"hostDefaults": "Bediener-standaarde",
|
||||
"hostDefaultsAdmin": "Bediener se bediener-standaarde",
|
||||
"aiProviders": "AI-lewerders",
|
||||
"homepageLayouts": "Tuisblad-lêers",
|
||||
"accountProfile": "Rekeningprofiel"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Map {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "تعذر تغيير بعض الإضافات. يمكنك إصلاحها لاحقاً من علامة التبويب الإضافات. اضغط التالي للمتابعة.",
|
||||
"pluginsApplyFailed": "تعذر حفظ اختياراتك. حاول مرة أخرى، أو تابع وقم بتغييرها لاحقاً من علامة التبويب الإضافات.",
|
||||
"pluginsLoadFailed": "تعذر تحميل قائمة الإضافات. يمكنك اختيار الإضافات لاحقاً من علامة التبويب الإضافات.",
|
||||
"presetTitle": "كم تريد أن يظهر على الشاشة؟",
|
||||
"presetIntro": "اختر مقدار ما يظهر على الشاشة.",
|
||||
"presetChangeLater": "يمكنك التبديل بين الإعدادات الجاهزة أو تغيير أي إعداد فردي لاحقاً في الإعدادات، تحت واجهة المستخدم.",
|
||||
"appearanceTitle": "اجعله خاصاً بك",
|
||||
"appearanceIntro": "اختر سمة، ولوناً مميزاً، وخطاً، ولغتك. تظهر التغييرات فوراً، ويمكنك تغييرها لاحقاً في الإعدادات.",
|
||||
"securityTitle": "أمن حسابك",
|
||||
"securityIntro": "أضف مفتاح مرور أو تطبيق مصادقة إلى حسابك. يمكنك أيضاً القيام بذلك لاحقاً في الإعدادات، تحت الأمان.",
|
||||
"desktopTitle": "كيف تريد استخدام Termix؟",
|
||||
"desktopBody": "يمكنك تغيير ذلك في أي وقت من المزامنة في الشريط الجانبي.",
|
||||
"desktopLocalTitle": "على هذا الجهاز فقط",
|
||||
"desktopLocalBody": "يتم تخزين كل شيء هنا. لا حاجة للإعداد.",
|
||||
"desktopLinkTitle": "مع خادم Termix الخاص بي",
|
||||
"desktopLinkBody": "سجّل الدخول إلى خادمك لاستخدام نفس المضيفين والإعدادات على كل جهاز.",
|
||||
"desktopLinked": "مرتبطة بـ {{server}}",
|
||||
"pluginsDocsLink": "اقرأ عن كل منها"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Termix {{version}} جاهز للتثبيت",
|
||||
"readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix."
|
||||
},
|
||||
"companion": {
|
||||
"close": "إغلاق اللوحة",
|
||||
"moveSide": "نقل اللوحة",
|
||||
"resize": "تغيير حجم اللوحة"
|
||||
},
|
||||
"nav": {
|
||||
"home": "الرئيسية",
|
||||
"terminal": "الطرفية",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "المضيفين",
|
||||
"credentials": "بيانات الاعتماد",
|
||||
"connections": "الاتصالات",
|
||||
"sync": "مزامنة",
|
||||
"reconnectDisconnectedTerminals": "إعادة توصيل جميع المحطات المنفصلة",
|
||||
"reconnectingTerminals_zero": "جاري إعادة توصيل {{count}} محطة",
|
||||
"reconnectingTerminals_one": "جاري إعادة توصيل {{count}} محطة",
|
||||
@@ -283,7 +291,10 @@
|
||||
"retry": "إعادة المحاولة",
|
||||
"refresh": "تحديث",
|
||||
"optional": "اختياري",
|
||||
"downloadSample": "تحميل نموذج",
|
||||
"failedToDeleteHost": "فشل في حذف {{name}}",
|
||||
"importSkipExisting": "استيراد (تخطي الموجود)",
|
||||
"importSSHConfig": "استيراد من إعدادات SSH",
|
||||
"connectionDetails": "تفاصيل الاتصال",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -506,7 +517,10 @@
|
||||
"noFolder": "(بدون مجلد)",
|
||||
"deleteSelected": "حذف",
|
||||
"exitSelection": "خروج من التحديد",
|
||||
"importSkip": "استيراد (تخطي الموجود)",
|
||||
"importOverwrite": "استيراد (استبدال)",
|
||||
"collapseBtn": "طي",
|
||||
"importExportBtn": "استيراد / تصدير",
|
||||
"hostStatusesRefreshed": "تم تحديث حالات المضيفين",
|
||||
"failedToRefreshHosts": "فشل تحديث المضيفين",
|
||||
"movedHostTo": "تم نقل {{host}} إلى \"{{folder}}\"",
|
||||
@@ -569,9 +583,6 @@
|
||||
"deleteFolderConfirm_many": "هل تريد حذف \"{{name}}\" و{{count}} مضيفًا؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"deleteFolderConfirm_other": "هل تريد حذف \"{{name}}\" و{{count}} مضيف؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"failedToMoveHosts": "فشل نقل الأجهزة",
|
||||
"managedBanner": "هذا المضيف مُدار بواسطة {{plugin}}. قم بتغييره هناك. يمكنك لا يزال تثبيته وإعادة ترتيبه.",
|
||||
"managedTooltip": "مُدار بواسطة {{plugin}}",
|
||||
"addBlocked": "المضيفون مُدارون بواسطة {{plugin}}، لذا لا يمكن إضافة مضيفين جدد هنا.",
|
||||
"failedToReorder": "فشل إعادة الترتيب",
|
||||
"expandAll": "توسيع جميع المجلدات",
|
||||
"collapseAll": "طي جميع المجلدات",
|
||||
@@ -686,6 +697,39 @@
|
||||
"addCredentialBtn2": "إضافة بيانات اعتماد",
|
||||
"checkingHostStatuses": "جاري التحقق من حالات المضيفين...",
|
||||
"pinnedSection": "مثبّتة",
|
||||
"hostsExported": "تم تصدير المضيفين بنجاح",
|
||||
"export": {
|
||||
"menuItem": "تصدير...",
|
||||
"title": "تصدير المضيفين",
|
||||
"scope": "النطاق",
|
||||
"scopeAll": "الكل",
|
||||
"scopeSelected": "المحددة",
|
||||
"searchHosts": "البحث عن المضيفين...",
|
||||
"include": "تضمين",
|
||||
"groupConnection": "الاتصال",
|
||||
"groupCredentials": "بيانات الاعتماد",
|
||||
"groupNotes": "ملاحظات",
|
||||
"groupTags": "الوسوم والتثبيت",
|
||||
"groupProxy": "الوكيل",
|
||||
"groupJumpHosts": "مضيفي القفز",
|
||||
"groupFeatureFlags": "إعدادات الإضافات",
|
||||
"groupAdvanced": "إعدادات متقدمة",
|
||||
"preview": "معاينة",
|
||||
"moreHosts_zero": "... و{{count}} مضيف آخر",
|
||||
"moreHosts_one": "... ومضيف واحد آخر",
|
||||
"moreHosts_two": "... و{{count}} مضيفين آخرين",
|
||||
"moreHosts_few": "... و{{count}} مضيفين آخرين",
|
||||
"moreHosts_many": "... و{{count}} مضيفًا آخر",
|
||||
"moreHosts_other": "... و{{count}} مضيف آخر",
|
||||
"summary": "{{selected}} من أصل {{total}} مضيف",
|
||||
"credentialsIncluded": "تم تضمين بيانات الاعتماد",
|
||||
"credentialsExcluded": "تم استبعاد بيانات الاعتماد",
|
||||
"noneSelected": "لم يتم تحديد أي مضيفين",
|
||||
"cancel": "إلغاء",
|
||||
"confirm": "تصدير",
|
||||
"fetchFailed": "فشل في تحميل المضيفين للتصدير",
|
||||
"bulkButton": "تصدير"
|
||||
},
|
||||
"sampleDownloaded": "تم تنزيل الملف النموذجي",
|
||||
"failedToDeleteCredential2": "فشل في حذف بيانات الاعتماد",
|
||||
"noFolderOption": "(بدون مجلد)",
|
||||
@@ -868,7 +912,11 @@
|
||||
"editorUpdate": "تحديث المضيف",
|
||||
"editorAdd": "إضافة مضيف",
|
||||
"speedSearch": "البحث عن المضيفين. مفاتيح الأسهم للاختيار، Enter للاتصال، Esc للإغلاق",
|
||||
"speedSearchList": "قائمة المضيفين. اكتب أو اضغط / للبحث."
|
||||
"speedSearchList": "قائمة المضيفين. اكتب أو اضغط / للبحث.",
|
||||
"importNoHosts": "لم يتم العثور على مضيفين في الملف",
|
||||
"importTooMany": "يمكنك استيراد ما يصل إلى {{max}} مضيفين في وقت واحد",
|
||||
"importSummary": "اكتمل الاستيراد: {{imported}} تم استيرادها، {{updated}} تم تحديثها، {{failed}} فشلت",
|
||||
"importHostsFailed": "فشل في استيراد المضيفين"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "شاشة مقسمة",
|
||||
@@ -1078,10 +1126,26 @@
|
||||
"hostCount_other": "{{count}} مضيف"
|
||||
},
|
||||
"admin": {
|
||||
"usersCount_zero": "{{count}} مستخدم",
|
||||
"usersCount_one": "مستخدم واحد",
|
||||
"usersCount_two": "{{count}} مستخدمين",
|
||||
"usersCount_few": "{{count}} مستخدمين",
|
||||
"usersCount_many": "{{count}} مستخدمًا",
|
||||
"rolesCount_zero": "{{count}} دور",
|
||||
"rolesCount_one": "دور واحد",
|
||||
"rolesCount_two": "{{count}} دورين",
|
||||
"rolesCount_few": "{{count}} أدوار",
|
||||
"rolesCount_many": "{{count}} دورًا",
|
||||
"apiKeysCount_zero": "{{count}} مفتاح",
|
||||
"apiKeysCount_one": "مفتاح واحد",
|
||||
"apiKeysCount_two": "{{count}} مفتاحين",
|
||||
"apiKeysCount_few": "{{count}} مفاتيح",
|
||||
"apiKeysCount_many": "{{count}} مفتاحًا",
|
||||
"sessionsActive_zero": "0 نشط",
|
||||
"sessionsActive_one": "1 نشط",
|
||||
"sessionsActive_two": "2 نشط",
|
||||
"sessionsActive_few": "{{count}} نشطة",
|
||||
"sessionsActive_many": "{{count}} نشطة",
|
||||
"hostsCount_zero": "{{count}} مضيف",
|
||||
"hostsCount_one": "مضيف واحد",
|
||||
"hostsCount_two": "{{count}} مضيفين",
|
||||
@@ -1095,7 +1159,11 @@
|
||||
"credentialsCount_many": "{{count}} بيانات اعتماد",
|
||||
"credentialsCount_other": "{{count}} بيانات اعتماد",
|
||||
"rolePermissions": {
|
||||
"count_zero": "{{count}} إذن",
|
||||
"count_one": "إذن واحد",
|
||||
"count_two": "{{count}} إذنين",
|
||||
"count_few": "{{count}} أذونات",
|
||||
"count_many": "{{count}} إذنًا",
|
||||
"groups": {
|
||||
"hosts": "المضيفين",
|
||||
"credentials": "بيانات الاعتماد",
|
||||
@@ -1135,61 +1203,25 @@
|
||||
"interfaceFontLabel": "خط الواجهة",
|
||||
"interfaceFontDescription": "يغيّر خط واجهة التطبيق. تحتفظ ملفات تعريف المحطة الطرفية بإعدادات الخط الخاصة بها.",
|
||||
"accentColorLabel": "لون التمييز",
|
||||
"apiKeyCount_zero": "{{count}} مفتاح",
|
||||
"apiKeyCount_one": "مفتاح واحد",
|
||||
"apiKeyCount_two": "{{count}} مفتاحين",
|
||||
"apiKeyCount_few": "{{count}} مفاتيح",
|
||||
"apiKeyCount_many": "{{count}} مفتاحًا",
|
||||
"colorPickerTooltip": "فتح منتقي الألوان",
|
||||
"themeSystem": "النظام",
|
||||
"themeLight": "فاتح",
|
||||
"themeDark": "داكن"
|
||||
"themeDark": "داكن",
|
||||
"themeDracula": "Dracula",
|
||||
"themeCatppuccin": "Catppuccin",
|
||||
"themeNord": "Nord",
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "كيف يتم تخطيط التطبيق بأكمله. تأتي السمات والتخطيطات من الإضافات.",
|
||||
"defaultsLabel": "الافتراضي للجميع",
|
||||
"defaultsHint": "ما يراه الناس حتى يختاروا تخطيطهم أو سماتهم الخاصة.",
|
||||
"followDefault": "استخدام الافتراضي",
|
||||
"defaultSaved": "تم حفظ المظهر الافتراضي",
|
||||
"saveFailed": "تعذر حفظ المظهر",
|
||||
"repair": {
|
||||
"title": "تعذر عرض هذا التخطيط",
|
||||
"safeTitle": "الوضع الآمن",
|
||||
"layout": "التخطيط",
|
||||
"plugin": "الإضافة",
|
||||
"reason": "السبب",
|
||||
"error": "خطأ",
|
||||
"noError": "لا توجد رسالة خطأ.",
|
||||
"copy": "نسخ",
|
||||
"copied": "تم النسخ",
|
||||
"copyFailed": "تعذر نسخ التفاصيل",
|
||||
"retry": "حاول مرة أخرى",
|
||||
"reset": "إعادة تعيين إلى المظهر الافتراضي",
|
||||
"resetEveryone": "إعادة تعيين الافتراضي للجميع",
|
||||
"update": "تحديث {{name}} إلى {{version}}",
|
||||
"disable": "إيقاف {{name}}",
|
||||
"install": "البحث عن التخطيطات والسمات",
|
||||
"installTitle": "التخطيطات والسمات",
|
||||
"installButton": "تثبيت",
|
||||
"nothingToInstall": "لا توجد تخطيطات أو سمات أخرى متاحة حالياً.",
|
||||
"registryUnreachable": "تعذر الوصول إلى سجل الإضافات.",
|
||||
"askAdmin": "اطلب من المسؤول تحديث أو إصلاح هذا التخطيط. يمكنك التبديل إلى تخطيط أو سمة أخرى لنفسك أدناه.",
|
||||
"switchLayout": "تبديل التخطيط",
|
||||
"switchTheme": "تبديل السمة",
|
||||
"saveFailed": "تعذر حفظ اختيارك",
|
||||
"reasons": {
|
||||
"safe-url": "العنوان طلب الوضع الآمن. اختر تخطيطاً أدناه، أو احذف ?safe-layout=1 من العنوان.",
|
||||
"safe-env": "تم تشغيل الخادم مع TERMIX_SAFE_LAYOUT، لذا يرى الجميع هذه الشاشة حتى يتم إزالته.",
|
||||
"missing": "لا توجد إضافة مثبتة توفر هذا التخطيط.",
|
||||
"disabled": "الإضافة التي توفر هذا التخطيط معطلة.",
|
||||
"blocked": "الإضافة التي توفر هذا التخطيط تنتظر شيئاً تحتاجه.",
|
||||
"incompatible": "الإضافة التي توفر هذا التخطيط لا تعمل مع هذه النسخة من Termix.",
|
||||
"failed": "فشل التخطيط في البدء.",
|
||||
"crashed": "تعطل التخطيط مرتين أثناء رسم التطبيق."
|
||||
},
|
||||
"noLayout": "لم يتم تعيين أي تخطيط",
|
||||
"restore": "استعادة {{name}} المرفقة",
|
||||
"restoreFailed": "تعذر استعادة النسخة المرفقة"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1212,18 +1244,6 @@
|
||||
"title": "إدارة تسجيل الدخول",
|
||||
"consequence": "يمكنه إضافة طريقة تسجيل دخول، أو عامل تحقق ثانٍ، أو نوع تسجيل دخول عبر SSH."
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "تعيين قواعد تسجيل الدخول",
|
||||
"consequence": "يمكنه تسجيل خروج الأشخاص عند الخمول، وتقييد مدة الجلسة، وإيقاف تذكرني، ومتطلب عامل ثانٍ."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "اختر كيفية الوصول إلى المضيفين",
|
||||
"consequence": "يمكنه نقل اتصال SSH إلى مضيف عبر مساره الخاص، لذا يرى كل ما يُرسل إلى ومن تلك المضيفين، بما في ذلك كلمات المرور."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "تشغيل المزامنة",
|
||||
"consequence": "يمكنه الإجابة على جميع طلبات المزامنة، لذا يرى البيانات التي تزامن الأجهزة والخوادم."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "إدارة الشهادات",
|
||||
"consequence": "يمكنه استبدال الشهادة التي يقدمها Termix عبر HTTPS والرد على فحوصات النطاق من جهات إصدار الشهادات."
|
||||
@@ -1232,10 +1252,6 @@
|
||||
"title": "الوصول إلى الأجهزة التسلسلية و USB",
|
||||
"consequence": "يمكنه فتح منفذ تسلسلي مادي على الجهاز الذي يعمل عليه Termix."
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "تصدير المضيفين مع أسرارهم",
|
||||
"consequence": "يمكنه قراءة مضيفيك بالكامل، بما في ذلك كلمات المرور والمفاتيح المحفوظة، وتسليمها. يتم كتابة كل تصدير في سجل التدقيق."
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "إنشاء وتعديل الخوادم",
|
||||
"consequence": "يمكنه إضافة خوادم وتعديل تلك التي لديك بالفعل."
|
||||
@@ -1280,30 +1296,10 @@
|
||||
"title": "قراءة سجل التدقيق",
|
||||
"consequence": "يمكنه رؤية من فعل ماذا ومتى."
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "التحقق مما إذا كانت المنافذ تجيب",
|
||||
"consequence": "يمكنه فتح اتصال عادي إلى أي عنوان من الخادم، لمعرفة ما إذا كان يمكن الوصول إليه."
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "توفير حالة المضيف",
|
||||
"consequence": "يصبح مصدر النقاط عبر الإنترنت وغير المتصلة لكل مضيف."
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "تخزين النشاط الأخير",
|
||||
"consequence": "يُصبح مخزناً لقائمة النشاطات الأخيرة لكل مستخدم."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "إدارة المضيفين كقراءة فقط",
|
||||
"consequence": "لا يمكن لأي شخص آخر تعديل أو حذف أو إعادة مشاركة المضيفين التي يديرها."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "فتح نوافذ سطح المكتب",
|
||||
"consequence": "فقط في تطبيق سطح المكتب الخاص بـ Termix."
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "استخدام هذا الكمبيوتر",
|
||||
"consequence": "يمكنه قراءة وتغيير الملفات، وتشغيل shell، وفتح المنافذ المحلية على هذا الكمبيوتر. فقط في تطبيق Termix المكتبي."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "التصرف كمستخدمين آخرين",
|
||||
"consequence": "يؤدي العمل في الخلفية باستخدام صلاحيات مستخدم آخر، مثل المهام المجدولة. يتم تسجيل كل استخدام في سجل التدقيق."
|
||||
@@ -1343,14 +1339,6 @@
|
||||
"ui:surface": {
|
||||
"title": "إضافة شاشات خاصة بها",
|
||||
"consequence": "علامات تبويب، ولوحات، وإعدادات يمكنك إخفاؤها لاحقاً."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "استبدال تخطيط التطبيق بالكامل",
|
||||
"consequence": "عند اختياره، يقوم كوده برسم كل شاشة، بما في ذلك طرفياتك وإعداداتك."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "إضافة سمات ألوان",
|
||||
"consequence": "ألوان وأنماط فقط. لا يتم تشغيل أي كود لسمات."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1528,7 +1516,6 @@
|
||||
"options": "تعذر حفظ خيارات {{name}}: {{error}}",
|
||||
"deleteData": "تعذر حذف بيانات {{name}}: {{error}}",
|
||||
"managedByServer": "هذا سطح المكتب مرتبط بخادم، وهو الذي يحدد إضافاته.",
|
||||
"defaultLook": "{{name}} هو المظهر الافتراضي، لذا يمكن إيقافه أو استبداله ولكن لا يمكن إلغاء تثبيته.",
|
||||
"hasDependents": "{{name}} مطلوب من قبل {{list}}. قم بتعطيلها أو إلغاء تثبيتها أولاً.",
|
||||
"missingDependencies": "{{name}} يحتاج إلى {{list}}، وهو غير مثبت.",
|
||||
"consentChanged": "تغير ما تطلبه هذه الإضافة منذ أن نظرته. تحقق من القائمة مرة أخرى.",
|
||||
@@ -1614,6 +1601,7 @@
|
||||
"noServerAccess": "لا يوجد وصول إلى خوادمك",
|
||||
"searchInstalled": "البحث في المثبتة",
|
||||
"searchPlugins": "البحث في الإضافات",
|
||||
"allCategories": "الكل",
|
||||
"nothingMatches": "لا شيء يطابق ذلك.",
|
||||
"noneInstalled": "لم يتم تثبيت أي إضافات بعد. تصفح لإضافة بعض.",
|
||||
"nothingHere": "لا شيء هنا.",
|
||||
@@ -1719,48 +1707,7 @@
|
||||
"documentation": "التوثيق",
|
||||
"envVars": "متغيرات البيئة",
|
||||
"envRequired": "مطلوب",
|
||||
"envDefault": "الافتراضي: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "التخطيطات",
|
||||
"themes": "السمات",
|
||||
"tabs": "العلامات",
|
||||
"panels": "لوحات الشريط الجانبي",
|
||||
"dashboardCards": "بطاقات لوحة التحكم",
|
||||
"protocols": "أنواع الاتصال",
|
||||
"auth": "طرق تسجيل الدخول",
|
||||
"actions": "الإجراءات",
|
||||
"hostCapability": "إعدادات المضيف"
|
||||
},
|
||||
"filters": {
|
||||
"type": "النوع",
|
||||
"allTypes": "جميع الأنواع",
|
||||
"category": "الفئة",
|
||||
"allCategories": "جميع الفئات",
|
||||
"results_zero": "لا توجد إضافات",
|
||||
"results_one": "إضافة واحدة",
|
||||
"results_two": "إضافتان",
|
||||
"results_few": "{{count}} إضافات",
|
||||
"results_many": "{{count}} إضافة",
|
||||
"results_other": "{{count}} إضافة",
|
||||
"remove": "إزالة الفلتر {{name}}",
|
||||
"clear": "مسح الفلاتر",
|
||||
"button": "الفلاتر",
|
||||
"buttonCount": "الفلاتر ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} من {{total}}",
|
||||
"page": "الصفحة {{page}} من {{pages}}",
|
||||
"previous": "الصفحة السابقة",
|
||||
"next": "الصفحة التالية",
|
||||
"perPage": "{{count}} في كل صفحة",
|
||||
"perPageLabel": "الإضافات في كل صفحة"
|
||||
},
|
||||
"showUnsupportedHint": "هذه مدرجة ولكن لا يوجد إصدار يعمل على هذا الإصدار بعد. قم بتحديث Termix لتثبيتها.",
|
||||
"unsupported": "يتطلب إصدار أحدث من Termix",
|
||||
"unsupportedHint": "لا يوجد إصدار من هذه الإضافة يعمل على هذا الإصدار من Termix. قم بتحديث Termix لتثبيتها.",
|
||||
"needsTermix": "يتطلب Termix {{range}}",
|
||||
"showUnsupported": "إظهار غير المدعوم ({{count}})",
|
||||
"showMore": "إظهار {{count}} أخرى ({{total}} متبقية)"
|
||||
"envDefault": "الافتراضي: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1776,6 +1723,107 @@
|
||||
"noData": "لا توجد بيانات",
|
||||
"filter": "تصفية..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_zero": "منذ {{count}} دقائق",
|
||||
"minutesAgo_one": "منذ دقيقة واحدة",
|
||||
"minutesAgo_two": "منذ دقيقتين",
|
||||
"minutesAgo_few": "منذ {{count}} دقائق",
|
||||
"minutesAgo_many": "منذ {{count}} دقيقة",
|
||||
"minutesAgo_other": "منذ {{count}} دقيقة",
|
||||
"hoursAgo_zero": "منذ {{count}} ساعات",
|
||||
"hoursAgo_one": "منذ ساعة واحدة",
|
||||
"hoursAgo_two": "منذ ساعتين",
|
||||
"hoursAgo_few": "منذ {{count}} ساعات",
|
||||
"hoursAgo_many": "منذ {{count}} ساعة",
|
||||
"hoursAgo_other": "منذ {{count}} ساعة",
|
||||
"pending_zero": "{{count}} تغييرات في انتظار المزامنة",
|
||||
"pending_one": "تغيير واحد في انتظار المزامنة",
|
||||
"pending_two": "تغييران في انتظار المزامنة",
|
||||
"pending_few": "{{count}} تغييرات في انتظار المزامنة",
|
||||
"pending_many": "{{count}} تغييراً في انتظار المزامنة",
|
||||
"pending_other": "{{count}} تغيير في انتظار المزامنة",
|
||||
"proxy": {
|
||||
"headersTitle": "رؤوس إضافية",
|
||||
"headersHint": "تُرسَل مع كل طلب إلى الخادم، على سبيل المثال رمز خدمة Cloudflare Access.",
|
||||
"headerName": "اسم الرأس",
|
||||
"headerValue": "القيمة",
|
||||
"savedValue": "تم الحفظ، اكتب لتغيير",
|
||||
"addHeader": "إضافة رأس",
|
||||
"removeHeader": "إزالة الرأس",
|
||||
"basicAuthTitle": "اسم المستخدم وكلمة المرور",
|
||||
"basicAuthHint": "لوكيل يطلب اسم مستخدم وكلمة مرور قبل Termix.",
|
||||
"username": "اسم المستخدم",
|
||||
"password": "كلمة المرور",
|
||||
"allowInvalidCertificate": "الثقة بشهادة موقعة ذاتياً",
|
||||
"allowInvalidCertificateHint": "فقط لخادمك الخاص، عندما لا تكون شهادته من جهة معروفة."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "هذا لا يبدو كعنوان ويب.",
|
||||
"unreachable": "تعذر الوصول إلى الخادم. تحقق من العنوان وأن الخادم قيد التشغيل.",
|
||||
"unreachableWithReason": "تعذر الوصول إلى الخادم: {{reason}}",
|
||||
"tlsUntrusted": "شهادة الخادم غير موثوقة. إذا كان هذا خادمك الخاص بشهادة موقعة ذاتيًا، فعّل الخيار أدناه.",
|
||||
"basicAuthRequired": "شيء ما أمام الخادم يطلب اسم مستخدم وكلمة مرور. أدخلهما أدناه.",
|
||||
"proxyLogin": "صفحة تسجيل دخول أو وكيل (Proxy) أجابت بدلاً من Termix. أضف الرؤوس (Headers) التي يحتاجها أدناه، أو تحقق من العنوان.",
|
||||
"notTermix": "لا يوجد خادم Termix عند هذا العنوان. إذا كان يعمل تحت مسار، فقم بتضمينه، مثل https://example.com/termix.",
|
||||
"versionMismatch": "يعمل ذلك الخادم على إصدار أقدم من Termix. قم بتحديثه إلى 2.9 أو أحدث للربط.",
|
||||
"differentAccount": "لقد قمت بتسجيل الدخول إلى حساب مختلف. سجّل الدخول إلى الحساب المرتبط بهذا الجهاز، أو قم بإلغاء الربط أولاً."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "الربط بخادم Termix",
|
||||
"reloginTitle": "تسجيل الدخول مرة أخرى",
|
||||
"savedServers": "الخوادم الأخيرة",
|
||||
"removeServer": "إزالة من القائمة",
|
||||
"serverDescription": "أدخل العنوان الذي تستخدمه لفتح Termix في متصفحك.",
|
||||
"signinDescription": "تسجيل الدخول إلى {{url}}",
|
||||
"chooseDescription": "هذا الجهاز يحتوي بالفعل على بيانات. ماذا يجب أن يحدث لها؟",
|
||||
"finishingDescription": "جارٍ ربط هذا الجهاز.",
|
||||
"serverUrl": "عنوان الخادم",
|
||||
"serverUrlHint": "على سبيل المثال https://termix.example.com",
|
||||
"showProxySettings": "إعدادات الوكيل العكسي",
|
||||
"hideProxySettings": "إخفاء إعدادات الوكيل العكسي",
|
||||
"continue": "متابعة",
|
||||
"back": "رجوع",
|
||||
"mergeTitle": "إضافته إلى حسابي",
|
||||
"mergeBody_zero": "تمت إضافة العناصر الموجودة على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
|
||||
"mergeBody_one": "تمت إضافة العنصر الموجود على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
|
||||
"mergeBody_two": "تمت إضافة العنصرين الموجودين على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
|
||||
"mergeBody_few": "تمت إضافة العناصر الموجودة على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
|
||||
"mergeBody_many": "تمت إضافة العناصر الموجودة على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
|
||||
"mergeBody_other": "تمت إضافة {{count}} عنصرًا موجودًا على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
|
||||
"replaceTitle": "استبداله بحسابي",
|
||||
"replaceBody_zero": "تمت إزالة العناصر الموجودة على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
|
||||
"replaceBody_one": "تمت إزالة العنصر الموجود على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
|
||||
"replaceBody_two": "تمت إزالة العنصرين الموجودين على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
|
||||
"replaceBody_few": "تمت إزالة العناصر الموجودة على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
|
||||
"replaceBody_many": "تمت إزالة العناصر الموجودة على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
|
||||
"replaceBody_other": "تمت إزالة {{count}} عنصرًا موجودًا على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
|
||||
"link": "ربط",
|
||||
"linking": "جارٍ الربط وتشغيل المزامنة الأولى...",
|
||||
"linked": "تم الربط. بياناتك تتم مزامنتها الآن.",
|
||||
"signedInAgain": "تم تسجيل الدخول. المزامنة تعمل مرة أخرى."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "المضيفين",
|
||||
"sshCredentials": "بيانات الاعتماد",
|
||||
"sshFolders": "المجلدات",
|
||||
"userPreferences": "التفضيلات",
|
||||
"pluginUserSettings": "إعدادات الإضافات",
|
||||
"sharedHosts": "المضيفين المشتركين معك",
|
||||
"sharedCredentials": "بيانات الاعتماد المشتركة معك",
|
||||
"vaultProfiles": "ملفات تعريف Vault",
|
||||
"snippetFolders": "مجلدات المقاطع",
|
||||
"snippets": "المقاطع",
|
||||
"dashboardServiceLinks": "روابط الخدمات",
|
||||
"homepageItems": "عناصر الصفحة الرئيسية",
|
||||
"networkTopology": "خريطة الشبكة",
|
||||
"workspaces": "مساحات العمل",
|
||||
"hostDefaults": "الإعدادات الافتراضية للمضيفين",
|
||||
"hostDefaultsAdmin": "الإعدادات الافتراضية لمضيفي الخادم",
|
||||
"aiProviders": "مزودي الذكاء الاصطناعي",
|
||||
"homepageLayouts": "تخطيطات الصفحة الرئيسية",
|
||||
"accountProfile": "ملف تعريف الحساب"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "مجلد {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "Някои плъгини не могат да бъдат променени. Можете да ги поправите по-късно от раздела Плъгини. Натиснете Напред, за да продължите.",
|
||||
"pluginsApplyFailed": "Вашите избори не могат да бъдат запазени. Опитайте отново или продължете и ги променете по-късно от раздела Плъгини.",
|
||||
"pluginsLoadFailed": "Списъкът с плъгини не може да бъде зареден. Можете да изберете плъгини по-късно от раздела Плъгини.",
|
||||
"presetTitle": "Колко искате да виждате на екрана?",
|
||||
"presetIntro": "Изберете колко се показва на екрана.",
|
||||
"presetChangeLater": "Можете да смените предварителните настройки или да промените всяка отделна настройка по-късно в Настройки, под Интерфейс.",
|
||||
"appearanceTitle": "Направете го ваше",
|
||||
"appearanceIntro": "Изберете тема, акцентен цвят, шрифт и език. Промените се виждат веднага и можете да ги промените по-късно в Настройки.",
|
||||
"securityTitle": "Сигурност на акаунта",
|
||||
"securityIntro": "Добавете passkey или приложение за автентикация към вашия акаунт. Можете да направите това и по-късно в Настройки, под Сигурност.",
|
||||
"desktopTitle": "Как искате да използвате Termix?",
|
||||
"desktopBody": "Можете да промените това по всяко време от Синхронизация в страничната лента.",
|
||||
"desktopLocalTitle": "Само на това устройство",
|
||||
"desktopLocalBody": "Всичко се съхранява тук. Няма нужда от настройка.",
|
||||
"desktopLinkTitle": "С моя Termix сървър",
|
||||
"desktopLinkBody": "Влезте в сървъра си, за да използвате същите хостове и настройки на всяко устройство.",
|
||||
"desktopLinked": "Връзка с {{server}}",
|
||||
"pluginsDocsLink": "Прочетете за всеки от тях"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Termix {{version}} е готов за инсталация",
|
||||
"readyHint": "Ще се инсталира следващия път, когато Termix се рестартира."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Затвори панела",
|
||||
"moveSide": "Премести панела",
|
||||
"resize": "Промени размера на панела"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Начало",
|
||||
"terminal": "Терминал",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "Хостове",
|
||||
"credentials": "Удостоверения",
|
||||
"connections": "Връзки",
|
||||
"sync": "Синхронизиране",
|
||||
"reconnectDisconnectedTerminals": "Повторно свързване на всички прекъснати терминали",
|
||||
"reconnectingTerminals_one": "Повторно свързване на {{count}} терминал",
|
||||
"reconnectingTerminals_other": "Повторно свързване на {{count}} терминала",
|
||||
@@ -271,7 +279,10 @@
|
||||
"retry": "Опитай отново",
|
||||
"refresh": "Опресни",
|
||||
"optional": "По избор",
|
||||
"downloadSample": "Изтегли пример",
|
||||
"failedToDeleteHost": "Неуспешно изтриване на {{name}}",
|
||||
"importSkipExisting": "Импортиране (пропусни съществуващите)",
|
||||
"importSSHConfig": "Импортиране от SSH конфигурация",
|
||||
"connectionDetails": "Детайли за връзката",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -490,7 +501,10 @@
|
||||
"noFolder": "(Без папка)",
|
||||
"deleteSelected": "Изтриване",
|
||||
"exitSelection": "Изход от избора",
|
||||
"importSkip": "Импортиране (пропускане на съществуващи)",
|
||||
"importOverwrite": "Импортиране (записване върху съществуващи)",
|
||||
"collapseBtn": "Свиване",
|
||||
"importExportBtn": "Импортиране / Експортиране",
|
||||
"hostStatusesRefreshed": "Статусите на хостовете са обновени",
|
||||
"failedToRefreshHosts": "Грешка при обновяването на хостовете",
|
||||
"movedHostTo": "Преместихте {{host}} в \"{{folder}}\"",
|
||||
@@ -545,9 +559,6 @@
|
||||
"deleteFolderConfirm_one": "Изтриване на \"{{name}}\" и неговия {{count}} хост? Това действие е необратимо.",
|
||||
"deleteFolderConfirm_other": "Изтриване на \"{{name}}\" и неговите {{count}} хоста? Това действие е необратимо.",
|
||||
"failedToMoveHosts": "Грешка при преместване на хостовете",
|
||||
"managedBanner": "Този хост се управлява от {{plugin}}. Направете промени там. Все още можете да го закрепите и да промените реда му.",
|
||||
"managedTooltip": "Управлява се от {{plugin}}",
|
||||
"addBlocked": "Хостовете се управляват от {{plugin}}, така че тук не могат да се добавят нови хостове.",
|
||||
"failedToReorder": "Грешка при преподреждане",
|
||||
"expandAll": "Разгъване на всички папки",
|
||||
"collapseAll": "Свиване на всички папки",
|
||||
@@ -658,6 +669,35 @@
|
||||
"addCredentialBtn2": "Добавяне на идентификационни данни",
|
||||
"checkingHostStatuses": "Проверяване на състоянието на хостовете...",
|
||||
"pinnedSection": "Закачени",
|
||||
"hostsExported": "Хостовете бяха експортирани успешно",
|
||||
"export": {
|
||||
"menuItem": "Експортиране...",
|
||||
"title": "Експортиране на хостове",
|
||||
"scope": "Обхват",
|
||||
"scopeAll": "Всички",
|
||||
"scopeSelected": "Избрани",
|
||||
"searchHosts": "Търсене на хостове...",
|
||||
"include": "Включване",
|
||||
"groupConnection": "Връзка",
|
||||
"groupCredentials": "Удостоверения",
|
||||
"groupNotes": "Бележки",
|
||||
"groupTags": "Етикети и закачане",
|
||||
"groupProxy": "Прокси",
|
||||
"groupJumpHosts": "Промежътъчни хостове",
|
||||
"groupFeatureFlags": "Настройки на плъгините",
|
||||
"groupAdvanced": "Разширена конфигурация",
|
||||
"preview": "Преглед",
|
||||
"moreHosts_one": "... още {{count}} хост",
|
||||
"moreHosts_other": "... още {{count}} хоста",
|
||||
"summary": "{{selected}} от {{total}} хоста",
|
||||
"credentialsIncluded": "включени удостоверения",
|
||||
"credentialsExcluded": "изключени удостоверения",
|
||||
"noneSelected": "Няма избрани хостове",
|
||||
"cancel": "Отказ",
|
||||
"confirm": "Експортиране",
|
||||
"fetchFailed": "Грешка при зареждане на хостовете за експортиране",
|
||||
"bulkButton": "Експортиране"
|
||||
},
|
||||
"sampleDownloaded": "Примерният файл беше изтеглен",
|
||||
"failedToDeleteCredential2": "Грешка при изтриване на удостоверение",
|
||||
"noFolderOption": "(Без папка)",
|
||||
@@ -820,7 +860,11 @@
|
||||
"editorUpdate": "Актуализирай хост",
|
||||
"editorAdd": "Добави хост",
|
||||
"speedSearch": "Търсене на хостове. Стрелки за избор, Enter за връзка, Esc за затваряне",
|
||||
"speedSearchList": "Списък с хостове. Напишете или натиснете / за търсене."
|
||||
"speedSearchList": "Списък с хостове. Напишете или натиснете / за търсене.",
|
||||
"importNoHosts": "Не са намерени хостове във файла",
|
||||
"importTooMany": "Можете да импортирате до {{max}} хоста наведнъж",
|
||||
"importSummary": "Импортирането е завършено: {{imported}} импортирани, {{updated}} актуализирани, {{failed}} неуспешни",
|
||||
"importHostsFailed": "Неуспешно импортиране на хостове"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Разделен екран",
|
||||
@@ -1071,57 +1115,17 @@
|
||||
"colorPickerTooltip": "Отваряне на изборника на цветове",
|
||||
"themeSystem": "Системна",
|
||||
"themeLight": "Светла",
|
||||
"themeDark": "Тъмна"
|
||||
"themeDark": "Тъмна",
|
||||
"themeDracula": "Dracula",
|
||||
"themeCatppuccin": "Catppuccin",
|
||||
"themeNord": "Nord",
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "Как е подредено цялото приложение. Темите и подредбите идват от плъгини.",
|
||||
"defaultsLabel": "По подразбиране за всички",
|
||||
"defaultsHint": "Какво виждат потребителите, докато не изберат своя собствена подредба или тема.",
|
||||
"followDefault": "Използвай по подразбиране",
|
||||
"defaultSaved": "Външният вид по подразбиране е запазен",
|
||||
"saveFailed": "Външният вид не може да бъде запазен",
|
||||
"repair": {
|
||||
"title": "Тази подредба не може да бъде показана",
|
||||
"safeTitle": "Сигурен режим",
|
||||
"layout": "Подредба",
|
||||
"plugin": "Плъгин",
|
||||
"reason": "Причина",
|
||||
"error": "Грешка",
|
||||
"noError": "Няма съобщение за грешка.",
|
||||
"copy": "Копирай",
|
||||
"copied": "Копирано",
|
||||
"copyFailed": "Детайлите не могат да бъдат копирани",
|
||||
"retry": "Опитай отново",
|
||||
"reset": "Върни външния вид по подразбиране",
|
||||
"resetEveryone": "Върни настройките по подразбиране за всички",
|
||||
"update": "Актуализирай {{name}} до {{version}}",
|
||||
"disable": "Изключи {{name}}",
|
||||
"install": "Намери подредби и теми",
|
||||
"installTitle": "Подредби и теми",
|
||||
"installButton": "Инсталирай",
|
||||
"nothingToInstall": "В момента няма други налични подредби или теми.",
|
||||
"registryUnreachable": "Регистърът на плъгините не може да бъде достигнат.",
|
||||
"askAdmin": "Попитайте администратор да актуализира или поправи тази подредба. По-долу можете да превключите на друга подредба или тема само за себе си.",
|
||||
"switchLayout": "Превключи подредба",
|
||||
"switchTheme": "Превключи тема",
|
||||
"saveFailed": "Вашият избор не може да бъде запазен",
|
||||
"reasons": {
|
||||
"safe-url": "Адресът изисква сигурен режим. Изберете подредба по-долу или премахнете ?safe-layout=1 от адреса.",
|
||||
"safe-env": "Сървърът е стартиран с TERMIX_SAFE_LAYOUT, така че всички виждат този екран, докато той не бъде премахнат.",
|
||||
"missing": "Няма инсталиран плъгин, който да предоставя тази подредба.",
|
||||
"disabled": "Плъгинът, който предоставя тази подредба, е изключен.",
|
||||
"blocked": "Плъгинът, който предоставя тази подредба, чака нещо, от което се нуждае.",
|
||||
"incompatible": "Плъгинът, който предоставя тази подредба, не работи с тази версия на Termix.",
|
||||
"failed": "Подредбата не успя да се стартира.",
|
||||
"crashed": "Подредбата се срина два пъти, докато зареждаше приложението."
|
||||
},
|
||||
"noLayout": "Няма зададена подредба",
|
||||
"restore": "Възстанови вградената {{name}}",
|
||||
"restoreFailed": "Вградената копия не може да бъде възстановена"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1144,18 +1148,6 @@
|
||||
"title": "Управление на влизането",
|
||||
"consequence": "Може да добавя метод за влизане, втори фактор или тип SSH влизане."
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "Задайте правила за влизане",
|
||||
"consequence": "Може да излиза потребителите при бездействие, да ограничава продължителността на сесията, да изключва опцията „Запомни ме“ и да изисква втор фактор."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Изберете как се достъпват хостовете",
|
||||
"consequence": "Може да пренасочва SSH връзката към хост през собствен си маршрут, така че вижда всичко, изпратено до и от тези хостове, включително пароли."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Изпълни синхронизация",
|
||||
"consequence": "Може да отговаря на всички заявки за синхронизация, така че вижда данните, които устройствата и сървърите синхронизират."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Управление на сертификати",
|
||||
"consequence": "Може да замени сертификата, който Termix предоставя чрез HTTPS, и да отговаря на проверки от домейни от органи за издаване на сертификати."
|
||||
@@ -1164,10 +1156,6 @@
|
||||
"title": "Достъп до серийни и USB устройства",
|
||||
"consequence": "Може да отвори физически серийен порт на устройството, което изпълнява Termix."
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "Експортирай хостове с техните тайни",
|
||||
"consequence": "Може да чете хостовете ви напълно, включително запазените пароли и ключове, и да ги предоставя. Всеки експорт се записва в журнала за одит."
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "Създаване и промяна на хостове",
|
||||
"consequence": "Може да добавя хостове и да редактира тези, които вече имате."
|
||||
@@ -1212,30 +1200,10 @@
|
||||
"title": "Четене на одитния журнал",
|
||||
"consequence": "Може да вижда кой какво е направил и кога."
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "Провери дали портовете отговарят",
|
||||
"consequence": "Може да отваря обикновена връзка към всеки адрес от сървъра, за да види дали е достъпен."
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "Предоставяне на статус на хоста",
|
||||
"consequence": "Става източник на точките за онлайн и офлайн статус за всеки хост."
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "Съхраняване на последната активност",
|
||||
"consequence": "Станва хранилище за списъка с последната активност на всеки потребител."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "Управление на хостове само за четене",
|
||||
"consequence": "Хостовете, които управлява, не могат да бъдат редактирани, изтривани или споделяни отново от никого друг."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Отваряне на прозорци на работния плот",
|
||||
"consequence": "Само в десктоп приложението на Termix."
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "Използване на този компютър",
|
||||
"consequence": "Може да чете и променя файлове, да стартира shell и да отваря локални портове на този компютър. Само в десктоп приложението на Termix."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "Действане от името на други потребители",
|
||||
"consequence": "Изпълнява фоновата работа с достъпа на друг потребител, като например планирани задачи. Всяко използване се записва в одитния журнал."
|
||||
@@ -1275,14 +1243,6 @@
|
||||
"ui:surface": {
|
||||
"title": "Добавя свои собствени екрани",
|
||||
"consequence": "Раздели, панели и настройки, които можете да скриете по-късно."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "Замяна на цялата структура на приложението",
|
||||
"consequence": "Когато е избран, неговият код изчертава всеки екран, включително терминалите и настройките ви."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "Добавяне на цветови теми",
|
||||
"consequence": "Само цветове и стилове. За тема не се изпълнява код."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1412,7 +1372,6 @@
|
||||
"options": "Не можа да се запазят опциите за {{name}}: {{error}}",
|
||||
"deleteData": "Не можа да се изтрият данните за {{name}}: {{error}}",
|
||||
"managedByServer": "Този работен плот е свързан със сървър, който определя плъгините му.",
|
||||
"defaultLook": "{{name}} е стандартният външен вид, така че може да бъде изключен или заменен, но не и деинсталиран.",
|
||||
"hasDependents": "{{name}} е необходим за {{list}}. Изключете ги или ги деинсталирайте първо.",
|
||||
"missingDependencies": "{{name}} се нуждае от {{list}}, който не е инсталиран.",
|
||||
"consentChanged": "Какво изисква този плъгин се е променило от последния път, когато го проверихте. Проверете списъка отново.",
|
||||
@@ -1482,6 +1441,7 @@
|
||||
"noServerAccess": "Няма достъп до сървърите ви",
|
||||
"searchInstalled": "Търси сред инсталираните",
|
||||
"searchPlugins": "Търси плъгини",
|
||||
"allCategories": "Всички",
|
||||
"nothingMatches": "Няма съвпадения.",
|
||||
"noneInstalled": "Все още няма инсталирани плъгини. Разгледайте каталога, за да добавите някой.",
|
||||
"nothingHere": "Тук няма нищо.",
|
||||
@@ -1571,44 +1531,7 @@
|
||||
"documentation": "Документация",
|
||||
"envVars": "Променливи на средата",
|
||||
"envRequired": "Задължителни",
|
||||
"envDefault": "По подразбиране: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "Структури",
|
||||
"themes": "Теми",
|
||||
"tabs": "Раздели",
|
||||
"panels": "Панели в страничната лента",
|
||||
"dashboardCards": "Карти на таблото",
|
||||
"protocols": "Видове връзки",
|
||||
"auth": "Методи за вписване",
|
||||
"actions": "Действия",
|
||||
"hostCapability": "Настройки на хоста"
|
||||
},
|
||||
"filters": {
|
||||
"type": "Вид",
|
||||
"allTypes": "Всички видове",
|
||||
"category": "Категория",
|
||||
"allCategories": "Всички категории",
|
||||
"results_one": "{{count}} плъгин",
|
||||
"results_other": "{{count}} плъгина",
|
||||
"remove": "Премахване на филтъра {{name}}",
|
||||
"clear": "Изчистване на филтрите",
|
||||
"button": "Филтри",
|
||||
"buttonCount": "Филтри ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} от {{total}}",
|
||||
"page": "Страница {{page}} от {{pages}}",
|
||||
"previous": "Предишна страница",
|
||||
"next": "Следваща страница",
|
||||
"perPage": "{{count}} на страница",
|
||||
"perPageLabel": "Плъгини на страница"
|
||||
},
|
||||
"showUnsupportedHint": "Тези са изброени, но все още няма версия, която да работи с тази версия. Актуализирайте Termix, за да ги инсталирате.",
|
||||
"unsupported": "Изисква по-нова версия на Termix",
|
||||
"unsupportedHint": "Няма версия на този плъгин, която да работи с тази версия на Termix. Актуализирайте Termix, за да го инсталирате.",
|
||||
"needsTermix": "Изисква Termix {{range}}",
|
||||
"showUnsupported": "Показване на неподдържани ({{count}})",
|
||||
"showMore": "Показване на още {{count}} (остава {{total}})"
|
||||
"envDefault": "По подразбиране: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1624,6 +1547,87 @@
|
||||
"noData": "Няма данни",
|
||||
"filter": "Филтър..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "преди {{count}} минута",
|
||||
"minutesAgo_other": "преди {{count}} минути",
|
||||
"hoursAgo_one": "преди {{count}} час",
|
||||
"hoursAgo_other": "преди {{count}} часа",
|
||||
"pending_one": "{{count}} промяна чака синхронизиране",
|
||||
"pending_other": "{{count}} промени чакат синхронизиране",
|
||||
"proxy": {
|
||||
"headersTitle": "Допълнителни хедъри",
|
||||
"headersHint": "Изпращат се с всяка заявка към сървъра, например токен за услуга Cloudflare Access.",
|
||||
"headerName": "Име на хедъра",
|
||||
"headerValue": "Стойност",
|
||||
"savedValue": "Запазено, въведете за промяна",
|
||||
"addHeader": "Добави хедър",
|
||||
"removeHeader": "Премахни хедър",
|
||||
"basicAuthTitle": "Потребителско име и парола",
|
||||
"basicAuthHint": "За прокси, който изисква потребителско име и парола преди Termix.",
|
||||
"username": "Потребителско име",
|
||||
"password": "Парола",
|
||||
"allowInvalidCertificate": "Да доверя самоподписан сертификат",
|
||||
"allowInvalidCertificateHint": "Само за вашия собствен сървър, когато сертификатът му не е от известен орган."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "Това не изглежда като уеб адрес.",
|
||||
"unreachable": "Не може да се свърже със сървъра. Проверете адреса и дали сървърът работи.",
|
||||
"unreachableWithReason": "Не може да се свърже със сървъра: {{reason}}",
|
||||
"tlsUntrusted": "Сертификатът на сървъра не е доверен. Ако това е ваш собствен сървър със самоподписан сертификат, включете опцията по-долу.",
|
||||
"basicAuthRequired": "Някакво устройство пред сървъра изисква потребителско име и парола. Въведете ги по-долу.",
|
||||
"proxyLogin": "Страница за вход или прокси отговори вместо Termix. Добавете необходимите хедъри по-долу или проверете адреса.",
|
||||
"notTermix": "На този адрес няма Termix сървър. Ако работи под път, включете го, например https://example.com/termix.",
|
||||
"versionMismatch": "Този сървър използва по-стара версия на Termix. Актуализирайте го до 2.9 или по-нова, за да го свържете.",
|
||||
"differentAccount": "Влязохте в различен акаунт. Влезте в акаунта, към който е свързано това устройство, или първо го разкачете."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Свързване с Termix сървър",
|
||||
"reloginTitle": "Влезте отново",
|
||||
"savedServers": "Последни сървъри",
|
||||
"removeServer": "Премахване от списъка",
|
||||
"serverDescription": "Въведете адреса, който използвате за отваряне на Termix в браузъра си.",
|
||||
"signinDescription": "Влезте в {{url}}",
|
||||
"chooseDescription": "Това устройство вече има данни. Какво да се случи с тях?",
|
||||
"finishingDescription": "Свързване на това устройство.",
|
||||
"serverUrl": "Адрес на сървъра",
|
||||
"serverUrlHint": "Например https://termix.example.com",
|
||||
"showProxySettings": "Настройки на обратния прокси",
|
||||
"hideProxySettings": "Скриване на настройките на обратния прокси",
|
||||
"continue": "Продължи",
|
||||
"back": "Назад",
|
||||
"mergeTitle": "Добави ги към моя акаунт",
|
||||
"mergeBody_one": "Елементът на това устройство се добавя към вашия акаунт. Елементите, които вече има акаунтът ви, остават непроменени на сървъра.",
|
||||
"mergeBody_other": "{{count}} елемента от това устройство се добавят към вашия акаунт. Елементите, които вече има акаунтът ви, остават непроменени на сървъра.",
|
||||
"replaceTitle": "Замени ги с моя акаунт",
|
||||
"replaceBody_one": "Елементът на това устройство се премахва, а устройството приема всичко от вашия акаунт. Хостове, запазени само на това устройство, остават.",
|
||||
"replaceBody_other": "{{count}} елемента от това устройство се премахват, а устройството приема всичко от вашия акаунт. Хостове, запазени само на това устройство, остават.",
|
||||
"link": "Свържи",
|
||||
"linking": "Свързване и стартиране на първата синхронизация...",
|
||||
"linked": "Свързано. Данните ви се синхронизират.",
|
||||
"signedInAgain": "Влезте. Синхронизацията работи отново."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Хостове",
|
||||
"sshCredentials": "Идентификационни данни",
|
||||
"sshFolders": "Папки",
|
||||
"userPreferences": "Предпочитания",
|
||||
"pluginUserSettings": "Настройки на плъгините",
|
||||
"sharedHosts": "Хостове, споделени с вас",
|
||||
"sharedCredentials": "Идентификационни данни, споделени с вас",
|
||||
"vaultProfiles": "Профили на Vault",
|
||||
"snippetFolders": "Папки със снипети",
|
||||
"snippets": "Снипети",
|
||||
"dashboardServiceLinks": "Връзки към услуги",
|
||||
"homepageItems": "Елементи на началната страница",
|
||||
"networkTopology": "Мрежова карта",
|
||||
"workspaces": "Работни пространства",
|
||||
"hostDefaults": "Стандартни настройки на хостовете",
|
||||
"hostDefaultsAdmin": "Стандартни настройки на хостовете на сървъра",
|
||||
"aiProviders": "AI доставчици",
|
||||
"homepageLayouts": "Разположения на началната страница",
|
||||
"accountProfile": "Профил на акаунта"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Папка {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "কিছু প্লাগইন পরিবর্তন করা যায়নি। আপনি পরে প্লাগইন ট্যাব থেকে এগুলো ঠিক করতে পারবেন। চালিয়ে যেতে পরবর্তী চাপুন।",
|
||||
"pluginsApplyFailed": "আপনার পছন্দ সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন, অথবা চালিয়ে যান এবং পরে প্লাগইন ট্যাব থেকে এগুলো পরিবর্তন করুন।",
|
||||
"pluginsLoadFailed": "প্লাগইন তালিকা লোড করা যায়নি। আপনি পরে প্লাগইন ট্যাব থেকে প্লাগইন বেছে নিতে পারবেন।",
|
||||
"presetTitle": "আপনি স্ক্রিনে কতটা দেখতে চান?",
|
||||
"presetIntro": "স্ক্রিনে কতটা দেখাবে তা বেছে নিন।",
|
||||
"presetChangeLater": "আপনি পরে সেটিংসে, ইন্টারফেস অংশে প্রিসেট পরিবর্তন বা যেকোনো একক সেটিং পরিবর্তন করতে পারবেন।",
|
||||
"appearanceTitle": "এটিকে আপনার নিজস্ব করুন",
|
||||
"appearanceIntro": "একটি থিম, একটি অ্যাকসেন্ট রঙ, একটি ফন্ট এবং আপনার ভাষা বেছে নিন। পরিবর্তনগুলো সাথে সাথে দেখা যাবে, এবং আপনি পরে সেটিংস থেকে এগুলো পরিবর্তন করতে পারবেন।",
|
||||
"securityTitle": "আপনার অ্যাকাউন্ট নিরাপদ করুন",
|
||||
"securityIntro": "আপনার অ্যাকাউন্টে একটি পাসকি বা অথেন্টিকেটর অ্যাপ যোগ করুন। আপনি এটি পরে সেটিংসে, নিরাপত্তা অংশেও করতে পারবেন।",
|
||||
"desktopTitle": "আপনি Termix কেভাবে ব্যবহার করতে চান?",
|
||||
"desktopBody": "আপনি যেকোনো সময় সাইডবারের সিঙ্ক থেকে এটি পরিবর্তন করতে পারবেন।",
|
||||
"desktopLocalTitle": "শুধুমাত্র এই ডিভাইসে",
|
||||
"desktopLocalBody": "সবকিছু এখানে সংরক্ষিত থাকে। সেটআপের কিছু নেই।",
|
||||
"desktopLinkTitle": "আমার Termix সার্ভারের সাথে",
|
||||
"desktopLinkBody": "প্রতিটি ডিভাইসে একই হোস্ট এবং সেটিংস ব্যবহার করতে আপনার সার্ভারে সাইন ইন করুন।",
|
||||
"desktopLinked": "{{server}} এর সাথে লিঙ্ক করা",
|
||||
"pluginsDocsLink": "প্রতিটির সম্পর্কে পড়ুন"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Termix {{version}} ইনস্টল করার জন্য প্রস্তুত",
|
||||
"readyHint": "এটি পরবর্তীবার Termix রিস্টার্ট করলে ইনস্টল হবে।"
|
||||
},
|
||||
"companion": {
|
||||
"close": "প্যানেল বন্ধ করুন",
|
||||
"moveSide": "প্যানেল সরান",
|
||||
"resize": "প্যানেলের আকার পরিবর্তন করুন"
|
||||
},
|
||||
"nav": {
|
||||
"home": "হোম",
|
||||
"terminal": "টার্মিনাল",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "হোস্টসমূহ",
|
||||
"credentials": "ক্রেডেনশিয়াল",
|
||||
"connections": "কানেকশন",
|
||||
"sync": "সিঙ্ক",
|
||||
"reconnectDisconnectedTerminals": "সব বিচ্ছিন্ন টার্মিনাল পুনরায় সংযোগ করুন",
|
||||
"reconnectingTerminals_one": "{{count}} টার্মিনাল পুনরায় সংযোগ করা হচ্ছে",
|
||||
"reconnectingTerminals_other": "{{count}} টার্মিনাল পুনরায় সংযোগ করা হচ্ছে",
|
||||
@@ -271,7 +279,10 @@
|
||||
"retry": "পুনরায় চেষ্টা করুন",
|
||||
"refresh": "রিফ্রেশ করুন",
|
||||
"optional": "ঐচ্ছিক",
|
||||
"downloadSample": "নমুনা ডাউনলোড করুন",
|
||||
"failedToDeleteHost": "{{name}} মুছে ফেলা যায়নি",
|
||||
"importSkipExisting": "ইমপোর্ট (বিদ্যমান উপেক্ষা করুন)",
|
||||
"importSSHConfig": "SSH কনফিগ থেকে ইমপোর্ট করুন",
|
||||
"connectionDetails": "কানেকশন বিবরণ",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -490,7 +501,10 @@
|
||||
"noFolder": "(কোনো ফোল্ডার নেই)",
|
||||
"deleteSelected": "মুছে ফেলুন",
|
||||
"exitSelection": "নির্বাচন থেকে বের হন",
|
||||
"importSkip": "আমদানি (বিদ্যমান উপেক্ষা করুন)",
|
||||
"importOverwrite": "আমদানি (অবমূল্যায়ন করুন)",
|
||||
"collapseBtn": "সংকুচিত করুন",
|
||||
"importExportBtn": "আমদানি / রপ্তানি",
|
||||
"hostStatusesRefreshed": "হোস্টের অবস্থা রিফ্রেশ করা হয়েছে",
|
||||
"failedToRefreshHosts": "হোস্ট রিফ্রেশ করতে ব্যর্থ হয়েছে",
|
||||
"movedHostTo": "{{host}} কে \"{{folder}}\" এ সরানো হয়েছে",
|
||||
@@ -545,9 +559,6 @@
|
||||
"deleteFolderConfirm_one": "\"{{name}}\" এবং এর {{count}}টি হোস্ট মুছে ফেলবেন? এটি পূর্বাবস্থায় ফেরানো যাবে না।",
|
||||
"deleteFolderConfirm_other": "\"{{name}}\" এবং এর {{count}}টি হোস্ট মুছে ফেলবেন? এটি পূর্বাবস্থায় ফেরানো যাবে না।",
|
||||
"failedToMoveHosts": "হোস্ট সরানো যায়নি",
|
||||
"managedBanner": "এই হোস্টটি {{plugin}} দ্বারা পরিচালিত হয়। সেখানে এটি পরিবর্তন করুন। আপনি এখনও এটি পিন এবং পুনর্বিন্যাস করতে পারেন।",
|
||||
"managedTooltip": "{{plugin}} দ্বারা পরিচালিত",
|
||||
"addBlocked": "হোস্টগুলো {{plugin}} দ্বারা পরিচালিত হয়, তাই এখানে নতুন হোস্ট যোগ করা যাবে না।",
|
||||
"failedToReorder": "পুনর্বিন্যাস করা যায়নি",
|
||||
"expandAll": "সব ফোল্ডার প্রসারিত করুন",
|
||||
"collapseAll": "সব ফোল্ডার সংকুচিত করুন",
|
||||
@@ -658,6 +669,35 @@
|
||||
"addCredentialBtn2": "ক্রেডেনশিয়াল যোগ করুন",
|
||||
"checkingHostStatuses": "হোস্টের অবস্থা যাচাই করা হচ্ছে...",
|
||||
"pinnedSection": "পিন করা",
|
||||
"hostsExported": "হোস্টগুলো সফলভাবে রপ্তানি করা হয়েছে",
|
||||
"export": {
|
||||
"menuItem": "রপ্তানি...",
|
||||
"title": "হোস্ট রপ্তানি করুন",
|
||||
"scope": "পরিসর",
|
||||
"scopeAll": "সব",
|
||||
"scopeSelected": "নির্বাচিত",
|
||||
"searchHosts": "হোস্ট খুঁজুন...",
|
||||
"include": "সহ অন্তর্ভুক্ত করুন",
|
||||
"groupConnection": "কানেকশন",
|
||||
"groupCredentials": "ক্রেডেনশিয়াল",
|
||||
"groupNotes": "নোট",
|
||||
"groupTags": "ট্যাগ ও পিন",
|
||||
"groupProxy": "প্রক্সি",
|
||||
"groupJumpHosts": "জাম্প হোস্ট",
|
||||
"groupFeatureFlags": "প্লাগইন সেটিংস",
|
||||
"groupAdvanced": "অ্যাডভান্সড কনফিগ",
|
||||
"preview": "পূর্বরূপ",
|
||||
"moreHosts_one": "... আরও {{count}}টি হোস্ট",
|
||||
"moreHosts_other": "... আরও {{count}}টি হোস্ট",
|
||||
"summary": "{{total}}টির মধ্যে {{selected}}টি হোস্ট",
|
||||
"credentialsIncluded": "ক্রেডেনশিয়াল অন্তর্ভুক্ত",
|
||||
"credentialsExcluded": "ক্রেডেনশিয়াল বাদ দেওয়া হয়েছে",
|
||||
"noneSelected": "কোনো হোস্ট নির্বাচন করা হয়নি",
|
||||
"cancel": "বাতিল",
|
||||
"confirm": "রপ্তানি",
|
||||
"fetchFailed": "রপ্তানির জন্য হোস্ট লোড করতে ব্যর্থ হয়েছে",
|
||||
"bulkButton": "রপ্তানি"
|
||||
},
|
||||
"sampleDownloaded": "নমুনা ফাইল ডাউনলোড হয়েছে",
|
||||
"failedToDeleteCredential2": "ক্রেডেনশিয়াল মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||
"noFolderOption": "(কোনো ফোল্ডার নেই)",
|
||||
@@ -820,7 +860,11 @@
|
||||
"editorUpdate": "হোস্ট আপডেট করুন",
|
||||
"editorAdd": "হোস্ট যোগ করুন",
|
||||
"speedSearch": "হোস্ট খুঁজুন। নির্বাচন করতে অ্যারো কী, সংযোগ করতে Enter, বন্ধ করতে Esc",
|
||||
"speedSearchList": "হোস্ট তালিকা। খুঁজতে টাইপ করুন অথবা / চাপুন।"
|
||||
"speedSearchList": "হোস্ট তালিকা। খুঁজতে টাইপ করুন অথবা / চাপুন।",
|
||||
"importNoHosts": "ফাইলে কোনো হোস্ট পাওয়া যায়নি",
|
||||
"importTooMany": "আপনি একসাথে সর্বোচ্চ {{max}} হোস্ট ইমপোর্ট করতে পারেন",
|
||||
"importSummary": "ইমপোর্ট সম্পন্ন: {{imported}} ইমপোর্ট করা হয়েছে, {{updated}} আপডেট করা হয়েছে, {{failed}} ব্যর্থ হয়েছে",
|
||||
"importHostsFailed": "হোস্ট ইমপোর্ট করতে ব্যর্থ হয়েছে"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "স্প্লিট স্ক্রিন",
|
||||
@@ -1071,57 +1115,17 @@
|
||||
"colorPickerTooltip": "কালার পিকার খুলুন",
|
||||
"themeSystem": "সিস্টেম",
|
||||
"themeLight": "লাইট",
|
||||
"themeDark": "ডার্ক"
|
||||
"themeDark": "ডার্ক",
|
||||
"themeDracula": "ড্রাকুলা",
|
||||
"themeCatppuccin": "ক্যাটপুচিন",
|
||||
"themeNord": "নর্ড",
|
||||
"themeSolarized": "সোলারাইজড",
|
||||
"themeTokyoNight": "টোকিও নাইট",
|
||||
"themeOneDark": "ওয়ান ডার্ক",
|
||||
"themeGruvbox": "গ্রুভবক্স"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "পুরো অ্যাপটি কীভাবে সাজানো থাকবে। থিম এবং লেআউট প্লাগইন থেকে আসে।",
|
||||
"defaultsLabel": "সবার জন্য ডিফল্ট",
|
||||
"defaultsHint": "ব্যবহারকারীরা তাদের নিজস্ব লেআউট বা থিম না বেছে নেওয়া পর্যন্ত যা দেখা যাবে।",
|
||||
"followDefault": "ডিফল্ট ব্যবহার করুন",
|
||||
"defaultSaved": "ডিফল্ট লুক সংরক্ষিত হয়েছে",
|
||||
"saveFailed": "লুক সংরক্ষণ করা যায়নি",
|
||||
"repair": {
|
||||
"title": "এই লেআউটটি প্রদর্শন করা যায়নি",
|
||||
"safeTitle": "সেফ মোড",
|
||||
"layout": "লেআউট",
|
||||
"plugin": "প্লাগইন",
|
||||
"reason": "কারণ",
|
||||
"error": "ত্রুটি",
|
||||
"noError": "কোনো ত্রুটি বার্তা নেই।",
|
||||
"copy": "কপি করুন",
|
||||
"copied": "কপি হয়েছে",
|
||||
"copyFailed": "বিস্তারিত কপি করা যায়নি",
|
||||
"retry": "আবার চেষ্টা করুন",
|
||||
"reset": "ডিফল্ট লুকে রিসেট করুন",
|
||||
"resetEveryone": "সবার জন্য ডিফল্ট রিসেট করুন",
|
||||
"update": "{{name}} কে {{version}} এ আপডেট করুন",
|
||||
"disable": "{{name}} বন্ধ করুন",
|
||||
"install": "লেআউট এবং থিম খুঁজুন",
|
||||
"installTitle": "লেআউট এবং থিম",
|
||||
"installButton": "ইনস্টল করুন",
|
||||
"nothingToInstall": "এখন অন্য কোনো লেআউট বা থিম উপলব্ধ নেই।",
|
||||
"registryUnreachable": "প্লাগইন রেজিস্ট্রিতে পৌঁছানো যায়নি।",
|
||||
"askAdmin": "এই লেআউটটি আপডেট বা ঠিক করতে একজন অ্যাডমিনকে অনুরোধ করুন। নিচে আপনি আপনার জন্য অন্য কোনো লেআউট বা থিম সিলেক্ট করতে পারেন।",
|
||||
"switchLayout": "লেআউট পরিবর্তন করুন",
|
||||
"switchTheme": "থিম পরিবর্তন করুন",
|
||||
"saveFailed": "আপনার পছন্দ সংরক্ষণ করা যায়নি",
|
||||
"reasons": {
|
||||
"safe-url": "ঠিকানাটি সেফ মোডের জন্য অনুরোধ করেছে। নিচে একটি লেআউট বেছে নিন, অথবা ঠিকানা থেকে ?safe-layout=1 সরিয়ে দিন।",
|
||||
"safe-env": "সার্ভারটি TERMIX_SAFE_LAYOUT দিয়ে চালু হয়েছে, তাই এটি সরানো না হওয়া পর্যন্ত সবাই এই স্ক্রিন দেখবে।",
|
||||
"missing": "কোনো ইনস্টল করা প্লাগইন এই লেআউট প্রদান করে না।",
|
||||
"disabled": "এই লেআউট প্রদানকারী প্লাগইনটি বন্ধ আছে।",
|
||||
"blocked": "এই লেআউট প্রদানকারী প্লাগইনটি তার প্রয়োজনীয় কিছু অপেক্ষা করছে।",
|
||||
"incompatible": "এই লেআউট প্রদানকারী প্লাগইনটি Termix-এর এই সংস্করণের সাথে কাজ করে না।",
|
||||
"failed": "লেআউটটি চালু হতে ব্যর্থ হয়েছে।",
|
||||
"crashed": "অ্যাপটি আঁকতে গিয়ে লেআউটটি দুবার ক্র্যাশ করেছে।"
|
||||
},
|
||||
"noLayout": "কোনো লেআউট সেট করা নেই",
|
||||
"restore": "বান্ডল করা {{name}} পুনরুদ্ধার করুন",
|
||||
"restoreFailed": "বান্ডল করা কপি পুনরুদ্ধার করা যায়নি"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1144,18 +1148,6 @@
|
||||
"title": "সাইন ইন পরিচালনা করুন",
|
||||
"consequence": "এটি একটি লগইন পদ্ধতি, দ্বিতীয় ফ্যাক্টর বা SSH সাইন-ইন টাইপ যোগ করতে পারে।"
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "সাইন ইন নিয়ম সেট করুন",
|
||||
"consequence": "এটি অবসর অবস্থায় ব্যবহারকারীদের সাইন আউট করতে পারে, সেশনের দৈর্ঘ্য সীমিত করতে পারে, 'মনে রাখো' বন্ধ করতে পারে এবং দ্বিতীয় ফ্যাক্টর প্রয়োজন করতে পারে।"
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "হোস্টগুলো কীভাবে পৌঁছানো হবে তা বেছে নিন",
|
||||
"consequence": "এটি হোস্টের সাথে SSH সংযোগটি তার নিজস্ব রুটের মাধ্যমে বহন করতে পারে, তাই এটি সেই হোস্টগুলোর সাথে যাওয়া-আসা করা সবকিছু দেখতে পায়, পাসওয়ার্ড সহ।"
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "সিঙ্ক চালান",
|
||||
"consequence": "এটি সমস্ত সিঙ্ক অনুরোধের উত্তর দিতে পারে, তাই এটি ডিভাইস এবং সার্ভার যে ডেটা সিঙ্ক করে তা দেখতে পায়।"
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "সার্টিফিকেট পরিচালনা করুন",
|
||||
"consequence": "এটি HTTPS এর মাধ্যমে Termix যে সার্টিফিকেট পরিবেশন করে তা প্রতিস্থাপন করতে পারে এবং সার্টিফিকেট কর্তৃপক্ষ থেকে ডোমেইন চেকের উত্তর দিতে পারে।"
|
||||
@@ -1164,10 +1156,6 @@
|
||||
"title": "সিরিয়াল এবং USB ডিভাইসে অ্যাক্সেস",
|
||||
"consequence": "এটি Termix চালু মেশিনে একটি শারীরিক সিরিয়াল পোর্ট খুলতে পারে।"
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "তাদের গোপন তথ্যসহ হোস্ট রপ্তানি করুন",
|
||||
"consequence": "সংরক্ষিত পাসওয়ার্ড এবং কী সহ আপনার হোস্টগুলোর সম্পূর্ণ তথ্য পড়তে পারে এবং তা প্রদান করতে পারে। প্রতিটি রপ্তানি অডিট লগে লেখা হয়।"
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "হোস্ট তৈরি এবং পরিবর্তন করুন",
|
||||
"consequence": "এটি নতুন হোস্ট যোগ করতে এবং আপনার বিদ্যমান হোস্ট সম্পাদনা করতে পারে।"
|
||||
@@ -1212,30 +1200,10 @@
|
||||
"title": "অডিট লগ পড়ুন",
|
||||
"consequence": "এটি দেখতে পারে কে কী করেছে এবং কখন।"
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "পোর্টের উত্তর আসছে কিনা তা পরীক্ষা করুন",
|
||||
"consequence": "সার্ভার থেকে যেকোনো ঠিকানায় সাধারণ সংযোগ খুলতে পারে, এটি পৌঁছানো যায় কিনা তা দেখার জন্য।"
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "হোস্টের অবস্থা প্রদান করুন",
|
||||
"consequence": "প্রতিটি হোস্টের অনলাইন এবং অফলাইন ডটের উৎস হয়ে ওঠে।"
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "সাম্প্রতিক কার্যকলাপ সংরক্ষণ করুন",
|
||||
"consequence": "প্রতিটি ব্যবহারকারীর সাম্প্রতিক কার্যকলাপের তালিকার জন্য এটি স্টোর হিসেবে কাজ করে।"
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "হোস্টগুলোকে রিড-অনলি হিসেবে পরিচালনা করুন",
|
||||
"consequence": "এটি পরিচালিত হোস্টগুলোকে অন্য কেউ সম্পাদনা, মুছে ফেলা বা পুনরায় শেয়ার করতে পারবে না।"
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "ডেস্কটপ উইন্ডো খুলুন",
|
||||
"consequence": "শুধুমাত্র Termix ডেস্কটপ অ্যাপে।"
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "এই কম্পিউটারটি ব্যবহার করুন",
|
||||
"consequence": "এই কম্পিউটারে ফাইল পড়া ও পরিবর্তন করা, শেল চালানো এবং স্থানীয় পোর্ট খোলা যায়। শুধুমাত্র Termix ডেস্কটপ অ্যাপে।"
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "অন্য ব্যবহারকারী হিসেবে কাজ করুন",
|
||||
"consequence": "অন্য ব্যবহারকারীর অ্যাক্সেস দিয়ে ব্যাকগ্রাউন্ড কাজ চালায়, যেমন নির্ধারিত জব। প্রতিটি ব্যবহার অডিট লগে লেখা হয়।"
|
||||
@@ -1275,14 +1243,6 @@
|
||||
"ui:surface": {
|
||||
"title": "নিজের স্ক্রিন যোগ করুন",
|
||||
"consequence": "ট্যাব, প্যানেল এবং সেটিংস যা আপনি পরে লুকিয়ে রাখতে পারেন।"
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "পুরো অ্যাপ লেআউট প্রতিস্থাপন করুন",
|
||||
"consequence": "নির্বাচন করলে, এর কোড প্রতিটি স্ক্রিন আঁকে, আপনার টার্মিনাল এবং সেটিংসসহ।"
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "রঙের থিম যোগ করুন",
|
||||
"consequence": "শুধুমাত্র রঙ এবং স্টাইল। কোনো থিমের জন্য কোড চলে না।"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1412,7 +1372,6 @@
|
||||
"options": "{{name}} এর জন্য অপশন সেভ করা যায়নি: {{error}}",
|
||||
"deleteData": "{{name}} এর ডেটা মুছে ফেলা যায়নি: {{error}}",
|
||||
"managedByServer": "এই ডেস্কটপটি একটি সার্ভারের সাথে লিঙ্ক করা, যা এর প্লাগইন নির্ধারণ করে।",
|
||||
"defaultLook": "{{name}} ডিফল্ট লুক, তাই এটি বন্ধ বা পরিবর্তন করা যায় কিন্তু আনইনস্টল করা যায় না।",
|
||||
"hasDependents": "{{name}} এর প্রয়োজন {{list}} এর। আগে সেগুলো বন্ধ করুন বা আনইনস্টল করুন।",
|
||||
"missingDependencies": "{{name}} এর প্রয়োজন {{list}}, যা ইনস্টল করা নেই।",
|
||||
"consentChanged": "আপনি দেখার পর থেকে এই প্লাগইনটি যা চায় তা পরিবর্তন হয়েছে। তালিকাটি আবার চেক করুন।",
|
||||
@@ -1482,6 +1441,7 @@
|
||||
"noServerAccess": "আপনার সার্ভারগুলোর কোনো অ্যাক্সেস নেই",
|
||||
"searchInstalled": "ইনস্টল করা প্লাগইন খুঁজুন",
|
||||
"searchPlugins": "প্লাগইন খুঁজুন",
|
||||
"allCategories": "সব",
|
||||
"nothingMatches": "কিছুই মিলছে না।",
|
||||
"noneInstalled": "এখনো কোনো প্লাগইন ইনস্টল করা হয়নি। কিছু যোগ করতে ব্রাউজ করুন।",
|
||||
"nothingHere": "এখানে কিছু নেই।",
|
||||
@@ -1571,44 +1531,7 @@
|
||||
"documentation": "ডকুমেন্টেশন",
|
||||
"envVars": "এনভায়রনমেন্ট ভেরিয়েবল",
|
||||
"envRequired": "প্রয়োজনীয়",
|
||||
"envDefault": "ডিফল্ট: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "লেআউট",
|
||||
"themes": "থিম",
|
||||
"tabs": "ট্যাব",
|
||||
"panels": "সাইডবার প্যানেল",
|
||||
"dashboardCards": "ড্যাশবোর্ড কার্ড",
|
||||
"protocols": "কানেকশন টাইপ",
|
||||
"auth": "সাইন ইন পদ্ধতি",
|
||||
"actions": "অ্যাকশন",
|
||||
"hostCapability": "হোস্ট সেটিংস"
|
||||
},
|
||||
"filters": {
|
||||
"type": "টাইপ",
|
||||
"allTypes": "সব টাইপ",
|
||||
"category": "ক্যাটাগরি",
|
||||
"allCategories": "সব ক্যাটাগরি",
|
||||
"results_one": "{{count}} প্লাগইন",
|
||||
"results_other": "{{count}} প্লাগইন",
|
||||
"remove": "ফিল্টার {{name}} সরান",
|
||||
"clear": "ফিল্টার মুছুন",
|
||||
"button": "ফিল্টার",
|
||||
"buttonCount": "ফিল্টার ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{total}} এর মধ্যে {{from}}-{{to}}",
|
||||
"page": "{{pages}} এর মধ্যে পৃষ্ঠা {{page}}",
|
||||
"previous": "পূর্ববর্তী পৃষ্ঠা",
|
||||
"next": "পরবর্তী পৃষ্ঠা",
|
||||
"perPage": "প্রতি পৃষ্ঠায় {{count}}",
|
||||
"perPageLabel": "প্রতি পৃষ্ঠায় প্লাগইন"
|
||||
},
|
||||
"showUnsupportedHint": "এগুলো তালিকাভুক্ত আছে কিন্তু এই ভার্সনে এখনও কোনো রিলিজ চলে না। এগুলো ইনস্টল করতে Termix আপডেট করুন।",
|
||||
"unsupported": "নতুন Termix প্রয়োজন",
|
||||
"unsupportedHint": "এই প্লাগইনের কোনো রিলিজ এই Termix ভার্সনে চলে না। এটি ইনস্টল করতে Termix আপডেট করুন।",
|
||||
"needsTermix": "Termix {{range}} প্রয়োজন",
|
||||
"showUnsupported": "অসমর্থিত দেখান ({{count}})",
|
||||
"showMore": "{{count}} আরও দেখান ({{total}} বাকি)"
|
||||
"envDefault": "ডিফল্ট: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1624,6 +1547,87 @@
|
||||
"noData": "কোনো তথ্য নেই",
|
||||
"filter": "ফিল্টার..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "{{count}} মিনিট আগে",
|
||||
"minutesAgo_other": "{{count}} মিনিট আগে",
|
||||
"hoursAgo_one": "{{count}} ঘণ্টা আগে",
|
||||
"hoursAgo_other": "{{count}} ঘণ্টা আগে",
|
||||
"pending_one": "{{count}} পরিবর্তন সিঙ্ক হওয়ার অপেক্ষায়",
|
||||
"pending_other": "{{count}} পরিবর্তন সিঙ্ক হওয়ার অপেক্ষায়",
|
||||
"proxy": {
|
||||
"headersTitle": "অতিরিক্ত হেডার",
|
||||
"headersHint": "সার্ভারে প্রতিটি অনুরোধের সাথে পাঠানো হয়, যেমন একটি Cloudflare Access সার্ভিস টোকেন।",
|
||||
"headerName": "হেডারের নাম",
|
||||
"headerValue": "মান",
|
||||
"savedValue": "সংরক্ষিত, পরিবর্তন করতে টাইপ করুন",
|
||||
"addHeader": "হেডার যোগ করুন",
|
||||
"removeHeader": "হেডার সরান",
|
||||
"basicAuthTitle": "ইউজারনেম এবং পাসওয়ার্ড",
|
||||
"basicAuthHint": "একটি প্রক্সির জন্য যা Termix-এর আগে ইউজারনেম এবং পাসওয়ার্ড চায়।",
|
||||
"username": "ইউজারনেম",
|
||||
"password": "পাসওয়ার্ড",
|
||||
"allowInvalidCertificate": "সেলফ-সাইনড সার্টিফিকেটকে বিশ্বাস করুন",
|
||||
"allowInvalidCertificateHint": "শুধুমাত্র আপনার নিজস্ব সার্ভারের জন্য, যখন এর সার্টিফিকেট কোনো পরিচিত কর্তৃপক্ষ থেকে না আসে।"
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "এটি একটি ওয়েব ঠিকানার মতো দেখাচ্ছে না।",
|
||||
"unreachable": "সার্ভারে সংযোগ করা যাচ্ছে না। ঠিকানাটি এবং সার্ভারটি চালু আছে কিনা তা পরীক্ষা করুন।",
|
||||
"unreachableWithReason": "সার্ভারে সংযোগ করা যাচ্ছে না: {{reason}}",
|
||||
"tlsUntrusted": "সার্ভার সার্টিফিকেটটি বিশ্বস্ত নয়। যদি এটি আপনার নিজস্ব সার্ভার হয় এবং এতে সেলফ-সাইন্ড সার্টিফিকেট থাকে, তবে নিচের অপশনটি চালু করুন।",
|
||||
"basicAuthRequired": "সার্ভারের সামনে থাকা কোনো কিছু ব্যবহারকারীর নাম এবং পাসওয়ার্ড চাইছে। সেগুলো নিচে লিখুন।",
|
||||
"proxyLogin": "টার্মিক্সের পরিবর্তে একটি লগইন পেজ বা প্রক্সি উত্তর দিয়েছে। নিচে প্রয়োজনীয় হেডারগুলো যোগ করুন, অথবা ঠিকানাটি পরীক্ষা করুন।",
|
||||
"notTermix": "এই ঠিকানায় কোনো টার্মিক্স সার্ভার নেই। যদি এটি কোনো পাথে চলছে, তবে তা অন্তর্ভুক্ত করুন, যেমন https://example.com/termix।",
|
||||
"versionMismatch": "সেই সার্ভারে পুরনো টার্মিক্স চলছে। লিংক করতে এটি ২.৯ বা তার নতুনতর সংস্করণে আপডেট করুন।",
|
||||
"differentAccount": "আপনি একটি ভিন্ন অ্যাকাউন্টে সাইন ইন করেছেন। এই ডিভাইসটি যে অ্যাকাউন্টের সাথে লিংক করা আছে, সেটিতে সাইন ইন করুন, অথবা আগে আনলিংক করুন।"
|
||||
},
|
||||
"wizard": {
|
||||
"title": "একটি টার্মিক্স সার্ভারের সাথে লিংক করুন",
|
||||
"reloginTitle": "আবার সাইন ইন করুন",
|
||||
"savedServers": "সাম্প্রতিক সার্ভারগুলো",
|
||||
"removeServer": "তালিকা থেকে সরান",
|
||||
"serverDescription": "আপনার ব্রাউজারে টার্মিক্স খোলার জন্য যে ঠিকানাটি ব্যবহার করেন তা লিখুন।",
|
||||
"signinDescription": "{{url}} এ সাইন ইন করুন",
|
||||
"chooseDescription": "এই ডিভাইসে ইতিমধ্যে ডেটা আছে। এটি নিয়ে কী করা উচিত?",
|
||||
"finishingDescription": "এই ডিভাইসটি লিংক করা হচ্ছে।",
|
||||
"serverUrl": "সার্ভার ঠিকানা",
|
||||
"serverUrlHint": "উদাহরণস্বরূপ https://termix.example.com",
|
||||
"showProxySettings": "রিভার্স প্রক্সি সেটিংস",
|
||||
"hideProxySettings": "রিভার্স প্রক্সি সেটিংস লুকান",
|
||||
"continue": "চালিয়ে যান",
|
||||
"back": "পিছনে যান",
|
||||
"mergeTitle": "এটি আমার অ্যাকাউন্টে যোগ করুন",
|
||||
"mergeBody_one": "এই ডিভাইসের আইটেমটি আপনার অ্যাকাউন্টে যোগ করা হবে। আপনার অ্যাকাউন্টে ইতিমধ্যে থাকা আইটেমগুলো সার্ভারে তাদের অবস্থানেই থাকবে।",
|
||||
"mergeBody_other": "এই ডিভাইসের {{count}}টি আইটেম আপনার অ্যাকাউন্টে যোগ করা হবে। আপনার অ্যাকাউন্টে ইতিমধ্যে থাকা আইটেমগুলো সার্ভারে তাদের অবস্থানেই থাকবে।",
|
||||
"replaceTitle": "এটি আমার অ্যাকাউন্ট দিয়ে প্রতিস্থাপন করুন",
|
||||
"replaceBody_one": "এই ডিভাইসের আইটেমটি সরানো হবে, এবং এই ডিভাইসটি আপনার অ্যাকাউন্ট থেকে সবকিছু নেবে। শুধুমাত্র এই ডিভাইসে রাখা হোস্টগুলোই থাকবে।",
|
||||
"replaceBody_other": "এই ডিভাইসের {{count}}টি আইটেম সরানো হবে, এবং এই ডিভাইসটি আপনার অ্যাকাউন্ট থেকে সবকিছু নেবে। শুধুমাত্র এই ডিভাইসে রাখা হোস্টগুলোই থাকবে।",
|
||||
"link": "লিংক করুন",
|
||||
"linking": "লিংক করা হচ্ছে এবং প্রথম সিঙ্ক চালানো হচ্ছে...",
|
||||
"linked": "লিংক করা হয়েছে। আপনার ডেটা এখন সিঙ্ক হচ্ছে।",
|
||||
"signedInAgain": "সাইন ইন করা হয়েছে। সিঙ্ক আবার চলছে।"
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "হোস্ট",
|
||||
"sshCredentials": "ক্রেডেনশিয়াল",
|
||||
"sshFolders": "ফোল্ডার",
|
||||
"userPreferences": "পছন্দ",
|
||||
"pluginUserSettings": "প্লাগইন সেটিংস",
|
||||
"sharedHosts": "আপনার সাথে শেয়ার করা হোস্ট",
|
||||
"sharedCredentials": "আপনার সাথে শেয়ার করা ক্রেডেনশিয়াল",
|
||||
"vaultProfiles": "Vault প্রোফাইল",
|
||||
"snippetFolders": "স্নিপেট ফোল্ডার",
|
||||
"snippets": "স্নিপেট",
|
||||
"dashboardServiceLinks": "সার্ভিস লিঙ্ক",
|
||||
"homepageItems": "হোমপেজ আইটেম",
|
||||
"networkTopology": "নেটওয়ার্ক ম্যাপ",
|
||||
"workspaces": "ওয়ার্কস্পেস",
|
||||
"hostDefaults": "হোস্ট ডিফল্ট",
|
||||
"hostDefaultsAdmin": "সার্ভার হোস্ট ডিফল্ট",
|
||||
"aiProviders": "AI প্রোভাইডার",
|
||||
"homepageLayouts": "হোমপেজ লেআউট",
|
||||
"accountProfile": "অ্যাকাউন্ট প্রোফাইল"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "ফোল্ডার {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "No s'han pogut canviar alguns complements. Pots corregir-ho més tard des de la pestanya de complements. Prem Següent per continuar.",
|
||||
"pluginsApplyFailed": "No s'han pogut desar les teves eleccions. Torna-ho a provar o continua i canvia-les més tard des de la pestanya de complements.",
|
||||
"pluginsLoadFailed": "No s'ha pogut carregar la llista de complements. Pots triar complements més tard des de la pestanya de complements.",
|
||||
"presetTitle": "Quant de contingut vols a la pantalla?",
|
||||
"presetIntro": "Tria quant de contingut es mostra a la pantalla.",
|
||||
"presetChangeLater": "Pots canviar els predefinits o qualsevol configuració individual més tard a Configuració, sota Interfície.",
|
||||
"appearanceTitle": "Fes-ho teu",
|
||||
"appearanceIntro": "Tria un tema, un color d'accentuació, una tipografia i la teva llengua. Els canvis es mostren immediatament i els pots modificar més tard a Configuració.",
|
||||
"securityTitle": "Seguretat del teu compte",
|
||||
"securityIntro": "Afegeix una clau de pas o una aplicació d'autenticació al teu compte. També pots fer-ho més tard a Configuració, sota Seguretat.",
|
||||
"desktopTitle": "Com vols fer servir Termix?",
|
||||
"desktopBody": "Pots canviar-ho en qualsevol moment des de Sincronització a la barra lateral.",
|
||||
"desktopLocalTitle": "Només en aquest dispositiu",
|
||||
"desktopLocalBody": "Tot s'emmagatzema aquí. No cal configurar res.",
|
||||
"desktopLinkTitle": "Amb el meu servidor Termix",
|
||||
"desktopLinkBody": "Inicia la sessió al teu servidor per fer servir els mateixos amfitrions i configuracions en tots els dispositius.",
|
||||
"desktopLinked": "Vinculat a {{server}}",
|
||||
"pluginsDocsLink": "Llegeix sobre cadascun"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Termix {{version}} està llest per instal·lar-se",
|
||||
"readyHint": "S'instal·larà la propera vegada que reiniciïs Termix."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Tanca el panell",
|
||||
"moveSide": "Mou el panell",
|
||||
"resize": "Redimensiona el panell"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inici",
|
||||
"terminal": "Terminal",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "Amfitrions",
|
||||
"credentials": "Credencials",
|
||||
"connections": "Connexions",
|
||||
"sync": "Sincronitzar",
|
||||
"reconnectDisconnectedTerminals": "Tornar a connectar tots els terminals desconnectats",
|
||||
"reconnectingTerminals_one": "Tornant a connectar {{count}} terminal",
|
||||
"reconnectingTerminals_many": "Tornant a connectar {{count}} terminals",
|
||||
@@ -274,7 +282,10 @@
|
||||
"retry": "Tornar-ho a provar",
|
||||
"refresh": "Actualitzar",
|
||||
"optional": "Opcional",
|
||||
"downloadSample": "Descarregar l'exemple",
|
||||
"failedToDeleteHost": "No s'ha pogut eliminar {{name}}",
|
||||
"importSkipExisting": "Importar (ometre els existents)",
|
||||
"importSSHConfig": "Importar des de la configuració SSH",
|
||||
"connectionDetails": "Detalls de la connexió",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -494,7 +505,10 @@
|
||||
"noFolder": "(Sense carpeta)",
|
||||
"deleteSelected": "Suprimeix",
|
||||
"exitSelection": "Sorteix de la selecció",
|
||||
"importSkip": "Importa (omet els existents)",
|
||||
"importOverwrite": "Importa (sobreescriu)",
|
||||
"collapseBtn": "Replega",
|
||||
"importExportBtn": "Importa / Exporta",
|
||||
"hostStatusesRefreshed": "Estat dels hosts actualitzat",
|
||||
"failedToRefreshHosts": "No s'han pogut actualitzar els hosts",
|
||||
"movedHostTo": "S'ha mogut {{host}} a «{{folder}}»",
|
||||
@@ -551,9 +565,6 @@
|
||||
"deleteFolderConfirm_many": "Voleu suprimir «{{name}}» i els seus {{count}} amfitrions? Aquesta acció no es pot desfer.",
|
||||
"deleteFolderConfirm_other": "Voleu suprimir «{{name}}» i el seu {{count}} amfitrió? Aquesta acció no es pot desfer.",
|
||||
"failedToMoveHosts": "No s'han pogut moure els amfitrions",
|
||||
"managedBanner": "Aquest host està gestionat per {{plugin}}. Fes-hi els canvis allà. Encara pots fixar-lo i reordenar-lo.",
|
||||
"managedTooltip": "Gestionat per {{plugin}}",
|
||||
"addBlocked": "Els hosts estan gestionats per {{plugin}}, per tant no es poden afegir hosts nous aquí.",
|
||||
"failedToReorder": "No s'ha pogut reordenar",
|
||||
"expandAll": "Expandeix totes les carpetes",
|
||||
"collapseAll": "Redueix totes les carpetes",
|
||||
@@ -665,6 +676,36 @@
|
||||
"addCredentialBtn2": "Afegeix una credencial",
|
||||
"checkingHostStatuses": "Comprovant l'estat dels hosts...",
|
||||
"pinnedSection": "Fixats",
|
||||
"hostsExported": "Hosts exportats correctament",
|
||||
"export": {
|
||||
"menuItem": "Exporta...",
|
||||
"title": "Exporta hosts",
|
||||
"scope": "Àmbit",
|
||||
"scopeAll": "Tots",
|
||||
"scopeSelected": "Seleccionats",
|
||||
"searchHosts": "Cerca hosts...",
|
||||
"include": "Inclou",
|
||||
"groupConnection": "Connexió",
|
||||
"groupCredentials": "Credencials",
|
||||
"groupNotes": "Notes",
|
||||
"groupTags": "Etiquetes i fixat",
|
||||
"groupProxy": "Proxy",
|
||||
"groupJumpHosts": "Hosts de salt",
|
||||
"groupFeatureFlags": "Configuració dels plugins",
|
||||
"groupAdvanced": "Configuració avançada",
|
||||
"preview": "Vista prèvia",
|
||||
"moreHosts_one": "... {{count}} amfitrió més",
|
||||
"moreHosts_many": "... {{count}} amfitrions més",
|
||||
"moreHosts_other": "... {{count}} amfitrió més",
|
||||
"summary": "{{selected}} de {{total}} hosts",
|
||||
"credentialsIncluded": "credencials incloses",
|
||||
"credentialsExcluded": "credencials excloses",
|
||||
"noneSelected": "Cap host seleccionat",
|
||||
"cancel": "Cancel·la",
|
||||
"confirm": "Exporta",
|
||||
"fetchFailed": "No s'han pogut carregar els hosts per a l'exportació",
|
||||
"bulkButton": "Exporta"
|
||||
},
|
||||
"sampleDownloaded": "Fitxer d'exemple descarregat",
|
||||
"failedToDeleteCredential2": "No s'ha pogut eliminar la credencial",
|
||||
"noFolderOption": "(Sense carpeta)",
|
||||
@@ -832,7 +873,11 @@
|
||||
"editorUpdate": "Actualitzar host",
|
||||
"editorAdd": "Afegir host",
|
||||
"speedSearch": "Cercar hosts. Fletxes per seleccionar, Retorn per connectar, Esc per tancar",
|
||||
"speedSearchList": "Llista d'hosts. Escriviu o premeu / per cercar."
|
||||
"speedSearchList": "Llista d'hosts. Escriviu o premeu / per cercar.",
|
||||
"importNoHosts": "No s'han trobat hosts al fitxer",
|
||||
"importTooMany": "Podeu importar fins a {{max}} hosts alhora",
|
||||
"importSummary": "Importació completada: {{imported}} importats, {{updated}} actualitzats, {{failed}} fallits",
|
||||
"importHostsFailed": "No s'han pogut importar els hosts"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Pantalla dividida",
|
||||
@@ -1034,9 +1079,13 @@
|
||||
},
|
||||
"admin": {
|
||||
"usersCount_one": "{{count}} usuari",
|
||||
"usersCount_many": "{{count}} usuaris",
|
||||
"rolesCount_one": "{{count}} rol",
|
||||
"rolesCount_many": "{{count}} rols",
|
||||
"apiKeysCount_one": "{{count}} clau",
|
||||
"apiKeysCount_many": "{{count}} claus",
|
||||
"sessionsActive_one": "{{count}} actiu",
|
||||
"sessionsActive_many": "{{count}} actius",
|
||||
"hostsCount_one": "{{count}} amfitrió",
|
||||
"hostsCount_many": "{{count}} amfitrions",
|
||||
"hostsCount_other": "{{count}} amfitrió",
|
||||
@@ -1045,6 +1094,7 @@
|
||||
"credentialsCount_other": "{{count}} credencial",
|
||||
"rolePermissions": {
|
||||
"count_one": "{{count}} permís",
|
||||
"count_many": "{{count}} permisos",
|
||||
"groups": {
|
||||
"hosts": "Amfitrions",
|
||||
"credentials": "Credencials",
|
||||
@@ -1085,60 +1135,21 @@
|
||||
"interfaceFontDescription": "Canvia la font de la interfície de l'aplicació. Els perfils de terminal conserven la seva pròpia configuració de font.",
|
||||
"accentColorLabel": "Color d'accent",
|
||||
"apiKeyCount_one": "{{count}} clau",
|
||||
"apiKeyCount_many": "{{count}} claus",
|
||||
"colorPickerTooltip": "Obre el selector de color",
|
||||
"themeSystem": "Sistema",
|
||||
"themeLight": "Clar",
|
||||
"themeDark": "Fosc"
|
||||
"themeDark": "Fosc",
|
||||
"themeDracula": "Dracula",
|
||||
"themeCatppuccin": "Catppuccin",
|
||||
"themeNord": "Nord",
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "Com s'organitza tota l'aplicació. Els temes i els dissenys provenen de connectors.",
|
||||
"defaultsLabel": "Predeterminat per a tothom",
|
||||
"defaultsHint": "El que veuran les persones fins que triïn el seu propi disseny o tema.",
|
||||
"followDefault": "Utilitza el predeterminat",
|
||||
"defaultSaved": "Aparença predeterminada desada",
|
||||
"saveFailed": "No s'ha pogut desar l'aparença",
|
||||
"repair": {
|
||||
"title": "No s'ha pogut mostrar aquest disseny",
|
||||
"safeTitle": "Mode segur",
|
||||
"layout": "Disseny",
|
||||
"plugin": "Connector",
|
||||
"reason": "Motiu",
|
||||
"error": "Error",
|
||||
"noError": "No hi ha missatge d'error.",
|
||||
"copy": "Copia",
|
||||
"copied": "Copiat",
|
||||
"copyFailed": "No s'han pogut copiar els detalls",
|
||||
"retry": "Torna-ho a provar",
|
||||
"reset": "Restableix l'aparença predeterminada",
|
||||
"resetEveryone": "Restableix el predeterminat per a tothom",
|
||||
"update": "Actualitza {{name}} a {{version}}",
|
||||
"disable": "Desactiva {{name}}",
|
||||
"install": "Cerca dissenys i temes",
|
||||
"installTitle": "Disseny i temes",
|
||||
"installButton": "Instal·la",
|
||||
"nothingToInstall": "Actualment no hi ha altres dissenys o temes disponibles.",
|
||||
"registryUnreachable": "No s'ha pogut accedir al registre de connectors.",
|
||||
"askAdmin": "Demana a un administrador que actualitzi o corregeixi aquest disseny. A continuació pots canviar a un altre disseny o tema per a tu mateix.",
|
||||
"switchLayout": "Canvia el disseny",
|
||||
"switchTheme": "Canvia el tema",
|
||||
"saveFailed": "No s'ha pogut desar la teva elecció",
|
||||
"reasons": {
|
||||
"safe-url": "L'adreça ha sol·licitat el mode segur. Tria un disseny a continuació o elimina ?safe-layout=1 de l'adreça.",
|
||||
"safe-env": "El servidor s'ha iniciat amb TERMIX_SAFE_LAYOUT, per tant tothom veurà aquesta pantalla fins que s'elimini.",
|
||||
"missing": "Cap connector instal·lat proporciona aquest disseny.",
|
||||
"disabled": "El connector que proporciona aquest disseny està desactivat.",
|
||||
"blocked": "El connector que proporciona aquest disseny està esperant alguna cosa que necessita.",
|
||||
"incompatible": "El connector que proporciona aquest disseny no funciona amb aquesta versió de Termix.",
|
||||
"failed": "El disseny no s'ha pogut iniciar.",
|
||||
"crashed": "El disseny s'ha bloquejat dues vegades mentre dibuixava l'aplicació."
|
||||
},
|
||||
"noLayout": "No s'ha configurat cap disseny",
|
||||
"restore": "Restaura {{name}} inclòs",
|
||||
"restoreFailed": "No s'ha pogut restaurar la còpia inclosa"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1161,18 +1172,6 @@
|
||||
"title": "Gestionar l'inici de sessió",
|
||||
"consequence": "Pot afegir un mètode d'inici de sessió, un segon factor o un tipus d'inici de sessió SSH."
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "Estableix les regles d'inici de sessió",
|
||||
"consequence": "Pot tancar la sessió de les persones quan estan inactives, limitar la durada de la sessió, desactivar l'opció «Recorda'm» i exigir un segon factor."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Tria com s'hi accedeix als hosts",
|
||||
"consequence": "Pot transportar la connexió SSH a un host a través de la seva pròpia ruta, de manera que veu tot el que s'envia i rep d'aquests hosts, incloses les contrasenyes."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Executa la sincronització",
|
||||
"consequence": "Pot respondre a totes les sol·licituds de sincronització, de manera que veu les dades que sincronitzen els dispositius i els servidors."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Gestionar certificats",
|
||||
"consequence": "Pot substituir el certificat que Termix serveix per HTTPS i respondre a les comprovacions de domini de les autoritats de certificació."
|
||||
@@ -1181,10 +1180,6 @@
|
||||
"title": "Accedir a dispositius sèrie i USB",
|
||||
"consequence": "Pot obrir un port sèrie físic a l'ordinador que executa Termix."
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "Exporta els hosts amb els seus secrets",
|
||||
"consequence": "Pot llegir els teus hosts en tota la seva extensió, incloses les contrasenyes i les claus desades, i distribuir-los. Tota exportació s'escriu al registre d'auditoria."
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "Crear i modificar servidors",
|
||||
"consequence": "Pot afegir servidors i editar els que ja teniu."
|
||||
@@ -1229,30 +1224,10 @@
|
||||
"title": "Llegir el registre d'auditoria",
|
||||
"consequence": "Pot veure qui ha fet què i quan."
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "Comprova si els ports responen",
|
||||
"consequence": "Pot obrir una connexió senzilla a qualsevol adreça des del servidor, per veure si és accessible."
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "Proporciona l'estat de l'host",
|
||||
"consequence": "Esdevé la font dels punts en línia i fora de línia per a cada host."
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "Emmagatzema l'activitat recent",
|
||||
"consequence": "Esdevé l'emmagatzematge de la llista d'activitat recent de cada usuari."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "Gestionar els amfitrions com a només lectura",
|
||||
"consequence": "Els amfitrions que gestiona no es poden editar, suprimir ni tornar a compartir per ningú més."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Obrir finestres d'escriptori",
|
||||
"consequence": "Només a l'aplicació d'escriptori de Termix."
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "Utilitzar aquest ordinador",
|
||||
"consequence": "Pot llegir i modificar fitxers, executar un shell i obrir ports locals en aquest ordinador. Només a l'aplicació d'escriptori de Termix."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "Actuar com a altres usuaris",
|
||||
"consequence": "Executa tasques en segon pla amb l'accés d'un altre usuari, com ara feines programades. Cada ús s'escriu al registre d'auditoria."
|
||||
@@ -1292,14 +1267,6 @@
|
||||
"ui:surface": {
|
||||
"title": "Afegir les seves pròpies pantalles",
|
||||
"consequence": "Pestanyes, panells i configuració que pots amagar més endavant."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "Substituir la disposició completa de l'aplicació",
|
||||
"consequence": "Quan s'escull, el seu codi dibuixa cada pantalla, inclosos els terminals i la configuració."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "Afegir temes de colors",
|
||||
"consequence": "Només colors i estils. No s'executa cap codi per a un tema."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1441,7 +1408,6 @@
|
||||
"options": "No s'han pogut desar les opcions de {{name}}: {{error}}",
|
||||
"deleteData": "No s'han pogut eliminar les dades de {{name}}: {{error}}",
|
||||
"managedByServer": "Aquest escriptori està enllaçat a un servidor, que decideix els seus complements.",
|
||||
"defaultLook": "{{name}} és l'aspecte predeterminat, per la qual cosa es pot desactivar o substituir, però no desinstal·lar.",
|
||||
"hasDependents": "{{name}} és necessari per a {{list}}. Desactiva'ls o desinstal·la'ls primer.",
|
||||
"missingDependencies": "{{name}} necessita {{list}}, que no està instal·lat.",
|
||||
"consentChanged": "El que demana aquest complement ha canviat des que ho vas consultar. Revisa la llista de nou.",
|
||||
@@ -1515,6 +1481,7 @@
|
||||
"noServerAccess": "Sense accés als teus servidors",
|
||||
"searchInstalled": "Cercar entre els instal·lats",
|
||||
"searchPlugins": "Cercar complements",
|
||||
"allCategories": "Tots",
|
||||
"nothingMatches": "Res no coincideix amb la cerca.",
|
||||
"noneInstalled": "Encara no tens cap complement instal·lat. Navega per afegir-ne alguns.",
|
||||
"nothingHere": "No hi ha res aquí.",
|
||||
@@ -1608,45 +1575,7 @@
|
||||
"documentation": "Documentació",
|
||||
"envVars": "Variables d'entorn",
|
||||
"envRequired": "Obligatori",
|
||||
"envDefault": "Per defecte: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "Disposicions",
|
||||
"themes": "Temes",
|
||||
"tabs": "Pestanyes",
|
||||
"panels": "Panells de la barra lateral",
|
||||
"dashboardCards": "Targetes del tauler de control",
|
||||
"protocols": "Tipus de connexió",
|
||||
"auth": "Mètodes d'inici de sessió",
|
||||
"actions": "Accions",
|
||||
"hostCapability": "Configuració de l'amfitrió"
|
||||
},
|
||||
"filters": {
|
||||
"type": "Tipus",
|
||||
"allTypes": "Tots els tipus",
|
||||
"category": "Categoria",
|
||||
"allCategories": "Totes les categories",
|
||||
"results_one": "{{count}} complement",
|
||||
"results_many": "{{count}} complements",
|
||||
"results_other": "{{count}} complements",
|
||||
"remove": "Eliminar el filtre {{name}}",
|
||||
"clear": "Esborrar filtres",
|
||||
"button": "Filtres",
|
||||
"buttonCount": "Filtres ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} de {{total}}",
|
||||
"page": "Pàgina {{page}} de {{pages}}",
|
||||
"previous": "Pàgina anterior",
|
||||
"next": "Pàgina següent",
|
||||
"perPage": "{{count}} per pàgina",
|
||||
"perPageLabel": "Complements per pàgina"
|
||||
},
|
||||
"showUnsupportedHint": "Aquests es llisten, però encara no hi ha cap versió que s'executi en aquesta versió. Actualitza Termix per instal·lar-los.",
|
||||
"unsupported": "Cal una versió més recent de Termix",
|
||||
"unsupportedHint": "Cap versió d'aquest complement s'executa en aquesta versió de Termix. Actualitza Termix per instal·lar-lo.",
|
||||
"needsTermix": "Cal Termix {{range}}",
|
||||
"showUnsupported": "Mostrar els no compatibles ({{count}})",
|
||||
"showMore": "Mostrar {{count}} més ({{total}} restants)"
|
||||
"envDefault": "Per defecte: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1662,6 +1591,92 @@
|
||||
"noData": "Sense dades",
|
||||
"filter": "Filtra..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "fa {{count}} minut",
|
||||
"minutesAgo_many": "fa {{count}} minuts",
|
||||
"minutesAgo_other": "fa {{count}} minuts",
|
||||
"hoursAgo_one": "fa {{count}} hora",
|
||||
"hoursAgo_many": "fa {{count}} hores",
|
||||
"hoursAgo_other": "fa {{count}} hores",
|
||||
"pending_one": "{{count}} canvi pendent de sincronitzar",
|
||||
"pending_many": "{{count}} canvis pendents de sincronitzar",
|
||||
"pending_other": "{{count}} canvis pendents de sincronitzar",
|
||||
"proxy": {
|
||||
"headersTitle": "Encapçalaments addicionals",
|
||||
"headersHint": "S'envien amb cada sol·licitud al servidor, per exemple, un testimoni de servei de Cloudflare Access.",
|
||||
"headerName": "Nom de l'encapçalament",
|
||||
"headerValue": "Valor",
|
||||
"savedValue": "Desat, escriu per canviar",
|
||||
"addHeader": "Afegeix encapçalament",
|
||||
"removeHeader": "Elimina encapçalament",
|
||||
"basicAuthTitle": "Nom d'usuari i contrasenya",
|
||||
"basicAuthHint": "Per a un proxy que demana un nom d'usuari i contrasenya abans de Termix.",
|
||||
"username": "Nom d'usuari",
|
||||
"password": "Contrasenya",
|
||||
"allowInvalidCertificate": "Confia en un certificat autofirmat",
|
||||
"allowInvalidCertificateHint": "Només per al teu propi servidor, quan el seu certificat no prové d'una autoritat coneguda."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "Això no sembla una adreça web.",
|
||||
"unreachable": "No s'ha pogut connectar al servidor. Comproveu l'adreça i que el servidor estigui en funcionament.",
|
||||
"unreachableWithReason": "No s'ha pogut connectar al servidor: {{reason}}",
|
||||
"tlsUntrusted": "El certificat del servidor no és de confiança. Si es tracta del vostre propi servidor amb un certificat autofirmat, activeu l'opció següent.",
|
||||
"basicAuthRequired": "Alguna cosa davant del servidor demana un nom d'usuari i una contrasenya. Introduïu-los a continuació.",
|
||||
"proxyLogin": "Una pàgina d'inici de sessió o un proxy ha respost en lloc de Termix. Afegiu els encapçalaments que necessita a continuació o comproveu l'adreça.",
|
||||
"notTermix": "No hi ha cap servidor Termix en aquesta adreça. Si s'executa en una ruta, incloeu-la, com ara https://example.com/termix.",
|
||||
"versionMismatch": "Aquest servidor executa una versió antiga de Termix. Actualitzeu-lo a la 2.9 o superior per enllaçar-lo.",
|
||||
"differentAccount": "Heu iniciat sessió en un compte diferent. Inicieu sessió en el compte amb què està enllaçat aquest dispositiu o desenllaceu-lo primer."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Enllaçar a un servidor Termix",
|
||||
"reloginTitle": "Inicieu sessió de nou",
|
||||
"savedServers": "Servidors recents",
|
||||
"removeServer": "Eliminar de la llista",
|
||||
"serverDescription": "Introduïu l'adreça que utilitzeu per obrir Termix al vostre navegador.",
|
||||
"signinDescription": "Inicieu sessió a {{url}}",
|
||||
"chooseDescription": "Aquest dispositiu ja té dades. Què s'ha de fer amb elles?",
|
||||
"finishingDescription": "Vinculant aquest dispositiu.",
|
||||
"serverUrl": "Adreça del servidor",
|
||||
"serverUrlHint": "Per exemple, https://termix.example.com",
|
||||
"showProxySettings": "Configuració del proxy invers",
|
||||
"hideProxySettings": "Amaga la configuració del proxy invers",
|
||||
"continue": "Continua",
|
||||
"back": "Enrere",
|
||||
"mergeTitle": "Afegeix-ho al meu compte",
|
||||
"mergeBody_one": "L'element d'aquest dispositiu s'afegeix al teu compte. Els elements que el teu compte ja té es mantenen tal com són al servidor.",
|
||||
"mergeBody_many": "Els {{count}} elements d'aquest dispositiu s'afegeixen al teu compte. Els elements que el teu compte ja té es mantenen tal com són al servidor.",
|
||||
"mergeBody_other": "Els {{count}} elements d'aquest dispositiu s'afegeixen al teu compte. Els elements que el teu compte ja té es mantenen tal com són al servidor.",
|
||||
"replaceTitle": "Substitueix-ho pel meu compte",
|
||||
"replaceBody_one": "L'element d'aquest dispositiu s'elimina i aquest dispositiu pren tot del teu compte. Només es mantenen els amfitrions que es conserven només en aquest dispositiu.",
|
||||
"replaceBody_many": "Els {{count}} elements d'aquest dispositiu s'eliminen i aquest dispositiu pren tot del teu compte. Només es mantenen els amfitrions que es conserven només en aquest dispositiu.",
|
||||
"replaceBody_other": "Els {{count}} elements d'aquest dispositiu s'eliminen i aquest dispositiu pren tot del teu compte. Només es mantenen els amfitrions que es conserven només en aquest dispositiu.",
|
||||
"link": "Vincula",
|
||||
"linking": "Vinculant i executant la primera sincronització...",
|
||||
"linked": "Vinculat. Les teves dades s'estan sincronitzant ara.",
|
||||
"signedInAgain": "Iniciat la sessió. La sincronització s'ha tornat a executar."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Amfitrions",
|
||||
"sshCredentials": "Credencials",
|
||||
"sshFolders": "Carpetes",
|
||||
"userPreferences": "Preferències",
|
||||
"pluginUserSettings": "Configuració del complement",
|
||||
"sharedHosts": "Amfitrions compartits amb tu",
|
||||
"sharedCredentials": "Credencials compartides amb tu",
|
||||
"vaultProfiles": "Perfils de Vault",
|
||||
"snippetFolders": "Carpetes de fragments",
|
||||
"snippets": "Fragments",
|
||||
"dashboardServiceLinks": "Enllaços de servei",
|
||||
"homepageItems": "Elements de la pàgina d'inici",
|
||||
"networkTopology": "Mapa de xarxa",
|
||||
"workspaces": "Espais de treball",
|
||||
"hostDefaults": "Valors predeterminats de l'amfitrió",
|
||||
"hostDefaultsAdmin": "Valors predeterminats de l'amfitrió del servidor",
|
||||
"aiProviders": "Proveïdors d'IA",
|
||||
"homepageLayouts": "Disseny de la pàgina d'inici",
|
||||
"accountProfile": "Perfil del compte"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Carpeta {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "Některé pluginy se nepodařilo změnit. Opravíte je později ze záložky Pluginy. Stiskněte Další pro pokračování.",
|
||||
"pluginsApplyFailed": "Vaše volby se nepodařilo uložit. Zkuste to znovu, nebo pokračujte a změňte je později ze záložky Pluginy.",
|
||||
"pluginsLoadFailed": "Seznam pluginů se nepodařilo načíst. Pluginy můžete vybrat později ze záložky Pluginy.",
|
||||
"presetTitle": "Kolik obsahu chcete na obrazovce?",
|
||||
"presetIntro": "Vyberte, kolik obsahu se zobrazí na obrazovce.",
|
||||
"presetChangeLater": "Přepínání předvoleb nebo změnu jednotlivých nastavení můžete provést později v Nastavení pod rozbalovací nabídkou Rozhraní.",
|
||||
"appearanceTitle": "Přizpůsobte si vzhled",
|
||||
"appearanceIntro": "Vyberte si motiv, akcentní barvu, písmo a jazyk. Změny se projeví okamžitě a později je můžete upravit v Nastavení.",
|
||||
"securityTitle": "Zabezpečte svůj účet",
|
||||
"securityIntro": "Přidejte do svého účtu passkey nebo aplikaci pro ověřování. Toto můžete provést také později v Nastavení pod Bezpečnost.",
|
||||
"desktopTitle": "Jak chcete Termix používat?",
|
||||
"desktopBody": "Toto můžete kdykoli změnit v Synchronizace v postranním panelu.",
|
||||
"desktopLocalTitle": "Pouze na tomto zařízení",
|
||||
"desktopLocalBody": "Vše je uloženo zde. Nic k nastavování.",
|
||||
"desktopLinkTitle": "S mým serverem Termix",
|
||||
"desktopLinkBody": "Přihlaste se ke svému serveru pro použití stejných hostitelů a nastavení na všech zařízeních.",
|
||||
"desktopLinked": "Propojeno s {{server}}",
|
||||
"pluginsDocsLink": "Přečtěte si o každém z nich"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Termix {{version}} je připraven k instalaci",
|
||||
"readyHint": "Nainstaluje se při příštím restartu Termixu."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Zavřít panel",
|
||||
"moveSide": "Přesunout panel",
|
||||
"resize": "Změnit velikost panelu"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Domů",
|
||||
"terminal": "Terminál",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "Hostitelé",
|
||||
"credentials": "Přihlašovací údaje",
|
||||
"connections": "Připojení",
|
||||
"sync": "Synchronizace",
|
||||
"reconnectDisconnectedTerminals": "Znovu připojit všechny odpojené terminály",
|
||||
"reconnectingTerminals_one": "Znovu se připojuji k {{count}} terminálu",
|
||||
"reconnectingTerminals_few": "Znovu se připojuji k {{count}} terminálům",
|
||||
@@ -277,7 +285,10 @@
|
||||
"retry": "Zkusit znovu",
|
||||
"refresh": "Obnovit",
|
||||
"optional": "Volitelné",
|
||||
"downloadSample": "Stáhnout vzor",
|
||||
"failedToDeleteHost": "Nepodařilo se smazat {{name}}",
|
||||
"importSkipExisting": "Importovat (přeskočit existující)",
|
||||
"importSSHConfig": "Importovat ze SSH konfigurace",
|
||||
"connectionDetails": "Podrobnosti o připojení",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -498,7 +509,10 @@
|
||||
"noFolder": "(Žádná složka)",
|
||||
"deleteSelected": "Smazat",
|
||||
"exitSelection": "Ukončit výběr",
|
||||
"importSkip": "Importovat (přeskočit existující)",
|
||||
"importOverwrite": "Importovat (přepsat)",
|
||||
"collapseBtn": "Sbalit",
|
||||
"importExportBtn": "Import / Export",
|
||||
"hostStatusesRefreshed": "Stavy hostitelů obnoveny",
|
||||
"failedToRefreshHosts": "Nepodařilo se obnovit hostitele",
|
||||
"movedHostTo": "Přesunuto {{host}} do \"{{folder}}\"",
|
||||
@@ -557,9 +571,6 @@
|
||||
"deleteFolderConfirm_many": "Smazat \"{{name}}\" a jeho {{count}} hostů? Tuto akci nelze vrátit zpět.",
|
||||
"deleteFolderConfirm_other": "Smazat \"{{name}}\" a jeho {{count}} hostů? Tuto akci nelze vrátit zpět.",
|
||||
"failedToMoveHosts": "Nepodařilo se přesunout hosty",
|
||||
"managedBanner": "Tento hostitel je spravován prostřednictvím {{plugin}}. Změňte jej tam. Stále jej můžete připnout a změnit pořadí.",
|
||||
"managedTooltip": "Spravováno prostřednictvím {{plugin}}",
|
||||
"addBlocked": "Hostitelé jsou spravováni prostřednictvím {{plugin}}, takže zde nelze přidávat nové hostitele.",
|
||||
"failedToReorder": "Nepodařilo se změnit pořadí",
|
||||
"expandAll": "Rozbalit všechny složky",
|
||||
"collapseAll": "Sbalit všechny složky",
|
||||
@@ -672,6 +683,37 @@
|
||||
"addCredentialBtn2": "Přidat přihlašovací údaje",
|
||||
"checkingHostStatuses": "Kontrola stavů hostitelů...",
|
||||
"pinnedSection": "Připnuté",
|
||||
"hostsExported": "Hostitelé úspěšně exportováni",
|
||||
"export": {
|
||||
"menuItem": "Exportovat...",
|
||||
"title": "Export hostitelů",
|
||||
"scope": "Rozsah",
|
||||
"scopeAll": "Všechny",
|
||||
"scopeSelected": "Vybrané",
|
||||
"searchHosts": "Hledat hostitele...",
|
||||
"include": "Zahrnout",
|
||||
"groupConnection": "Připojení",
|
||||
"groupCredentials": "Přihlašovací údaje",
|
||||
"groupNotes": "Poznámky",
|
||||
"groupTags": "Štítky a připnutí",
|
||||
"groupProxy": "Proxy",
|
||||
"groupJumpHosts": "Překopové hostitele",
|
||||
"groupFeatureFlags": "Nastavení pluginů",
|
||||
"groupAdvanced": "Rozšířená konfigurace",
|
||||
"preview": "Náhled",
|
||||
"moreHosts_one": "... a {{count}} další host",
|
||||
"moreHosts_few": "... a {{count}} další hosté",
|
||||
"moreHosts_many": "... a {{count}} dalších hostů",
|
||||
"moreHosts_other": "... a {{count}} dalších hostů",
|
||||
"summary": "{{selected}} z {{total}} hostitelů",
|
||||
"credentialsIncluded": "zahrnuty přihlašovací údaje",
|
||||
"credentialsExcluded": "vynechány přihlašovací údaje",
|
||||
"noneSelected": "Nejsou vybráni žádní hostitelé",
|
||||
"cancel": "Zrušit",
|
||||
"confirm": "Exportovat",
|
||||
"fetchFailed": "Nepodařilo se načíst hostitele pro export",
|
||||
"bulkButton": "Exportovat"
|
||||
},
|
||||
"sampleDownloaded": "Vzorkový soubor stažen",
|
||||
"failedToDeleteCredential2": "Nepodařilo se smazat přihlašovací údaje",
|
||||
"noFolderOption": "(Žádná složka)",
|
||||
@@ -844,7 +886,11 @@
|
||||
"editorUpdate": "Aktualizovat hostitele",
|
||||
"editorAdd": "Přidat hostitele",
|
||||
"speedSearch": "Hledat hostitele. Šipky pro výběr, Enter pro připojení, Esc pro uzavření",
|
||||
"speedSearchList": "Seznam hostitelů. Píšte nebo stiskněte / pro hledání."
|
||||
"speedSearchList": "Seznam hostitelů. Píšte nebo stiskněte / pro hledání.",
|
||||
"importNoHosts": "V souboru nebyly nalezeny žádné hostitele",
|
||||
"importTooMany": "Naraz můžete importovat až {{max}} hostitelů",
|
||||
"importSummary": "Import dokončen: {{imported}} importováno, {{updated}} aktualizováno, {{failed}} selhalo",
|
||||
"importHostsFailed": "Nepodařilo se importovat hostitele"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Rozdělená obrazovka",
|
||||
@@ -1049,9 +1095,17 @@
|
||||
},
|
||||
"admin": {
|
||||
"usersCount_one": "{{count}} uživatel",
|
||||
"usersCount_few": "{{count}} uživatelé",
|
||||
"usersCount_many": "{{count}} uživatelů",
|
||||
"rolesCount_one": "{{count}} role",
|
||||
"rolesCount_few": "{{count}} role",
|
||||
"rolesCount_many": "{{count}} rolí",
|
||||
"apiKeysCount_one": "{{count}} klíč",
|
||||
"apiKeysCount_few": "{{count}} klíče",
|
||||
"apiKeysCount_many": "{{count}} klíčů",
|
||||
"sessionsActive_one": "{{count}} aktivní",
|
||||
"sessionsActive_few": "{{count}} aktivní",
|
||||
"sessionsActive_many": "{{count}} aktivních",
|
||||
"hostsCount_one": "{{count}} host",
|
||||
"hostsCount_few": "{{count}} hosté",
|
||||
"hostsCount_many": "{{count}} hostů",
|
||||
@@ -1062,6 +1116,8 @@
|
||||
"credentialsCount_other": "{{count}} přihlašovacích údajů",
|
||||
"rolePermissions": {
|
||||
"count_one": "{{count}} oprávnění",
|
||||
"count_few": "{{count}} oprávnění",
|
||||
"count_many": "{{count}} oprávnění",
|
||||
"groups": {
|
||||
"hosts": "Hostitelé",
|
||||
"credentials": "Přihlašovací údaje",
|
||||
@@ -1102,60 +1158,22 @@
|
||||
"interfaceFontDescription": "Změní písmo uživatelského rozhraní aplikace. Profily terminálu si zachovávají vlastní nastavení písma.",
|
||||
"accentColorLabel": "Akcentová barva",
|
||||
"apiKeyCount_one": "{{count}} klíč",
|
||||
"apiKeyCount_few": "{{count}} klíče",
|
||||
"apiKeyCount_many": "{{count}} klíčů",
|
||||
"colorPickerTooltip": "Otevřít výběr barev",
|
||||
"themeSystem": "Systém",
|
||||
"themeLight": "Světlý",
|
||||
"themeDark": "Tmavý"
|
||||
"themeDark": "Tmavý",
|
||||
"themeDracula": "Dracula",
|
||||
"themeCatppuccin": "Catppuccin",
|
||||
"themeNord": "Nord",
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "Jak je celá aplikace rozložena. Motivy a rozložení pocházejí z pluginů.",
|
||||
"defaultsLabel": "Výchozí pro všechny",
|
||||
"defaultsHint": "Co lidé uvidí, dokud si nevyberou vlastní rozložení nebo motiv.",
|
||||
"followDefault": "Použít výchozí nastavení",
|
||||
"defaultSaved": "Vzhled uložen jako výchozí",
|
||||
"saveFailed": "Vzhled se nepodařilo uložit",
|
||||
"repair": {
|
||||
"title": "Toto rozložení se nepodařilo zobrazit",
|
||||
"safeTitle": "Bezpečný režim",
|
||||
"layout": "Rozložení",
|
||||
"plugin": "Plugin",
|
||||
"reason": "Důvod",
|
||||
"error": "Chyba",
|
||||
"noError": "Žádná chybová zpráva.",
|
||||
"copy": "Kopírovat",
|
||||
"copied": "Zkopírováno",
|
||||
"copyFailed": "Podrobnosti se nepodařilo zkopírovat",
|
||||
"retry": "Zkusit znovu",
|
||||
"reset": "Obnovit výchozí vzhled",
|
||||
"resetEveryone": "Obnovit výchozí nastavení pro všechny",
|
||||
"update": "Aktualizovat {{name}} na {{version}}",
|
||||
"disable": "Vypnout {{name}}",
|
||||
"install": "Hledat rozložení a motivy",
|
||||
"installTitle": "Rozložení a motivy",
|
||||
"installButton": "Instalovat",
|
||||
"nothingToInstall": "Právě teď nejsou k dispozici žádná jiná rozložení ani motivy.",
|
||||
"registryUnreachable": "Nelze se připojit k rejstříku pluginů.",
|
||||
"askAdmin": "Požádejte správce, aby aktualizoval nebo opravil toto rozložení. Níže si můžete přepnout na jiné rozložení nebo motiv.",
|
||||
"switchLayout": "Přepnout rozložení",
|
||||
"switchTheme": "Přepnout motiv",
|
||||
"saveFailed": "Vaše volba se nepodařilo uložit",
|
||||
"reasons": {
|
||||
"safe-url": "Adresa požadovala bezpečný režim. Vyberte rozložení níže nebo z adresy odeberte ?safe-layout=1.",
|
||||
"safe-env": "Server byl spuštěn s TERMIX_SAFE_LAYOUT, takže všichni uvidí tuto obrazovku, dokud nebude odstraněn.",
|
||||
"missing": "Žádný nainstalovaný plugin toto rozložení neposkytuje.",
|
||||
"disabled": "Plugin, který toto rozložení poskytuje, je vypnutý.",
|
||||
"blocked": "Plugin, který toto rozložení poskytuje, čeká na něco, co potřebuje.",
|
||||
"incompatible": "Plugin, který toto rozložení poskytuje, nefunguje s touto verzí Termix.",
|
||||
"failed": "Rozložení se nepodařilo spustit.",
|
||||
"crashed": "Rozložení dvakrát selhalo při vykreslování aplikace."
|
||||
},
|
||||
"noLayout": "Není nastaveno žádné rozložení",
|
||||
"restore": "Obnovit dodané {{name}}",
|
||||
"restoreFailed": "Nepodařilo se obnovit dodanou kopii"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1178,18 +1196,6 @@
|
||||
"title": "Spravovat přihlašování",
|
||||
"consequence": "Může přidat metodu přihlášení, druhý faktor nebo typ SSH přihlášení."
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "Nastavit pravidla pro přihlášení",
|
||||
"consequence": "Může odhlásit lidi při nečinnosti, omezit délku relace, vypnout funkci Zapamatovat si mě a vyžadovat druhé ověření."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Vyberte, jak se k hostitelům přistupuje",
|
||||
"consequence": "Může přenášet SSH připojení k hostiteli přes vlastní trasu, takže vidí vše, co se k těmto hostitelům odesílá a od nich přijímá, včetně hesel."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Spustit synchronizaci",
|
||||
"consequence": "Může odpovídat na všechny požadavky na synchronizaci, takže vidí data, která synchronizují zařízení a servery."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Spravovat certifikáty",
|
||||
"consequence": "Může nahradit certifikát, který Termix poskytuje přes HTTPS, a odpovídat na ověřování domén od certifikačních autorit."
|
||||
@@ -1198,10 +1204,6 @@
|
||||
"title": "Přístup k sériovým a USB zařízením",
|
||||
"consequence": "Může otevřít fyzický sériový port na počítači, kde běží Termix."
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "Exportovat hostitele s jejich tajnými informacemi",
|
||||
"consequence": "Může číst vaše hostitele v plném rozsahu, včetně uložených hesel a klíčů, a předávat je dál. Každý export je zapsán do auditního protokolu."
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "Vytvářet a měnit hostitele",
|
||||
"consequence": "Může přidávat hostitele a upravovat ty, které již máte."
|
||||
@@ -1246,30 +1248,10 @@
|
||||
"title": "Číst auditní protokol",
|
||||
"consequence": "Může vidět, kdo co a kdy udělal."
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "Zkontrolovat, zda porty odpovídají",
|
||||
"consequence": "Může otevřít obyčejné připojení k libovolné adrese ze serveru, aby zjistil, zda je dosažitelná."
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "Poskytovat stav hostitele",
|
||||
"consequence": "Stává se zdrojem teček online a offline pro každého hostitele."
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "Ukládat nedávnou aktivitu",
|
||||
"consequence": "Stává se úložištěm seznamu nedávné aktivity pro každého uživatele."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "Spravovat hostitele jako pouze pro čtení",
|
||||
"consequence": "Hostitele, které spravuje, nemůže nikdo jiný upravovat, mazat ani znovu sdílet."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Otevírat okna plochy",
|
||||
"consequence": "Pouze v desktopové aplikaci Termix."
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "Použít tento počítač",
|
||||
"consequence": "Může číst a měnit soubory, spouštět shell a otevírat místní porty na tomto počítači. Pouze v desktopové aplikaci Termix."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "Jednat jménem jiných uživatelů",
|
||||
"consequence": "Spouští pozadí úlohy s přístupem jiného uživatele, například plánované úlohy. Každé použití je zaznamenáno v auditním protokolu."
|
||||
@@ -1309,14 +1291,6 @@
|
||||
"ui:surface": {
|
||||
"title": "Přidat vlastní obrazovky",
|
||||
"consequence": "Záložky, panely a nastavení, která lze později skrýt."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "Nahradit celé rozložení aplikace",
|
||||
"consequence": "Pokud je vybráno, jeho kód vykresluje každou obrazovku, včetně vašich terminálů a nastavení."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "Přidat barevná témata",
|
||||
"consequence": "Pouze barvy a styly. Pro téma se žádný kód nespouští."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1470,7 +1444,6 @@
|
||||
"options": "Nelze uložit možnosti pro {{name}}: {{error}}",
|
||||
"deleteData": "Nelze smazat data pro {{name}}: {{error}}",
|
||||
"managedByServer": "Tento plocha je propojena se serverem, který určuje její pluginy.",
|
||||
"defaultLook": "{{name}} je výchozí vzhled, takže lze vypnout nebo zaměnit, ale ne odinstalovat.",
|
||||
"hasDependents": "{{name}} je potřeba pro {{list}}. Nejprve je vypněte nebo odinstalujte.",
|
||||
"missingDependencies": "{{name}} potřebuje {{list}}, který není nainstalován.",
|
||||
"consentChanged": "Požadavky tohoto pluginu se od vašeho posledního zobrazení změnily. Zkontrolujte seznam znovu.",
|
||||
@@ -1548,6 +1521,7 @@
|
||||
"noServerAccess": "Žádný přístup k vašim serverům",
|
||||
"searchInstalled": "Hledat mezi nainstalovanými",
|
||||
"searchPlugins": "Hledat pluginy",
|
||||
"allCategories": "Vše",
|
||||
"nothingMatches": "Nic neodpovídá této položce.",
|
||||
"noneInstalled": "Zatím nejsou nainstalovány žádné pluginy. Prohledejte je a přidejte nějaké.",
|
||||
"nothingHere": "Nic zde není.",
|
||||
@@ -1645,46 +1619,7 @@
|
||||
"documentation": "Dokumentace",
|
||||
"envVars": "Proměnné prostředí",
|
||||
"envRequired": "Povinné",
|
||||
"envDefault": "Výchozí: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "Rozložení",
|
||||
"themes": "Témata",
|
||||
"tabs": "Záložky",
|
||||
"panels": "Panely postranního panelu",
|
||||
"dashboardCards": "Karty přehledu",
|
||||
"protocols": "Typy připojení",
|
||||
"auth": "Způsoby přihlášení",
|
||||
"actions": "Akce",
|
||||
"hostCapability": "Nastavení hostitele"
|
||||
},
|
||||
"filters": {
|
||||
"type": "Typ",
|
||||
"allTypes": "Všechny typy",
|
||||
"category": "Kategorie",
|
||||
"allCategories": "Všechny kategorie",
|
||||
"results_one": "{{count}} plugin",
|
||||
"results_few": "{{count}} pluginy",
|
||||
"results_many": "{{count}} pluginů",
|
||||
"results_other": "{{count}} pluginů",
|
||||
"remove": "Odebrat filtr {{name}}",
|
||||
"clear": "Vymazat filtry",
|
||||
"button": "Filtry",
|
||||
"buttonCount": "Filtry ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} z {{total}}",
|
||||
"page": "Strana {{page}} z {{pages}}",
|
||||
"previous": "Předchozí strana",
|
||||
"next": "Další strana",
|
||||
"perPage": "{{count}} na stranu",
|
||||
"perPageLabel": "Pluginů na stranu"
|
||||
},
|
||||
"showUnsupportedHint": "Tyto jsou uvedeny, ale žádná verze zatím na této verzi neběží. Aktualizujte Termix pro jejich instalaci.",
|
||||
"unsupported": "Vyžaduje novější Termix",
|
||||
"unsupportedHint": "Žádná verze tohoto pluginu neběží na této verzi Termix. Aktualizujte Termix pro jeho instalaci.",
|
||||
"needsTermix": "Vyžaduje Termix {{range}}",
|
||||
"showUnsupported": "Zobrazit nepodporované ({{count}})",
|
||||
"showMore": "Zobrazit {{count}} dalších (zbývá {{total}})"
|
||||
"envDefault": "Výchozí: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1700,6 +1635,97 @@
|
||||
"noData": "Žádná data",
|
||||
"filter": "Filtrovat..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "před {{count}} minutou",
|
||||
"minutesAgo_few": "před {{count}} minutami",
|
||||
"minutesAgo_many": "před {{count}} minutami",
|
||||
"minutesAgo_other": "před {{count}} minutami",
|
||||
"hoursAgo_one": "před {{count}} hodinou",
|
||||
"hoursAgo_few": "před {{count}} hodinami",
|
||||
"hoursAgo_many": "před {{count}} hodinami",
|
||||
"hoursAgo_other": "před {{count}} hodinami",
|
||||
"pending_one": "{{count}} změna čeká na synchronizaci",
|
||||
"pending_few": "{{count}} změny čekají na synchronizaci",
|
||||
"pending_many": "{{count}} změn čeká na synchronizaci",
|
||||
"pending_other": "{{count}} změn čeká na synchronizaci",
|
||||
"proxy": {
|
||||
"headersTitle": "Další hlavičky",
|
||||
"headersHint": "Odesílány s každým požadavkem na server, například token služby Cloudflare Access.",
|
||||
"headerName": "Název hlavičky",
|
||||
"headerValue": "Hodnota",
|
||||
"savedValue": "Uloženo, pro změnu napište novou hodnotu",
|
||||
"addHeader": "Přidat hlavičku",
|
||||
"removeHeader": "Odstranit hlavičku",
|
||||
"basicAuthTitle": "Uživatelské jméno a heslo",
|
||||
"basicAuthHint": "Pro proxy, která vyžaduje uživatelské jméno a heslo před Termixem.",
|
||||
"username": "Uživatelské jméno",
|
||||
"password": "Heslo",
|
||||
"allowInvalidCertificate": "Důvěřovat certifikátu podepsanému sám sebou",
|
||||
"allowInvalidCertificateHint": "Pouze pro váš vlastní server, pokud jeho certifikát není od známé autority."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "To nevypadá jako webová adresa.",
|
||||
"unreachable": "Nelze se připojit k serveru. Zkontrolujte adresu a zda je server spuštěn.",
|
||||
"unreachableWithReason": "Nelze se připojit k serveru: {{reason}}",
|
||||
"tlsUntrusted": "Certifikát serveru není důvěryhodný. Pokud je to váš vlastní server se self-signed certifikátem, povolte možnost níže.",
|
||||
"basicAuthRequired": "Něco před serverem vyžaduje uživatelské jméno a heslo. Zadejte je níže.",
|
||||
"proxyLogin": "Místo Termix odpověděla přihlašovací stránka nebo proxy. Níže přidejte potřebné hlavičky nebo zkontrolujte adresu.",
|
||||
"notTermix": "Na této adrese není žádný Termix server. Pokud běží na konkrétní cestě, uveďte ji, například https://example.com/termix.",
|
||||
"versionMismatch": "Tento server používá starší verzi Termix. Pro propojení jej aktualizujte na verzi 2.9 nebo novější.",
|
||||
"differentAccount": "Přihlásili jste se do jiného účtu. Přihlaste se do účtu, ke kterému je toto zařízení propojeno, nebo jej nejprve odpojte."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Propojit s Termix serverem",
|
||||
"reloginTitle": "Přihlásit se znovu",
|
||||
"savedServers": "Nedávné servery",
|
||||
"removeServer": "Odebrat ze seznamu",
|
||||
"serverDescription": "Zadejte adresu, kterou používáte k otevření Termixu ve vašem prohlížeči.",
|
||||
"signinDescription": "Přihlaste se do {{url}}",
|
||||
"chooseDescription": "Toto zařízení již obsahuje data. Co s nimi máte udělat?",
|
||||
"finishingDescription": "Propojuji toto zařízení.",
|
||||
"serverUrl": "Adresa serveru",
|
||||
"serverUrlHint": "Například https://termix.example.com",
|
||||
"showProxySettings": "Nastavení reverzní proxy",
|
||||
"hideProxySettings": "Skrýt nastavení reverzní proxy",
|
||||
"continue": "Pokračovat",
|
||||
"back": "Zpět",
|
||||
"mergeTitle": "Přidat do mého účtu",
|
||||
"mergeBody_one": "Položka na tomto zařízení je přidána do vašeho účtu. Položky, které váš účet již má, zůstanou na serveru beze změny.",
|
||||
"mergeBody_few": "{{count}} položky na tomto zařízení jsou přidány do vašeho účtu. Položky, které váš účet již má, zůstanou na serveru beze změny.",
|
||||
"mergeBody_many": "{{count}} položek na tomto zařízení je přidáno do vašeho účtu. Položky, které váš účet již má, zůstanou na serveru beze změny.",
|
||||
"mergeBody_other": "{{count}} položek na tomto zařízení je přidáno do vašeho účtu. Položky, které váš účet již má, zůstanou na serveru beze změny.",
|
||||
"replaceTitle": "Nahradit obsahem mého účtu",
|
||||
"replaceBody_one": "Položka na tomto zařízení je odstraněna a toto zařízení přebere vše z vašeho účtu. Hosté uložené pouze na tomto zařízení zůstanou.",
|
||||
"replaceBody_few": "{{count}} položky na tomto zařízení jsou odstraněny a toto zařízení přebere vše z vašeho účtu. Hosté uložené pouze na tomto zařízení zůstanou.",
|
||||
"replaceBody_many": "{{count}} položek na tomto zařízení je odstraněno a toto zařízení přebere vše z vašeho účtu. Hosté uložené pouze na tomto zařízení zůstanou.",
|
||||
"replaceBody_other": "{{count}} položek na tomto zařízení je odstraněno a toto zařízení přebere vše z vašeho účtu. Hosté uložené pouze na tomto zařízení zůstanou.",
|
||||
"link": "Propojit",
|
||||
"linking": "Probíhá propojování a první synchronizace...",
|
||||
"linked": "Propojeno. Vaše data se nyní synchronizují.",
|
||||
"signedInAgain": "Přihlášen. Synchronizace běží znovu."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Hostitelé",
|
||||
"sshCredentials": "Přihlašovací údaje",
|
||||
"sshFolders": "Složky",
|
||||
"userPreferences": "Předvolby",
|
||||
"pluginUserSettings": "Nastavení pluginů",
|
||||
"sharedHosts": "Hostitelé sdílení s vámi",
|
||||
"sharedCredentials": "Přihlašovací údaje sdílené s vámi",
|
||||
"vaultProfiles": "Profily Vault",
|
||||
"snippetFolders": "Složky úryvků",
|
||||
"snippets": "Úryvky",
|
||||
"dashboardServiceLinks": "Odkazy na služby",
|
||||
"homepageItems": "Položky na domovské stránce",
|
||||
"networkTopology": "Mapa sítě",
|
||||
"workspaces": "Pracovní prostory",
|
||||
"hostDefaults": "Výchozí hodnoty hostitelů",
|
||||
"hostDefaultsAdmin": "Výchozí hodnoty hostitelů serveru",
|
||||
"aiProviders": "Poskytovatelé AI",
|
||||
"homepageLayouts": "Rozložení domovské stránky",
|
||||
"accountProfile": "Profil účtu"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Složka {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "Nogle plugins kunne ikke ændres. Du kan rette dem senere fra Plugins-fanen. Tryk på Næste for at fortsætte.",
|
||||
"pluginsApplyFailed": "Dine valg kunne ikke gemmes. Prøv igen, eller fortsæt og ændr dem senere fra Plugins-fanen.",
|
||||
"pluginsLoadFailed": "Pluginlisten kunne ikke indlæses. Du kan vælge plugins senere fra Plugins-fanen.",
|
||||
"presetTitle": "Hvor meget vil du have på skærmen?",
|
||||
"presetIntro": "Vælg, hvor meget der vises på skærmen.",
|
||||
"presetChangeLater": "Du kan skifte forudindstillinger eller ændre enhver enkelt indstilling senere i Indstillinger under Grænseflade.",
|
||||
"appearanceTitle": "Gør det dit eget",
|
||||
"appearanceIntro": "Vælg et tema, en accentfarve, en skrifttype og dit sprog. Ændringer vises med det samme, og du kan ændre dem senere i Indstillinger.",
|
||||
"securityTitle": "Sikr din konto",
|
||||
"securityIntro": "Tilføj en passkey eller en autentifikator-app til din konto. Du kan også gøre dette senere i Indstillinger under Sikkerhed.",
|
||||
"desktopTitle": "Hvordan vil du bruge Termix?",
|
||||
"desktopBody": "Du kan ændre dette når som helst fra Sync i sidebjælken.",
|
||||
"desktopLocalTitle": "Kun på denne enhed",
|
||||
"desktopLocalBody": "Alt gemmes her. Intet skal opsættes.",
|
||||
"desktopLinkTitle": "Med min Termix-server",
|
||||
"desktopLinkBody": "Log ind på din server for at bruge de samme værter og indstillinger på alle enheder.",
|
||||
"desktopLinked": "Linket til {{server}}",
|
||||
"pluginsDocsLink": "Læs om hver enkelt"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Termix {{version}} er klar til installation",
|
||||
"readyHint": "Den installeres næste gang Termix genstartes."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Luk panel",
|
||||
"moveSide": "Flyt panel",
|
||||
"resize": "Ændr størrelse på panel"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hjem",
|
||||
"terminal": "Terminal",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "Værter",
|
||||
"credentials": "Legitimationsoplysninger",
|
||||
"connections": "Forbindelser",
|
||||
"sync": "Synkroniser",
|
||||
"reconnectDisconnectedTerminals": "Genopret alle afbrudte terminaler",
|
||||
"reconnectingTerminals_one": "Genopretter {{count}} terminal",
|
||||
"reconnectingTerminals_other": "Genopretter {{count}} terminaler",
|
||||
@@ -271,7 +279,10 @@
|
||||
"retry": "Prøv igen",
|
||||
"refresh": "Opdater",
|
||||
"optional": "Valgfrit",
|
||||
"downloadSample": "Download eksempel",
|
||||
"failedToDeleteHost": "Kunne ikke slette {{name}}",
|
||||
"importSkipExisting": "Importer (spring eksisterende over)",
|
||||
"importSSHConfig": "Importer fra SSH-konfiguration",
|
||||
"connectionDetails": "Forbindelsesdetaljer",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -490,7 +501,10 @@
|
||||
"noFolder": "(Ingen mappe)",
|
||||
"deleteSelected": "Slet",
|
||||
"exitSelection": "Afslut markering",
|
||||
"importSkip": "Importer (spring eksisterende over)",
|
||||
"importOverwrite": "Importer (overskriv)",
|
||||
"collapseBtn": "Skjul",
|
||||
"importExportBtn": "Importer / Eksporter",
|
||||
"hostStatusesRefreshed": "Værtstatusser opdateret",
|
||||
"failedToRefreshHosts": "Kunne ikke opdatere værter",
|
||||
"movedHostTo": "Flyttet {{host}} til \"{{folder}}\"",
|
||||
@@ -545,9 +559,6 @@
|
||||
"deleteFolderConfirm_one": "Slet \"{{name}}\" og dens {{count}} vært? Dette kan ikke fortrydes.",
|
||||
"deleteFolderConfirm_other": "Slet \"{{name}}\" og dens {{count}} værter? Dette kan ikke fortrydes.",
|
||||
"failedToMoveHosts": "Kunne ikke flytte værter",
|
||||
"managedBanner": "Denne vært styres af {{plugin}}. Ændr den der. Du kan stadig fastgøre og omorganisere den.",
|
||||
"managedTooltip": "Styres af {{plugin}}",
|
||||
"addBlocked": "Værter styres af {{plugin}}, så nye værter kan ikke tilføjes her.",
|
||||
"failedToReorder": "Kunne ikke omorganisere",
|
||||
"expandAll": "Udvid alle mapper",
|
||||
"collapseAll": "Skjul alle mapper",
|
||||
@@ -658,6 +669,35 @@
|
||||
"addCredentialBtn2": "Tilføj legitimationsoplysninger",
|
||||
"checkingHostStatuses": "Tjekker værtstatus...",
|
||||
"pinnedSection": "Fastgjort",
|
||||
"hostsExported": "Værter eksporteret succesfuldt",
|
||||
"export": {
|
||||
"menuItem": "Eksportér...",
|
||||
"title": "Eksportér værter",
|
||||
"scope": "Omfang",
|
||||
"scopeAll": "Alle",
|
||||
"scopeSelected": "Valgte",
|
||||
"searchHosts": "Søg i værter...",
|
||||
"include": "Inkluder",
|
||||
"groupConnection": "Forbindelse",
|
||||
"groupCredentials": "Legitimationsoplysninger",
|
||||
"groupNotes": "Noter",
|
||||
"groupTags": "Tags & fastgørelse",
|
||||
"groupProxy": "Proxy",
|
||||
"groupJumpHosts": "Jump hosts",
|
||||
"groupFeatureFlags": "Plugin-indstillinger",
|
||||
"groupAdvanced": "Avanceret konfiguration",
|
||||
"preview": "Forhåndsvisning",
|
||||
"moreHosts_one": "... {{count}} mere vært",
|
||||
"moreHosts_other": "... {{count}} flere værter",
|
||||
"summary": "{{selected}} af {{total}} værter",
|
||||
"credentialsIncluded": "legitimationsoplysninger inkluderet",
|
||||
"credentialsExcluded": "legitimationsoplysninger ekskluderet",
|
||||
"noneSelected": "Ingen værter valgt",
|
||||
"cancel": "Annuller",
|
||||
"confirm": "Eksportér",
|
||||
"fetchFailed": "Kunne ikke indlæse værter til eksport",
|
||||
"bulkButton": "Eksportér"
|
||||
},
|
||||
"sampleDownloaded": "Eksempelfil downloadet",
|
||||
"failedToDeleteCredential2": "Kunne ikke slette legitimationsoplysninger",
|
||||
"noFolderOption": "(Ingen mappe)",
|
||||
@@ -820,7 +860,11 @@
|
||||
"editorUpdate": "Opdater vært",
|
||||
"editorAdd": "Tilføj vært",
|
||||
"speedSearch": "Søg i værter. Piltaster til valg, Enter til forbindelse, Esc til lukning",
|
||||
"speedSearchList": "Værtliste. Skriv eller tryk / for at søge."
|
||||
"speedSearchList": "Værtliste. Skriv eller tryk / for at søge.",
|
||||
"importNoHosts": "Ingen værter fundet i filen",
|
||||
"importTooMany": "Du kan importere op til {{max}} værter ad gangen",
|
||||
"importSummary": "Import fuldført: {{imported}} importeret, {{updated}} opdateret, {{failed}} fejlet",
|
||||
"importHostsFailed": "Kunne ikke importere værter"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Delt skærm",
|
||||
@@ -1071,57 +1115,17 @@
|
||||
"colorPickerTooltip": "Åbn farvevælger",
|
||||
"themeSystem": "System",
|
||||
"themeLight": "Lys",
|
||||
"themeDark": "Mørk"
|
||||
"themeDark": "Mørk",
|
||||
"themeDracula": "Dracula",
|
||||
"themeCatppuccin": "Catppuccin",
|
||||
"themeNord": "Nord",
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "Hvordan hele appen er indrettet. Temaer og layouts kommer fra plugins.",
|
||||
"defaultsLabel": "Standard for alle",
|
||||
"defaultsHint": "Hvad folk ser, indtil de vælger deres eget layout eller tema.",
|
||||
"followDefault": "Brug standardindstillingen",
|
||||
"defaultSaved": "Standardudseende gemt",
|
||||
"saveFailed": "Kunne ikke gemme udseendet",
|
||||
"repair": {
|
||||
"title": "Dette layout kunne ikke vises",
|
||||
"safeTitle": "Sikker tilstand",
|
||||
"layout": "Layout",
|
||||
"plugin": "Plugin",
|
||||
"reason": "Årsag",
|
||||
"error": "Fejl",
|
||||
"noError": "Ingen fejlmeddelelse.",
|
||||
"copy": "Kopiér",
|
||||
"copied": "Kopieret",
|
||||
"copyFailed": "Kunne ikke kopiere detaljerne",
|
||||
"retry": "Prøv igen",
|
||||
"reset": "Nulstil til standardudseende",
|
||||
"resetEveryone": "Nulstil standardindstillingen for alle",
|
||||
"update": "Opdater {{name}} til {{version}}",
|
||||
"disable": "Sluk {{name}}",
|
||||
"install": "Find layouts og temaer",
|
||||
"installTitle": "Layouts og temaer",
|
||||
"installButton": "Installer",
|
||||
"nothingToInstall": "Der er ikke andre layouts eller temaer tilgængelige lige nu.",
|
||||
"registryUnreachable": "Plugin-registret kunne ikke nås.",
|
||||
"askAdmin": "Bed en administrator om at opdatere eller reparere dette layout. Du kan skifte til et andet layout eller tema for dig selv nedenfor.",
|
||||
"switchLayout": "Skift layout",
|
||||
"switchTheme": "Skift tema",
|
||||
"saveFailed": "Kunne ikke gemme dit valg",
|
||||
"reasons": {
|
||||
"safe-url": "Adressen bad om sikker tilstand. Vælg et layout nedenfor, eller fjern ?safe-layout=1 fra adressen.",
|
||||
"safe-env": "Serveren er startet med TERMIX_SAFE_LAYOUT, så alle ser denne skærm, indtil den fjernes.",
|
||||
"missing": "Intet installeret plugin leverer dette layout.",
|
||||
"disabled": "Pluginet, der leverer dette layout, er slukket.",
|
||||
"blocked": "Pluginet, der leverer dette layout, venter på noget, det har brug for.",
|
||||
"incompatible": "Pluginet, der leverer dette layout, fungerer ikke med denne version af Termix.",
|
||||
"failed": "Layoutet kunne ikke starte.",
|
||||
"crashed": "Layoutet kørte to gange i jorden, mens appen blev tegnet."
|
||||
},
|
||||
"noLayout": "Intet layout er indstillet",
|
||||
"restore": "Gendan den medfølgende {{name}}",
|
||||
"restoreFailed": "Kunne ikke gendanne den medfølgende kopi"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1144,18 +1148,6 @@
|
||||
"title": "Håndter login",
|
||||
"consequence": "Den kan tilføje en loginmetode, en anden faktor eller en SSH-login-type."
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "Indstil loginsregler",
|
||||
"consequence": "Det kan logge folk ud, når de er inaktive, begrænse sessionens længde, slukke for \"Husk mig\" og kræve en anden faktor."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Vælg, hvordan værter nås",
|
||||
"consequence": "Det kan bære SSH-forbindelsen til en vært over sin egen rute, så det ser alt, der sendes til og fra disse værter, inklusive adgangskoder."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Kør synkronisering",
|
||||
"consequence": "Det kan besvare alle synkroniseringsanmodninger, så det ser de data, som enheder og servere synkroniserer."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Administrer certifikater",
|
||||
"consequence": "Den kan erstatte det certifikat, Termix serverer over HTTPS, og besvare domænekontroller fra certifikatautoriteter."
|
||||
@@ -1164,10 +1156,6 @@
|
||||
"title": "Adgang til serielle og USB-enheder",
|
||||
"consequence": "Den kan åbne en fysisk seriell port på maskinen, der kører Termix."
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "Eksportér værter med deres hemmeligheder",
|
||||
"consequence": "Kan læse dine værter i fuld udstrækning, inklusive gemte adgangskoder og nøgler, og udlevere dem. Hver eksport skrives til revisionsloggen."
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "Opret og ændre værter",
|
||||
"consequence": "Den kan tilføje værter og redigere dem, du allerede har."
|
||||
@@ -1212,30 +1200,10 @@
|
||||
"title": "Læs revisionsloggen",
|
||||
"consequence": "Den kan se, hvem der gjorde hvad og hvornår."
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "Tjek, om porte svarer",
|
||||
"consequence": "Kan åbne en simpel forbindelse til enhver adresse fra serveren for at se, om den er tilgængelig."
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "Lever værstatus",
|
||||
"consequence": "Bliver kilden til de online- og offline-prikker for hver vært."
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "Gem seneste aktivitet",
|
||||
"consequence": "Bliver lageret for den seneste aktivitetsliste for hver bruger."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "Administrer værter som skrivebeskyttet",
|
||||
"consequence": "Værter, den administrerer, kan ikke redigeres, slettes eller deles videre af andre."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Åbn skrivebordsvinduer",
|
||||
"consequence": "Kun i Termix-skivebordsappen."
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "Brug denne computer",
|
||||
"consequence": "Kan læse og ændre filer, køre en shell og åbne lokale porte på denne computer. Kun i Termix desktop-appen."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "Udfør som andre brugere",
|
||||
"consequence": "Kører baggrundsarbejde med en anden brugers adgang, f.eks. planlagte jobs. Hver brug skrives til revisionsloggen."
|
||||
@@ -1275,14 +1243,6 @@
|
||||
"ui:surface": {
|
||||
"title": "Tilføje sine egne skærme",
|
||||
"consequence": "Faner, paneler og indstillinger, du kan skjule senere."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "Erstat hele appens layout",
|
||||
"consequence": "Når valgt, tegner dens kode hver skærm, inklusive dine terminaler og indstillinger."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "Tilføj farvetemaer",
|
||||
"consequence": "Kun farver og stilarter. Der køres ingen kode for et tema."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1412,7 +1372,6 @@
|
||||
"options": "Kunne ikke gemme indstillinger for {{name}}: {{error}}",
|
||||
"deleteData": "Kunne ikke slette data for {{name}}: {{error}}",
|
||||
"managedByServer": "Denne desktop er knyttet til en server, som bestemmer dens plugins.",
|
||||
"defaultLook": "{{name}} er standardudseendet, så det kan slås fra eller skiftes, men ikke afinstalleres.",
|
||||
"hasDependents": "{{name}} er nødvendig for {{list}}. Sluk dem eller afinstaller dem først.",
|
||||
"missingDependencies": "{{name}} har brug for {{list}}, som ikke er installeret.",
|
||||
"consentChanged": "Det, dette plugin anmoder om, er ændret siden du kiggede. Tjek listen igen.",
|
||||
@@ -1482,6 +1441,7 @@
|
||||
"noServerAccess": "Ingen adgang til dine servere",
|
||||
"searchInstalled": "Søg i installerede",
|
||||
"searchPlugins": "Søg i plugins",
|
||||
"allCategories": "Alle",
|
||||
"nothingMatches": "Intet matcher dette.",
|
||||
"noneInstalled": "Ingen plugins er installeret endnu. Gennemse for at tilføje nogle.",
|
||||
"nothingHere": "Intet her.",
|
||||
@@ -1571,44 +1531,7 @@
|
||||
"documentation": "Dokumentation",
|
||||
"envVars": "Miljøvariabler",
|
||||
"envRequired": "Påkrævet",
|
||||
"envDefault": "Standard: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "Layouts",
|
||||
"themes": "Temaer",
|
||||
"tabs": "Faner",
|
||||
"panels": "Sidepaneler",
|
||||
"dashboardCards": "Dashboard-kort",
|
||||
"protocols": "Forbindelsestyper",
|
||||
"auth": "Login-metoder",
|
||||
"actions": "Handlinger",
|
||||
"hostCapability": "Værtsindstillinger"
|
||||
},
|
||||
"filters": {
|
||||
"type": "Type",
|
||||
"allTypes": "Alle typer",
|
||||
"category": "Kategori",
|
||||
"allCategories": "Alle kategorier",
|
||||
"results_one": "{{count}} plugin",
|
||||
"results_other": "{{count}} plugins",
|
||||
"remove": "Fjern filter {{name}}",
|
||||
"clear": "Ryd filtre",
|
||||
"button": "Filtre",
|
||||
"buttonCount": "Filtre ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} af {{total}}",
|
||||
"page": "Side {{page}} af {{pages}}",
|
||||
"previous": "Forrige side",
|
||||
"next": "Næste side",
|
||||
"perPage": "{{count}} pr. side",
|
||||
"perPageLabel": "Plugins pr. side"
|
||||
},
|
||||
"showUnsupportedHint": "Disse er opført, men der er endnu ingen udgivelse, der kører på denne version. Opdater Termix for at installere dem.",
|
||||
"unsupported": "Kræver en nyere Termix",
|
||||
"unsupportedHint": "Der er ingen udgivelse af dette plugin, der kører på denne version af Termix. Opdater Termix for at installere det.",
|
||||
"needsTermix": "Kræver Termix {{range}}",
|
||||
"showUnsupported": "Vis understøttet ({{count}})",
|
||||
"showMore": "Vis {{count}} mere ({{total}} tilbage)"
|
||||
"envDefault": "Standard: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1624,6 +1547,87 @@
|
||||
"noData": "Ingen data",
|
||||
"filter": "Filtrer..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "{{count}} minut siden",
|
||||
"minutesAgo_other": "{{count}} minutter siden",
|
||||
"hoursAgo_one": "{{count}} time siden",
|
||||
"hoursAgo_other": "{{count}} timer siden",
|
||||
"pending_one": "{{count}} ændring venter på synkronisering",
|
||||
"pending_other": "{{count}} ændringer venter på synkronisering",
|
||||
"proxy": {
|
||||
"headersTitle": "Ekstra headers",
|
||||
"headersHint": "Sendes med hver anmodning til serveren, f.eks. en Cloudflare Access service-token.",
|
||||
"headerName": "Header-navn",
|
||||
"headerValue": "Værdi",
|
||||
"savedValue": "Gemt, skriv for at ændre",
|
||||
"addHeader": "Tilføj header",
|
||||
"removeHeader": "Fjern header",
|
||||
"basicAuthTitle": "Brugernavn og adgangskode",
|
||||
"basicAuthHint": "Til en proxy, der beder om brugernavn og adgangskode før Termix.",
|
||||
"username": "Brugernavn",
|
||||
"password": "Adgangskode",
|
||||
"allowInvalidCertificate": "Stol på et selvsigneret certifikat",
|
||||
"allowInvalidCertificateHint": "Kun til din egen server, når dens certifikat ikke er fra en kendt autoritet."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "Det ligner ikke en webadresse.",
|
||||
"unreachable": "Kunne ikke nå serveren. Tjek adressen og at serveren kører.",
|
||||
"unreachableWithReason": "Kunne ikke nå serveren: {{reason}}",
|
||||
"tlsUntrusted": "Servercertifikatet er ikke betroet. Hvis dette er din egen server med et selvsigneret certifikat, skal du slå indstillingen nedenfor til.",
|
||||
"basicAuthRequired": "Noget foran serveren beder om et brugernavn og en adgangskode. Indtast dem nedenfor.",
|
||||
"proxyLogin": "En login-side eller en proxy svarede i stedet for Termix. Tilføj de headers, den har brug for, nedenfor, eller tjek adressen.",
|
||||
"notTermix": "Der er ingen Termix-server på den adresse. Hvis den kører under en sti, skal du inkludere den, f.eks. https://example.com/termix.",
|
||||
"versionMismatch": "Den server kører en ældre version af Termix. Opdater den til 2.9 eller nyere for at linke.",
|
||||
"differentAccount": "Du er logget ind på en anden konto. Log ind på den konto, denne enhed er linket til, eller fjern linket først."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Link til en Termix-server",
|
||||
"reloginTitle": "Log ind igen",
|
||||
"savedServers": "Seneste servere",
|
||||
"removeServer": "Fjern fra listen",
|
||||
"serverDescription": "Indtast den adresse, du bruger til at åbne Termix i din browser.",
|
||||
"signinDescription": "Log ind på {{url}}",
|
||||
"chooseDescription": "Denne enhed har allerede data. Hvad skal der ske med dem?",
|
||||
"finishingDescription": "Linker denne enhed.",
|
||||
"serverUrl": "Serveradresse",
|
||||
"serverUrlHint": "For eksempel https://termix.example.com",
|
||||
"showProxySettings": "Indstillinger for reverse proxy",
|
||||
"hideProxySettings": "Skjul indstillinger for reverse proxy",
|
||||
"continue": "Fortsæt",
|
||||
"back": "Tilbage",
|
||||
"mergeTitle": "Tilføj det til min konto",
|
||||
"mergeBody_one": "Elementet på denne enhed tilføjes til din konto. Elementer, din konto allerede har, forbliver som de er på serveren.",
|
||||
"mergeBody_other": "De {{count}} elementer på denne enhed tilføjes til din konto. Elementer, din konto allerede har, forbliver som de er på serveren.",
|
||||
"replaceTitle": "Erstat det med min konto",
|
||||
"replaceBody_one": "Elementet på denne enhed fjernes, og denne enhed tager alt fra din konto. Værter, der kun er gemt på denne enhed, forbliver.",
|
||||
"replaceBody_other": "De {{count}} elementer på denne enhed fjernes, og denne enhed tager alt fra din konto. Værter, der kun er gemt på denne enhed, forbliver.",
|
||||
"link": "Link",
|
||||
"linking": "Linker og kører den første synkronisering...",
|
||||
"linked": "Linket. Dine data synkroniseres nu.",
|
||||
"signedInAgain": "Logget ind. Synkroniseringen kører igen."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Værter",
|
||||
"sshCredentials": "Legitimationsoplysninger",
|
||||
"sshFolders": "Mapper",
|
||||
"userPreferences": "Præferencer",
|
||||
"pluginUserSettings": "Plugin-indstillinger",
|
||||
"sharedHosts": "Værter delt med dig",
|
||||
"sharedCredentials": "Legitimationsoplysninger delt med dig",
|
||||
"vaultProfiles": "Vault-profiler",
|
||||
"snippetFolders": "Snippet-mapper",
|
||||
"snippets": "Snippets",
|
||||
"dashboardServiceLinks": "Service-links",
|
||||
"homepageItems": "Startside-elementer",
|
||||
"networkTopology": "Netværkskort",
|
||||
"workspaces": "Arbejdsområder",
|
||||
"hostDefaults": "Standardindstillinger for værter",
|
||||
"hostDefaultsAdmin": "Standardindstillinger for serverværter",
|
||||
"aiProviders": "AI-leverandører",
|
||||
"homepageLayouts": "Startside-layouts",
|
||||
"accountProfile": "Kontoprofil"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Mappe {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "Einige Plugins konnten nicht geändert werden. Sie können sie später im Tab „Plugins“ beheben. Drücken Sie Weiter, um fortzufahren.",
|
||||
"pluginsApplyFailed": "Ihre Auswahl konnte nicht gespeichert werden. Versuchen Sie es erneut oder fahren Sie fort und ändern Sie sie später im Tab „Plugins“.",
|
||||
"pluginsLoadFailed": "Die Plugin-Liste konnte nicht geladen werden. Sie können Plugins später im Tab „Plugins“ auswählen.",
|
||||
"presetTitle": "Wie viel möchten Sie auf dem Bildschirm sehen?",
|
||||
"presetIntro": "Wählen Sie aus, wie viel auf dem Bildschirm angezeigt wird.",
|
||||
"presetChangeLater": "Sie können später in den Einstellungen unter „Schnittstelle“ zwischen Voreinstellungen wechseln oder einzelne Einstellungen ändern.",
|
||||
"appearanceTitle": "Machen Sie es zu Ihrem eigenen",
|
||||
"appearanceIntro": "Wählen Sie ein Design, eine Akzentfarbe, eine Schriftart und Ihre Sprache. Änderungen werden sofort angezeigt und können später in den Einstellungen geändert werden.",
|
||||
"securityTitle": "Sichern Sie Ihr Konto",
|
||||
"securityIntro": "Fügen Sie Ihrem Konto einen Passkey oder eine Authenticator-App hinzu. Sie können dies auch später in den Einstellungen unter „Sicherheit“ tun.",
|
||||
"desktopTitle": "Wie möchten Sie Termix verwenden?",
|
||||
"desktopBody": "Sie können dies jederzeit über „Sync“ in der Seitenleiste ändern.",
|
||||
"desktopLocalTitle": "Nur auf diesem Gerät",
|
||||
"desktopLocalBody": "Alles wird hier gespeichert. Keine Einrichtung erforderlich.",
|
||||
"desktopLinkTitle": "Mit meinem Termix-Server",
|
||||
"desktopLinkBody": "Melden Sie sich bei Ihrem Server an, um dieselben Hosts und Einstellungen auf jedem Gerät zu verwenden.",
|
||||
"desktopLinked": "Verknüpft mit {{server}}",
|
||||
"pluginsDocsLink": "Mehr über jedes Plugin erfahren"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Termix {{version}} ist bereit zur Installation",
|
||||
"readyHint": "Es wird beim nächsten Neustart von Termix installiert."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Panel schließen",
|
||||
"moveSide": "Panel verschieben",
|
||||
"resize": "Panelgröße ändern"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Startseite",
|
||||
"terminal": "Terminal",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "Hosts",
|
||||
"credentials": "Anmeldeinformationen",
|
||||
"connections": "Verbindungen",
|
||||
"sync": "Synchronisieren",
|
||||
"reconnectDisconnectedTerminals": "Alle getrennten Terminals wiederherstellen",
|
||||
"reconnectingTerminals_one": "Stelle {{count}} Terminal wieder her",
|
||||
"reconnectingTerminals_other": "Stelle {{count}} Terminals wieder her",
|
||||
@@ -271,7 +279,10 @@
|
||||
"retry": "Wiederholen",
|
||||
"refresh": "Aktualisieren",
|
||||
"optional": "Optional",
|
||||
"downloadSample": "Beispiel herunterladen",
|
||||
"failedToDeleteHost": "{{name}} konnte nicht gelöscht werden",
|
||||
"importSkipExisting": "Importieren (vorhandene überspringen)",
|
||||
"importSSHConfig": "Aus SSH-Konfiguration importieren",
|
||||
"connectionDetails": "Verbindungsdetails",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -490,7 +501,10 @@
|
||||
"noFolder": "(Kein Ordner)",
|
||||
"deleteSelected": "Löschen",
|
||||
"exitSelection": "Auswahl beenden",
|
||||
"importSkip": "Importieren (vorhandene überspringen)",
|
||||
"importOverwrite": "Importieren (überschreiben)",
|
||||
"collapseBtn": "Einklappen",
|
||||
"importExportBtn": "Importieren / Exportieren",
|
||||
"hostStatusesRefreshed": "Host-Status aktualisiert",
|
||||
"failedToRefreshHosts": "Aktualisieren der Hosts fehlgeschlagen",
|
||||
"movedHostTo": "{{host}} nach \"{{folder}}\" verschoben",
|
||||
@@ -545,9 +559,6 @@
|
||||
"deleteFolderConfirm_one": "\"{{name}}\" und seinen {{count}} Host löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"deleteFolderConfirm_other": "\"{{name}}\" und seine {{count}} Hosts löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"failedToMoveHosts": "Hosts konnten nicht verschoben werden",
|
||||
"managedBanner": "Dieser Host wird von {{plugin}} verwaltet. Ändere ihn dort. Du kannst ihn weiterhin anheften und neu anordnen.",
|
||||
"managedTooltip": "Verwaltet von {{plugin}}",
|
||||
"addBlocked": "Hosts werden von {{plugin}} verwaltet, daher können hier keine neuen Hosts hinzugefügt werden.",
|
||||
"failedToReorder": "Neuanordnung fehlgeschlagen",
|
||||
"expandAll": "Alle Verzeichnisse aufklappen",
|
||||
"collapseAll": "Alle Verzeichnisse zuklappen",
|
||||
@@ -658,6 +669,35 @@
|
||||
"addCredentialBtn2": "Anmeldeinformationen hinzufügen",
|
||||
"checkingHostStatuses": "Host-Status werden überprüft...",
|
||||
"pinnedSection": "Angeheftet",
|
||||
"hostsExported": "Hosts erfolgreich exportiert",
|
||||
"export": {
|
||||
"menuItem": "Exportieren...",
|
||||
"title": "Hosts exportieren",
|
||||
"scope": "Umfang",
|
||||
"scopeAll": "Alle",
|
||||
"scopeSelected": "Ausgewählt",
|
||||
"searchHosts": "Hosts durchsuchen...",
|
||||
"include": "Einschließen",
|
||||
"groupConnection": "Verbindung",
|
||||
"groupCredentials": "Anmeldedaten",
|
||||
"groupNotes": "Notizen",
|
||||
"groupTags": "Tags & Anheften",
|
||||
"groupProxy": "Proxy",
|
||||
"groupJumpHosts": "Jump Hosts",
|
||||
"groupFeatureFlags": "Plugin-Einstellungen",
|
||||
"groupAdvanced": "Erweiterte Konfiguration",
|
||||
"preview": "Vorschau",
|
||||
"moreHosts_one": "... {{count}} weiterer Host",
|
||||
"moreHosts_other": "... {{count}} weitere Hosts",
|
||||
"summary": "{{selected}} von {{total}} Hosts",
|
||||
"credentialsIncluded": "Anmeldedaten enthalten",
|
||||
"credentialsExcluded": "Anmeldedaten ausgeschlossen",
|
||||
"noneSelected": "Keine Hosts ausgewählt",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Exportieren",
|
||||
"fetchFailed": "Hosts konnten nicht für den Export geladen werden",
|
||||
"bulkButton": "Exportieren"
|
||||
},
|
||||
"sampleDownloaded": "Beispieldatei heruntergeladen",
|
||||
"failedToDeleteCredential2": "Anmeldedaten konnten nicht gelöscht werden",
|
||||
"noFolderOption": "(Kein Ordner)",
|
||||
@@ -820,7 +860,11 @@
|
||||
"editorUpdate": "Host aktualisieren",
|
||||
"editorAdd": "Host hinzufügen",
|
||||
"speedSearch": "Hosts durchsuchen. Pfeiltasten zum Auswählen, Enter zum Verbinden, Esc zum Schließen",
|
||||
"speedSearchList": "Hostliste. Tippen Sie oder drücken Sie / zum Suchen."
|
||||
"speedSearchList": "Hostliste. Tippen Sie oder drücken Sie / zum Suchen.",
|
||||
"importNoHosts": "Keine Hosts in der Datei gefunden",
|
||||
"importTooMany": "Sie können auf einmal bis zu {{max}} Hosts importieren",
|
||||
"importSummary": "Import abgeschlossen: {{imported}} importiert, {{updated}} aktualisiert, {{failed}} fehlgeschlagen",
|
||||
"importHostsFailed": "Import von Hosts fehlgeschlagen"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Geteilter Bildschirm",
|
||||
@@ -1071,57 +1115,17 @@
|
||||
"colorPickerTooltip": "Farbauswahl öffnen",
|
||||
"themeSystem": "System",
|
||||
"themeLight": "Hell",
|
||||
"themeDark": "Dunkel"
|
||||
"themeDark": "Dunkel",
|
||||
"themeDracula": "Dracula",
|
||||
"themeCatppuccin": "Catppuccin",
|
||||
"themeNord": "Nord",
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "Wie die gesamte App angeordnet ist. Themes und Layouts stammen von Plugins.",
|
||||
"defaultsLabel": "Standard für alle",
|
||||
"defaultsHint": "Was Benutzer sehen, bis sie ihr eigenes Layout oder Theme auswählen.",
|
||||
"followDefault": "Standard verwenden",
|
||||
"defaultSaved": "Standardansicht gespeichert",
|
||||
"saveFailed": "Ansicht konnte nicht gespeichert werden",
|
||||
"repair": {
|
||||
"title": "Dieses Layout konnte nicht angezeigt werden",
|
||||
"safeTitle": "Sicherer Modus",
|
||||
"layout": "Layout",
|
||||
"plugin": "Plugin",
|
||||
"reason": "Grund",
|
||||
"error": "Fehler",
|
||||
"noError": "Keine Fehlermeldung.",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"copyFailed": "Details konnten nicht kopiert werden",
|
||||
"retry": "Erneut versuchen",
|
||||
"reset": "Auf Standardansicht zurücksetzen",
|
||||
"resetEveryone": "Standard für alle zurücksetzen",
|
||||
"update": "{{name}} auf {{version}} aktualisieren",
|
||||
"disable": "{{name}} deaktivieren",
|
||||
"install": "Layouts und Themes finden",
|
||||
"installTitle": "Layouts und Themes",
|
||||
"installButton": "Installieren",
|
||||
"nothingToInstall": "Derzeit sind keine anderen Layouts oder Themes verfügbar.",
|
||||
"registryUnreachable": "Das Plugin-Register konnte nicht erreicht werden.",
|
||||
"askAdmin": "Bitte einen Administrator, dieses Layout zu aktualisieren oder zu reparieren. Du kannst unten auf ein anderes Layout oder Theme umschalten.",
|
||||
"switchLayout": "Layout wechseln",
|
||||
"switchTheme": "Theme wechseln",
|
||||
"saveFailed": "Deine Auswahl konnte nicht gespeichert werden",
|
||||
"reasons": {
|
||||
"safe-url": "Die Adresse hat den sicheren Modus angefordert. Wähle unten ein Layout oder entferne ?safe-layout=1 aus der Adresse.",
|
||||
"safe-env": "Der Server wurde mit TERMIX_SAFE_LAYOUT gestartet, daher sehen alle Benutzer diesen Bildschirm, bis dies entfernt wird.",
|
||||
"missing": "Kein installiertes Plugin stellt dieses Layout bereit.",
|
||||
"disabled": "Das Plugin, das dieses Layout bereitstellt, ist deaktiviert.",
|
||||
"blocked": "Das Plugin, das dieses Layout bereitstellt, wartet auf eine benötigte Ressource.",
|
||||
"incompatible": "Das Plugin, das dieses Layout bereitstellt, ist mit dieser Version von Termix nicht kompatibel.",
|
||||
"failed": "Das Layout konnte nicht gestartet werden.",
|
||||
"crashed": "Das Layout ist beim Rendern der App zweimal abgestürzt."
|
||||
},
|
||||
"noLayout": "Kein Layout festgelegt",
|
||||
"restore": "Beigefügtes {{name}} wiederherstellen",
|
||||
"restoreFailed": "Beigefügte Kopie konnte nicht wiederhergestellt werden"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1144,18 +1148,6 @@
|
||||
"title": "Anmeldung verwalten",
|
||||
"consequence": "Es kann eine Anmeldemethode, einen zweiten Faktor oder einen SSH-Anmeldetyp hinzufügen."
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "Anmelderegeln festlegen",
|
||||
"consequence": "Es kann Benutzer bei Inaktivität abmelden, die Sitzungsdauer begrenzen, „Angemeldet bleiben“ deaktivieren und einen zweiten Faktor erfordern."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Wählen, wie Hosts erreicht werden",
|
||||
"consequence": "Es kann die SSH-Verbindung zu einem Host über seinen eigenen Pfad leiten, sodass es alles sieht, was zu und von diesen Hosts gesendet wird, einschließlich Passwörtern."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Synchronisierung ausführen",
|
||||
"consequence": "Es kann alle Synchronisierungsanfragen beantworten, sodass es die Daten sieht, die Geräte und Server synchronisieren."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Zertifikate verwalten",
|
||||
"consequence": "Es kann das Zertifikat ersetzen, das Termix über HTTPS bereitstellt, und Domänenprüfungen von Zertifizierungsstellen beantworten."
|
||||
@@ -1164,10 +1156,6 @@
|
||||
"title": "Auf serielle und USB-Geräte zugreifen",
|
||||
"consequence": "Es kann einen physischen seriellen Port auf dem Computer öffnen, auf dem Termix läuft."
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "Hosts mit ihren Geheimnissen exportieren",
|
||||
"consequence": "Kann deine Hosts vollständig lesen, einschließlich gespeicherter Passwörter und Schlüssel, und sie weitergeben. Jeder Export wird im Audit-Log protokolliert."
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "Hosts erstellen und ändern",
|
||||
"consequence": "Es kann Hosts hinzufügen und bestehende bearbeiten."
|
||||
@@ -1212,30 +1200,10 @@
|
||||
"title": "Audit-Log lesen",
|
||||
"consequence": "Es kann sehen, wer was und wann getan hat."
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "Prüfen, ob Ports antworten",
|
||||
"consequence": "Kann eine unverschlüsselte Verbindung zu jeder Adresse vom Server aus öffnen, um zu sehen, ob sie erreichbar ist."
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "Hoststatus bereitstellen",
|
||||
"consequence": "Wird zur Quelle der Online- und Offline-Punkte für jeden Host."
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "Letzte Aktivitäten speichern",
|
||||
"consequence": "Dient als Speicher für die Liste der letzten Aktivitäten jedes Nutzers."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "Hosts schreibgeschützt verwalten",
|
||||
"consequence": "Die verwalteten Hosts können von niemandem anderem bearbeitet, gelöscht oder erneut geteilt werden."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Desktop-Fenster öffnen",
|
||||
"consequence": "Nur in der Termix-Desktop-App."
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "Diesen Computer verwenden",
|
||||
"consequence": "Kann auf diesem Computer Dateien lesen und ändern, eine Shell ausführen und lokale Ports öffnen. Nur in der Termix-Desktop-App."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "Als andere Benutzer handeln",
|
||||
"consequence": "Führt Hintergrundarbeit mit dem Zugriff eines anderen Benutzers aus, z. B. geplante Jobs. Jede Verwendung wird im Audit-Log festgehalten."
|
||||
@@ -1275,14 +1243,6 @@
|
||||
"ui:surface": {
|
||||
"title": "Eigene Bildschirme hinzufügen",
|
||||
"consequence": "Tabs, Panels und Einstellungen, die Sie später ausblenden können."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "Gesamtes App-Layout ersetzen",
|
||||
"consequence": "Wenn ausgewählt, zeichnet sein Code jeden Bildschirm, einschließlich Ihrer Terminals und Einstellungen."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "Farbschemata hinzufügen",
|
||||
"consequence": "Nur Farben und Stile. Für ein Farbschema wird kein Code ausgeführt."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1412,7 +1372,6 @@
|
||||
"options": "Optionen für {{name}} konnten nicht gespeichert werden: {{error}}",
|
||||
"deleteData": "Daten für {{name}} konnten nicht gelöscht werden: {{error}}",
|
||||
"managedByServer": "Dieser Desktop ist mit einem Server verknüpft, der die Plugins bestimmt.",
|
||||
"defaultLook": "{{name}} ist das Standardschema, daher kann es deaktiviert oder ersetzt, aber nicht deinstalliert werden.",
|
||||
"hasDependents": "{{name}} wird von {{list}} benötigt. Deaktiviere oder deinstalliere diese zuerst.",
|
||||
"missingDependencies": "{{name}} benötigt {{list}}, das nicht installiert ist.",
|
||||
"consentChanged": "Die Anforderungen dieses Plugins haben sich seit deinem letzten Blick geändert. Prüfe die Liste erneut.",
|
||||
@@ -1482,6 +1441,7 @@
|
||||
"noServerAccess": "Kein Zugriff auf Ihre Server",
|
||||
"searchInstalled": "Installierte durchsuchen",
|
||||
"searchPlugins": "Plugins durchsuchen",
|
||||
"allCategories": "Alle",
|
||||
"nothingMatches": "Nichts passt dazu.",
|
||||
"noneInstalled": "Noch keine Plugins installiert. Stöbern Sie, um welche hinzuzufügen.",
|
||||
"nothingHere": "Hier ist nichts.",
|
||||
@@ -1571,44 +1531,7 @@
|
||||
"documentation": "Dokumentation",
|
||||
"envVars": "Umgebungsvariablen",
|
||||
"envRequired": "Erforderlich",
|
||||
"envDefault": "Standard: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "Layouts",
|
||||
"themes": "Farbschemata",
|
||||
"tabs": "Tabs",
|
||||
"panels": "Seitenleisten-Panel",
|
||||
"dashboardCards": "Dashboard-Karten",
|
||||
"protocols": "Verbindungstypen",
|
||||
"auth": "Anmeldemethoden",
|
||||
"actions": "Aktionen",
|
||||
"hostCapability": "Host-Einstellungen"
|
||||
},
|
||||
"filters": {
|
||||
"type": "Typ",
|
||||
"allTypes": "Alle Typen",
|
||||
"category": "Kategorie",
|
||||
"allCategories": "Alle Kategorien",
|
||||
"results_one": "{{count}} Plugin",
|
||||
"results_other": "{{count}} Plugins",
|
||||
"remove": "Filter {{name}} entfernen",
|
||||
"clear": "Filter löschen",
|
||||
"button": "Filter",
|
||||
"buttonCount": "Filter ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} von {{total}}",
|
||||
"page": "Seite {{page}} von {{pages}}",
|
||||
"previous": "Vorherige Seite",
|
||||
"next": "Nächste Seite",
|
||||
"perPage": "{{count}} pro Seite",
|
||||
"perPageLabel": "Plugins pro Seite"
|
||||
},
|
||||
"showUnsupportedHint": "Diese werden aufgelistet, aber es gibt noch keine Version, die mit dieser Version läuft. Aktualisieren Sie Termix, um sie zu installieren.",
|
||||
"unsupported": "Benötigt eine neuere Termix-Version",
|
||||
"unsupportedHint": "Keine Version dieses Plugins läuft mit dieser Version von Termix. Aktualisieren Sie Termix, um es zu installieren.",
|
||||
"needsTermix": "Benötigt Termix {{range}}",
|
||||
"showUnsupported": "Nicht unterstützte anzeigen ({{count}})",
|
||||
"showMore": "{{count}} weitere anzeigen ({{total}} übrig)"
|
||||
"envDefault": "Standard: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1624,6 +1547,87 @@
|
||||
"noData": "Keine Daten",
|
||||
"filter": "Filtern..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "vor {{count}} Minute",
|
||||
"minutesAgo_other": "vor {{count}} Minuten",
|
||||
"hoursAgo_one": "vor {{count}} Stunde",
|
||||
"hoursAgo_other": "vor {{count}} Stunden",
|
||||
"pending_one": "{{count}} Änderung wartet auf Synchronisierung",
|
||||
"pending_other": "{{count}} Änderungen warten auf Synchronisierung",
|
||||
"proxy": {
|
||||
"headersTitle": "Zusätzliche Header",
|
||||
"headersHint": "Wird mit jeder Anfrage an den Server gesendet, z. B. ein Cloudflare Access Service-Token.",
|
||||
"headerName": "Header-Name",
|
||||
"headerValue": "Wert",
|
||||
"savedValue": "Gespeichert, zum Ändern tippen",
|
||||
"addHeader": "Header hinzufügen",
|
||||
"removeHeader": "Header entfernen",
|
||||
"basicAuthTitle": "Benutzername und Passwort",
|
||||
"basicAuthHint": "Für einen Proxy, der vor Termix nach Benutzername und Passwort fragt.",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"allowInvalidCertificate": "Selbstsigniertes Zertifikat vertrauen",
|
||||
"allowInvalidCertificateHint": "Nur für Ihren eigenen Server, wenn dessen Zertifikat nicht von einer bekannten Stelle stammt."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "Das sieht nicht nach einer Webadresse aus.",
|
||||
"unreachable": "Der Server konnte nicht erreicht werden. Überprüfen Sie die Adresse und stellen Sie sicher, dass der Server läuft.",
|
||||
"unreachableWithReason": "Der Server konnte nicht erreicht werden: {{reason}}",
|
||||
"tlsUntrusted": "Das Zertifikat des Servers ist nicht vertrauenswürdig. Wenn dies Ihr eigener Server mit einem selbstsignierten Zertifikat ist, aktivieren Sie die Option unten.",
|
||||
"basicAuthRequired": "Ein System vor dem Server fordert einen Benutzernamen und ein Passwort an. Geben Sie diese unten ein.",
|
||||
"proxyLogin": "Eine Anmeldeseite oder ein Proxy hat anstelle von Termix geantwortet. Fügen Sie unten die erforderlichen Header hinzu oder überprüfen Sie die Adresse.",
|
||||
"notTermix": "Unter dieser Adresse befindet sich kein Termix-Server. Wenn er unter einem Pfad läuft, fügen Sie diesen hinzu, z. B. https://example.com/termix.",
|
||||
"versionMismatch": "Dieser Server verwendet eine ältere Version von Termix. Aktualisieren Sie ihn auf Version 2.9 oder höher, um ihn zu verknüpfen.",
|
||||
"differentAccount": "Sie haben sich bei einem anderen Konto angemeldet. Melden Sie sich beim Konto an, mit dem dieses Gerät verknüpft ist, oder trennen Sie zuerst die Verknüpfung."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Mit einem Termix-Server verknüpfen",
|
||||
"reloginTitle": "Erneut anmelden",
|
||||
"savedServers": "Zuletzt verwendete Server",
|
||||
"removeServer": "Aus der Liste entfernen",
|
||||
"serverDescription": "Geben Sie die Adresse ein, die Sie verwenden, um Termix in Ihrem Browser zu öffnen.",
|
||||
"signinDescription": "Bei {{url}} anmelden",
|
||||
"chooseDescription": "Dieses Gerät enthält bereits Daten. Was soll mit ihnen geschehen?",
|
||||
"finishingDescription": "Dieses Gerät wird verknüpft.",
|
||||
"serverUrl": "Serveradresse",
|
||||
"serverUrlHint": "Zum Beispiel https://termix.example.com",
|
||||
"showProxySettings": "Reverse-Proxy-Einstellungen",
|
||||
"hideProxySettings": "Reverse-Proxy-Einstellungen ausblenden",
|
||||
"continue": "Weiter",
|
||||
"back": "Zurück",
|
||||
"mergeTitle": "Zu meinem Konto hinzufügen",
|
||||
"mergeBody_one": "Der Eintrag auf diesem Gerät wird zu Ihrem Konto hinzugefügt. Einträge, die Ihr Konto bereits hat, bleiben auf dem Server unverändert.",
|
||||
"mergeBody_other": "Die {{count}} Einträge auf diesem Gerät werden zu Ihrem Konto hinzugefügt. Einträge, die Ihr Konto bereits hat, bleiben auf dem Server unverändert.",
|
||||
"replaceTitle": "Durch mein Konto ersetzen",
|
||||
"replaceBody_one": "Der Eintrag auf diesem Gerät wird entfernt und dieses Gerät übernimmt alles von Ihrem Konto. Hosts, die nur auf diesem Gerät gespeichert sind, bleiben erhalten.",
|
||||
"replaceBody_other": "Die {{count}} Einträge auf diesem Gerät werden entfernt und dieses Gerät übernimmt alles von Ihrem Konto. Hosts, die nur auf diesem Gerät gespeichert sind, bleiben erhalten.",
|
||||
"link": "Verknüpfen",
|
||||
"linking": "Verknüpfung wird hergestellt und erste Synchronisierung wird ausgeführt...",
|
||||
"linked": "Verknüpft. Ihre Daten werden jetzt synchronisiert.",
|
||||
"signedInAgain": "Angemeldet. Die Synchronisierung läuft wieder."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Hosts",
|
||||
"sshCredentials": "Anmeldeinformationen",
|
||||
"sshFolders": "Ordner",
|
||||
"userPreferences": "Einstellungen",
|
||||
"pluginUserSettings": "Plugin-Einstellungen",
|
||||
"sharedHosts": "Mit Ihnen geteilte Hosts",
|
||||
"sharedCredentials": "Mit Ihnen geteilte Anmeldeinformationen",
|
||||
"vaultProfiles": "Vault-Profile",
|
||||
"snippetFolders": "Snippet-Ordner",
|
||||
"snippets": "Snippets",
|
||||
"dashboardServiceLinks": "Service-Links",
|
||||
"homepageItems": "Startseiten-Elemente",
|
||||
"networkTopology": "Netzwerkübersicht",
|
||||
"workspaces": "Arbeitsbereiche",
|
||||
"hostDefaults": "Host-Standardwerte",
|
||||
"hostDefaultsAdmin": "Server-Host-Standardwerte",
|
||||
"aiProviders": "AI-Anbieter",
|
||||
"homepageLayouts": "Startseiten-Layouts",
|
||||
"accountProfile": "Kontoprofil"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Ordner {{name}}",
|
||||
|
||||
@@ -213,8 +213,20 @@
|
||||
"pluginsSomeFailed": "Ορισμένοι προσθέτες δεν μπόρεσαν να αλλάξουν. Μπορείτε να τους διορθώσετε αργότερα από την καρτέλα Προσθέτες. Πατήστε Επόμενο για να συνεχίσετε.",
|
||||
"pluginsApplyFailed": "Οι επιλογές σας δεν μπόρεσαν να αποθηκευτούν. Δοκιμάστε ξανά ή συνεχίστε και αλλάξτε τις αργότερα από την καρτέλα Προσθέτες.",
|
||||
"pluginsLoadFailed": "Δεν μπόρεσε να φορτωθεί η λίστα προσθέτων. Μπορείτε να επιλέξετε προσθέτες αργότερα από την καρτέλα Προσθέτες.",
|
||||
"presetTitle": "Πόσα θέλετε να φαίνονται στην οθόνη;",
|
||||
"presetIntro": "Επιλέξτε πόσα εμφανίζονται στην οθόνη.",
|
||||
"presetChangeLater": "Μπορείτε να αλλάξετε προεπιλογές ή οποιαδήποτε μεμονωμένη ρύθμιση αργότερα στις Ρυθμίσεις, κάτω από Επαφή.",
|
||||
"appearanceTitle": "Κάντε το δικό σας",
|
||||
"appearanceIntro": "Επιλέξτε ένα θέμα, ένα χρώμα έμφασης, μια γραμματοσειρά και τη γλώσσα σας. Οι αλλαγές εμφανίζονται αμέσως και μπορείτε να τις αλλάξετε αργότερα στις Ρυθμίσεις.",
|
||||
"securityTitle": "Ασφαλίστε τον λογαριασμό σας",
|
||||
"securityIntro": "Προσθέστε ένα passkey ή μια εφαρμογή ελέγχου ταυτότητας στον λογαριασμό σας. Μπορείτε επίσης να το κάνετε αυτό αργότερα στις Ρυθμίσεις, κάτω από Ασφάλεια.",
|
||||
"desktopTitle": "Πώς θέλετε να χρησιμοποιήσετε το Termix;",
|
||||
"desktopBody": "Μπορείτε να αλλάξετε αυτό ανά πάσα στιγμή από τη Συγχρονισμό στη πλευρική γραμμή.",
|
||||
"desktopLocalTitle": "Μόνο σε αυτήν τη συσκευή",
|
||||
"desktopLocalBody": "Όλα αποθηκεύονται εδώ. Τίποτα για ρύθμιση.",
|
||||
"desktopLinkTitle": "Με τον διακομιστή Termix μου",
|
||||
"desktopLinkBody": "Συνδεθείτε στον διακομιστή σας για να χρησιμοποιείτε τους ίδιους διακομιστές και ρυθμίσεις σε κάθε συσκευή.",
|
||||
"desktopLinked": "Συνδεδεμένο με {{server}}",
|
||||
"pluginsDocsLink": "Διαβάστε για καθένα"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -235,11 +247,6 @@
|
||||
"ready": "Το Termix {{version}} είναι έτοιμο για εγκατάσταση",
|
||||
"readyHint": "Εγκαθίσταται την επόμενη φορά που θα επανεκκινήσει το Termix."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Κλείσιμο πάνελ",
|
||||
"moveSide": "Μετακίνηση πάνελ",
|
||||
"resize": "Αλλαγή μεγέθους πάνελ"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Αρχική",
|
||||
"terminal": "Τερματικό",
|
||||
@@ -250,6 +257,7 @@
|
||||
"hosts": "Διακομιστές",
|
||||
"credentials": "Στοιχεία Εισόδου",
|
||||
"connections": "Συνδέσεις",
|
||||
"sync": "Συγχρονισμός",
|
||||
"reconnectDisconnectedTerminals": "Επανασύνδεση όλων των αποσυνδεδεμένων τερματικών",
|
||||
"reconnectingTerminals_one": "Επανασύνδεση {{count}} τερματικού",
|
||||
"reconnectingTerminals_other": "Επανασύνδεση {{count}} τερματικών",
|
||||
@@ -271,7 +279,10 @@
|
||||
"retry": "Επανάληψη",
|
||||
"refresh": "Ανανέωση",
|
||||
"optional": "Προαιρετικό",
|
||||
"downloadSample": "Λήψη Παραδείγματος",
|
||||
"failedToDeleteHost": "Αποτυχία διαγραφής του {{name}}",
|
||||
"importSkipExisting": "Εισαγωγή (παράλειψη υπαρχόντων)",
|
||||
"importSSHConfig": "Εισαγωγή από ρυθμίσεις SSH",
|
||||
"connectionDetails": "Λεπτομέρειες Σύνδεσης",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -490,7 +501,10 @@
|
||||
"noFolder": "(Χωρίς φάκελο)",
|
||||
"deleteSelected": "Διαγραφή",
|
||||
"exitSelection": "Έξοδος από επιλογή",
|
||||
"importSkip": "Εισαγωγή (παράλειψη υπαρχόντων)",
|
||||
"importOverwrite": "Εισαγωγή (αντικατάσταση)",
|
||||
"collapseBtn": "Σύμπτυξη",
|
||||
"importExportBtn": "Εισαγωγή / Εξαγωγή",
|
||||
"hostStatusesRefreshed": "Οι καταστάσεις των hosts ανανεώθηκαν",
|
||||
"failedToRefreshHosts": "Αποτυχία ανανέωσης των hosts",
|
||||
"movedHostTo": "Μετακίνηση του {{host}} στον «{{folder}}»",
|
||||
@@ -545,9 +559,6 @@
|
||||
"deleteFolderConfirm_one": "Διαγραφή του \"{{name}}\" και των {{count}} hosts του; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"deleteFolderConfirm_other": "Διαγραφή του \"{{name}}\" και των {{count}} hosts του; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"failedToMoveHosts": "Αποτυχία μετακίνησης hosts",
|
||||
"managedBanner": "Αυτός ο host διαχειρίζεται από το {{plugin}}. Κάντε αλλαγές εκεί. Μπορείτε ακόμα να τον προσαρτήσετε και να αλλάξετε τη σειρά του.",
|
||||
"managedTooltip": "Διαχειρίζεται από το {{plugin}}",
|
||||
"addBlocked": "Οι hosts διαχειρίζονται από το {{plugin}}, οπότε δεν μπορούν να προστεθούν νέοι hosts εδώ.",
|
||||
"failedToReorder": "Αποτυχία αναδιάταξης",
|
||||
"expandAll": "Ανάπτυξη όλων των φακέλων",
|
||||
"collapseAll": "Σύμπτυξη όλων των φακέλων",
|
||||
@@ -658,6 +669,35 @@
|
||||
"addCredentialBtn2": "Προσθήκη Διαπιστευτηρίου",
|
||||
"checkingHostStatuses": "Έλεγχος καταστάσεων hosts...",
|
||||
"pinnedSection": "Στερεωμένα",
|
||||
"hostsExported": "Οι hosts εξάχθηκαν επιτυχώς",
|
||||
"export": {
|
||||
"menuItem": "Εξαγωγή...",
|
||||
"title": "Εξαγωγή hosts",
|
||||
"scope": "Εμβέλεια",
|
||||
"scopeAll": "Όλα",
|
||||
"scopeSelected": "Επιλεγμένα",
|
||||
"searchHosts": "Αναζήτηση hosts...",
|
||||
"include": "Συμπερίληψη",
|
||||
"groupConnection": "Σύνδεση",
|
||||
"groupCredentials": "Στοιχεία σύνδεσης",
|
||||
"groupNotes": "Σημειώσεις",
|
||||
"groupTags": "Ετικέτες & στέρευση",
|
||||
"groupProxy": "Proxy",
|
||||
"groupJumpHosts": "Jump hosts",
|
||||
"groupFeatureFlags": "Ρυθμίσεις plugins",
|
||||
"groupAdvanced": "Προχωρημένη ρύθμιση",
|
||||
"preview": "Προεπισκόπηση",
|
||||
"moreHosts_one": "... {{count}} ακόμη host",
|
||||
"moreHosts_other": "... {{count}} ακόμη hosts",
|
||||
"summary": "{{selected}} από {{total}} hosts",
|
||||
"credentialsIncluded": "τα στοιχεία σύνδεσης συμπεριλήφθηκαν",
|
||||
"credentialsExcluded": "τα στοιχεία σύνδεσης αποκλείστηκαν",
|
||||
"noneSelected": "Δεν έχουν επιλεγεί hosts",
|
||||
"cancel": "Ακύρωση",
|
||||
"confirm": "Εξαγωγή",
|
||||
"fetchFailed": "Αποτυχία φόρτωσης hosts για εξαγωγή",
|
||||
"bulkButton": "Εξαγωγή"
|
||||
},
|
||||
"sampleDownloaded": "Το αρχείο δείγματος κατεβάστηκε",
|
||||
"failedToDeleteCredential2": "Αποτυχία διαγραφής στοιχείου σύνδεσης",
|
||||
"noFolderOption": "(Χωρίς φάκελο)",
|
||||
@@ -820,7 +860,11 @@
|
||||
"editorUpdate": "Ενημέρωση Υπολογιστή",
|
||||
"editorAdd": "Προσθήκη Υπολογιστή",
|
||||
"speedSearch": "Αναζήτηση υπολογιστών. Πλήκτρα βέλγους για επιλογή, Enter για σύνδεση, Esc για κλείσιμο",
|
||||
"speedSearchList": "Λίστα υπολογιστών. Πληκτρολογήστε ή πατήστε / για αναζήτηση."
|
||||
"speedSearchList": "Λίστα υπολογιστών. Πληκτρολογήστε ή πατήστε / για αναζήτηση.",
|
||||
"importNoHosts": "Δεν βρέθηκαν υπολογιστές στο αρχείο",
|
||||
"importTooMany": "Μπορείτε να εισάγετε έως {{max}} υπολογιστές ταυτόχρονα",
|
||||
"importSummary": "Η εισαγωγή ολοκληρώθηκε: {{imported}} εισήχθησαν, {{updated}} ενημερώθηκαν, {{failed}} απέτυχαν",
|
||||
"importHostsFailed": "Αποτυχία εισαγωγής υπολογιστών"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Διαίρεση οθόνης",
|
||||
@@ -1071,57 +1115,17 @@
|
||||
"colorPickerTooltip": "Άνοιγμα επιλογέα χρώματος",
|
||||
"themeSystem": "Σύστημα",
|
||||
"themeLight": "Φωτεινό",
|
||||
"themeDark": "Σκοτεινό"
|
||||
"themeDark": "Σκοτεινό",
|
||||
"themeDracula": "Dracula",
|
||||
"themeCatppuccin": "Catppuccin",
|
||||
"themeNord": "Nord",
|
||||
"themeSolarized": "Solarized",
|
||||
"themeTokyoNight": "Tokyo Night",
|
||||
"themeOneDark": "One Dark",
|
||||
"themeGruvbox": "Gruvbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"layoutHint": "Πώς είναι διατεταγμένη η εφαρμογή συνολικά. Τα θέματα και τα layouts προέρχονται από plugins.",
|
||||
"defaultsLabel": "Προεπιλογή για όλους",
|
||||
"defaultsHint": "Αυτό που βλέπουν οι χρήστες μέχρι να επιλέξουν το δικό τους layout ή θέμα.",
|
||||
"followDefault": "Χρήση της προεπιλογής",
|
||||
"defaultSaved": "Η προεπιλεγμένη εμφάνιση αποθηκεύτηκε",
|
||||
"saveFailed": "Δεν ήταν δυνατή η αποθήκευση της εμφάνισης",
|
||||
"repair": {
|
||||
"title": "Αυτό το layout δεν μπόρεσε να εμφανιστεί",
|
||||
"safeTitle": "Ασφαλής λειτουργία",
|
||||
"layout": "Layout",
|
||||
"plugin": "Plugin",
|
||||
"reason": "Λόγος",
|
||||
"error": "Σφάλμα",
|
||||
"noError": "Κανένα μήνυμα σφάλματος.",
|
||||
"copy": "Αντιγραφή",
|
||||
"copied": "Αντιγράφηκε",
|
||||
"copyFailed": "Δεν ήταν δυνατή η αντιγραφή των λεπτομερειών",
|
||||
"retry": "Δοκιμή ξανά",
|
||||
"reset": "Επαναφορά στην προεπιλεγμένη εμφάνιση",
|
||||
"resetEveryone": "Επαναφορά της προεπιλογής για όλους",
|
||||
"update": "Ενημέρωση του {{name}} στην έκδοση {{version}}",
|
||||
"disable": "Απενεργοποίηση του {{name}}",
|
||||
"install": "Εύρεση layouts και θεμάτων",
|
||||
"installTitle": "Layouts και θέματα",
|
||||
"installButton": "Εγκατάσταση",
|
||||
"nothingToInstall": "Δεν υπάρχουν άλλα layouts ή θέματα διαθέσιμα αυτή τη στιγμή.",
|
||||
"registryUnreachable": "Δεν ήταν δυνατή η πρόσβαση στο μητρώο plugins.",
|
||||
"askAdmin": "Ζητήστε από έναν διαχειριστή να ενημερώσει ή να διορθώσει αυτό το layout. Μπορείτε να αλλάξετε σε άλλο layout ή θέμα για τον εαυτό σας παρακάτω.",
|
||||
"switchLayout": "Αλλαγή layout",
|
||||
"switchTheme": "Αλλαγή θέματος",
|
||||
"saveFailed": "Δεν ήταν δυνατή η αποθήκευση της επιλογής σας",
|
||||
"reasons": {
|
||||
"safe-url": "Η διεύθυνση ζήτησε ασφαλή λειτουργία. Επιλέξτε ένα layout παρακάτω, ή αφαιρέστε το ?safe-layout=1 από τη διεύθυνση.",
|
||||
"safe-env": "Ο server ξεκίνησε με το TERMIX_SAFE_LAYOUT, οπότε όλοι βλέπουν αυτή την οθόνη μέχρι να αφαιρεθεί.",
|
||||
"missing": "Κανένα εγκατεστημένο plugin δεν παρέχει αυτό το layout.",
|
||||
"disabled": "Το plugin που παρέχει αυτό το layout είναι απενεργοποιημένο.",
|
||||
"blocked": "Το plugin που παρέχει αυτό το layout περιμένει κάτι που χρειάζεται.",
|
||||
"incompatible": "Το plugin που παρέχει αυτό το layout δεν λειτουργεί με αυτή την έκδοση του Termix.",
|
||||
"failed": "Το layout απέτυχε να ξεκινήσει.",
|
||||
"crashed": "Το layout κατέρρευσε δύο φορές κατά την απόδοση της εφαρμογής."
|
||||
},
|
||||
"noLayout": "Δεν έχει οριστεί layout",
|
||||
"restore": "Επαναφορά του ενσωματωμένου {{name}}",
|
||||
"restoreFailed": "Δεν ήταν δυνατή η επαναφορά του ενσωματωμένου αντιγράφου"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1144,18 +1148,6 @@
|
||||
"title": "Διαχείριση σύνδεσης",
|
||||
"consequence": "Μπορεί να προσθέσει μέθοδο σύνδεσης, δεύτερο παράγοντα ή τύπο σύνδεσης SSH."
|
||||
},
|
||||
"auth:policy": {
|
||||
"title": "Ορισμός κανόνων σύνδεσης",
|
||||
"consequence": "Μπορεί να αποσυνδέει χρήστες όταν είναι ανενεργοί, να περιορίζει τη διάρκεια της συνεδρίας, να απενεργοποιεί τη λειτουργία «Θυμήσου με» και να απαιτεί δεύτερο παράγοντα."
|
||||
},
|
||||
"ssh:transport": {
|
||||
"title": "Επιλογή τρόπου πρόσβασης στους hosts",
|
||||
"consequence": "Μπορεί να μεταφέρει τη σύνδεση SSH σε έναν host μέσω της δικής του διαδρομής, οπότε βλέπει τα πάντα που στέλνονται και λαμβάνονται από αυτούς τους hosts, συμπεριλαμβανομένων των κωδικών πρόσβασης."
|
||||
},
|
||||
"sync:provide": {
|
||||
"title": "Εκτέλεση συγχρονισμού",
|
||||
"consequence": "Μπορεί να απαντά σε όλα τα αιτήματα συγχρονισμού, οπότε βλέπει τα δεδομένα που συγχρονίζουν οι συσκευές και οι servers."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Διαχείριση πιστοποιητικών",
|
||||
"consequence": "Μπορεί να αντικαταστήσει το πιστοποιητικό που εξυπηρετεί το Termix μέσω HTTPS και να απαντά σε ελέγχους τομέα από αρχές πιστοποίησης."
|
||||
@@ -1164,10 +1156,6 @@
|
||||
"title": "Πρόσβαση σε σειριακές και USB συσκευές",
|
||||
"consequence": "Μπορεί να ανοίξει μια φυσική σειριακή θύρα στη συσκευή που τρέχει το Termix."
|
||||
},
|
||||
"hosts:export": {
|
||||
"title": "Εξαγωγή hosts με τα μυστικά τους",
|
||||
"consequence": "Μπορεί να διαβάζει τους hosts σας πλήρως, συμπεριλαμβανομένων των αποθηκευμένων κωδικών πρόσβασης και κλειδιών, και να τα παρέχει. Κάθε εξαγωγή καταγράφεται στο αρχείο ελέγχου."
|
||||
},
|
||||
"hosts:write": {
|
||||
"title": "Δημιουργία και αλλαγή διακομιστών",
|
||||
"consequence": "Μπορεί να προσθέτει διακομιστές και να επεξεργάζεται αυτούς που έχετε ήδη."
|
||||
@@ -1212,30 +1200,10 @@
|
||||
"title": "Ανάγνωση αρχείου ελέγχου",
|
||||
"consequence": "Μπορεί να δει ποιος έκανε τι και πότε."
|
||||
},
|
||||
"network:probe": {
|
||||
"title": "Έλεγχος αν οι θύρες απαντούν",
|
||||
"consequence": "Μπορεί να ανοίξει μια απλή σύνδεση σε οποιαδήποτε διεύθυνση από τον server, για να δει αν είναι προσβάσιμη."
|
||||
},
|
||||
"hosts:status-provide": {
|
||||
"title": "Παροχή κατάστασης host",
|
||||
"consequence": "Γίνεται η πηγή των σημείων online και offline για κάθε host."
|
||||
},
|
||||
"hosts:activity-provide": {
|
||||
"title": "Αποθήκευση πρόσφατης δραστηριότητας",
|
||||
"consequence": "Γίνεται το αποθετήριο για τη λίστα πρόσφατης δραστηριότητας κάθε χρήστη."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "Διαχείριση hosts ως μόνο για ανάγνωση",
|
||||
"consequence": "Οι hosts που διαχειρίζεται δεν μπορούν να επεξεργαστούν, να διαγραφούν ή να κοινοποιηθούν ξανά από κανέναν άλλον."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Άνοιγμα παραθύρων επιφάνειας εργασίας",
|
||||
"consequence": "Μόνο στην εφαρμογή επιφάνειας εργασίας Termix."
|
||||
},
|
||||
"desktop:local": {
|
||||
"title": "Χρήση αυτού του υπολογιστή",
|
||||
"consequence": "Μπορεί να διαβάζει και να αλλάζει αρχεία, να εκτελεί shell και να ανοίγει τοπικές θύρες σε αυτόν τον υπολογιστή. Μόνο στην εφαρμογή Termix για επιφάνεια εργασίας."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "Δράση ως άλλοι χρήστες",
|
||||
"consequence": "Εκτελεί εργασίες υποβάθρου με την πρόσβαση άλλου χρήστη, όπως προγραμματισμένες εργασίες. Κάθε χρήση καταγράφεται στο αρχείο ελέγχου."
|
||||
@@ -1275,14 +1243,6 @@
|
||||
"ui:surface": {
|
||||
"title": "Προσθήκη των δικών του οθονών",
|
||||
"consequence": "Καρτέλες, πάνελ και ρυθμίσεις που μπορείτε να κρύψετε αργότερα."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "Αντικατάσταση ολόκληρης της διάταξης της εφαρμογής",
|
||||
"consequence": "Όταν επιλεγεί, ο κώδικάς του σχεδιάζει κάθε οθόνη, συμπεριλαμβανομένων των τερματικών και των ρυθμίσεών σας."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "Προσθήκη χρωματικών θεμάτων",
|
||||
"consequence": "Μόνο χρώματα και στυλ. Δεν εκτελείται κώδικας για ένα θέμα."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -1412,7 +1372,6 @@
|
||||
"options": "Αδυναμία αποθήκευσης των επιλογών για το {{name}}: {{error}}",
|
||||
"deleteData": "Αδυναμία διαγραφής των δεδομένων για το {{name}}: {{error}}",
|
||||
"managedByServer": "Αυτή η επιφάνεια εργασίας συνδέεται με έναν διακομιστή, ο οποίος καθορίζει τα πρόσθετά της.",
|
||||
"defaultLook": "Το {{name}} είναι η προεπιλεγμένη εμφάνιση, οπότε μπορεί να απενεργοποιηθεί ή να αντικατασταθεί αλλά όχι να αποεγκατασταθεί.",
|
||||
"hasDependents": "Το {{name}} απαιτείται από το {{list}}. Απενεργοποιήστε ή απεγκαταστήστε πρώτα αυτά.",
|
||||
"missingDependencies": "Το {{name}} χρειάζεται το {{list}}, το οποίο δεν είναι εγκατεστημένο.",
|
||||
"consentChanged": "Αυτό που ζητάει αυτό το πρόσθετο άλλαξε από τότε που το κοιτάξατε. Ελέγξτε ξανά τη λίστα.",
|
||||
@@ -1482,6 +1441,7 @@
|
||||
"noServerAccess": "Χωρίς πρόσβαση στους διακομιστές σας",
|
||||
"searchInstalled": "Αναζήτηση εγκατεστημένων",
|
||||
"searchPlugins": "Αναζήτηση προσθέτων",
|
||||
"allCategories": "Όλα",
|
||||
"nothingMatches": "Τίποτα δεν ταιριάζει.",
|
||||
"noneInstalled": "Δεν έχουν εγκατασταθεί προσθέματα ακόμη. Περιηγηθείτε για να προσθέσετε μερικά.",
|
||||
"nothingHere": "Τίποτα εδώ.",
|
||||
@@ -1571,44 +1531,7 @@
|
||||
"documentation": "Τεκμηρίωση",
|
||||
"envVars": "Μεταβλητές περιβάλλοντος",
|
||||
"envRequired": "Απαιτούμενο",
|
||||
"envDefault": "Προεπιλογή: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "Διάταξεις",
|
||||
"themes": "Θέματα",
|
||||
"tabs": "Καρτέλες",
|
||||
"panels": "Πίνακες πλαϊνής μπάρας",
|
||||
"dashboardCards": "Κάρτες πίνακα ελέγχου",
|
||||
"protocols": "Τύποι σύνδεσης",
|
||||
"auth": "Μέθοδοι σύνδεσης",
|
||||
"actions": "Ενέργειες",
|
||||
"hostCapability": "Ρυθμίσεις host"
|
||||
},
|
||||
"filters": {
|
||||
"type": "Τύπος",
|
||||
"allTypes": "Όλοι οι τύποι",
|
||||
"category": "Κατηγορία",
|
||||
"allCategories": "Όλες οι κατηγορίες",
|
||||
"results_one": "{{count}} plugin",
|
||||
"results_other": "{{count}} plugins",
|
||||
"remove": "Αφαίρεση φίλτρου {{name}}",
|
||||
"clear": "Εκκαθάριση φίλτρων",
|
||||
"button": "Φίλτρα",
|
||||
"buttonCount": "Φίλτρα ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} από {{total}}",
|
||||
"page": "Σελίδα {{page}} από {{pages}}",
|
||||
"previous": "Προηγούμενη σελίδα",
|
||||
"next": "Επόμενη σελίδα",
|
||||
"perPage": "{{count}} ανά σελίδα",
|
||||
"perPageLabel": "Plugins ανά σελίδα"
|
||||
},
|
||||
"showUnsupportedHint": "Αυτά αναφέρονται αλλά κανένα release δεν εκτελείται ακόμη σε αυτή την έκδοση. Ενημερώστε το Termix για να τα εγκαταστήσετε.",
|
||||
"unsupported": "Απαιτεί νεότερο Termix",
|
||||
"unsupportedHint": "Κανένα release αυτού του plugin δεν εκτελείται σε αυτή την έκδοση του Termix. Ενημερώστε το Termix για να το εγκαταστήσετε.",
|
||||
"needsTermix": "Απαιτεί Termix {{range}}",
|
||||
"showUnsupported": "Εμφάνιση μη υποστηριζόμενων ({{count}})",
|
||||
"showMore": "Εμφάνιση {{count}} περισσότερων ({{total}} απομένουν)"
|
||||
"envDefault": "Προεπιλογή: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1624,6 +1547,87 @@
|
||||
"noData": "Χωρίς δεδομένα",
|
||||
"filter": "Φίλτρο..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "{{count}} λεπτό πριν",
|
||||
"minutesAgo_other": "{{count}} λεπτά πριν",
|
||||
"hoursAgo_one": "{{count}} ώρα πριν",
|
||||
"hoursAgo_other": "{{count}} ώρες πριν",
|
||||
"pending_one": "{{count}} αλλαγή σε αναμονή για συγχρονισμό",
|
||||
"pending_other": "{{count}} αλλαγές σε αναμονή για συγχρονισμό",
|
||||
"proxy": {
|
||||
"headersTitle": "Επιπλέον κεφαλίδες",
|
||||
"headersHint": "Αποστέλλονται με κάθε αίτημα στον διακομιστή, για παράδειγμα ένα token υπηρεσίας Cloudflare Access.",
|
||||
"headerName": "Όνομα κεφαλίδας",
|
||||
"headerValue": "Τιμή",
|
||||
"savedValue": "Αποθηκεύτηκε, πληκτρολογήστε για αλλαγή",
|
||||
"addHeader": "Προσθήκη κεφαλίδας",
|
||||
"removeHeader": "Αφαίρεση κεφαλίδας",
|
||||
"basicAuthTitle": "Όνομα χρήστη και κωδικός πρόσβασης",
|
||||
"basicAuthHint": "Για έναν διαμεσολαβητή που ζητά όνομα χρήστη και κωδικό πρόσβασης πριν από το Termix.",
|
||||
"username": "Όνομα χρήστη",
|
||||
"password": "Κωδικός πρόσβασης",
|
||||
"allowInvalidCertificate": "Εμπιστοσύνη σε αυτουπογεγραμμένο πιστοποιητικό",
|
||||
"allowInvalidCertificateHint": "Μόνο για τον δικό σας διακομιστή, όταν το πιστοποιητικό του δεν προέρχεται από γνωστή αρχή."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "Αυτό δεν μοιάζει με διεύθυνση ιστού.",
|
||||
"unreachable": "Αδυναμία επικοινωνίας με τον διακομιστή. Ελέγξτε τη διεύθυνση και ότι ο διακομιστής λειτουργεί.",
|
||||
"unreachableWithReason": "Αδυναμία επικοινωνίας με τον διακομιστή: {{reason}}",
|
||||
"tlsUntrusted": "Το πιστοποιητικό του διακομιστή δεν εμπιστεύεται. Αν αυτός είναι ο δικός σας διακομιστής με αυτουπογεγραμμένο πιστοποιητικό, ενεργοποιήστε την επιλογή παρακάτω.",
|
||||
"basicAuthRequired": "Κάτι πριν από τον διακομιστή ζητά όνομα χρήστη και κωδικό. Εισάγετέ τα παρακάτω.",
|
||||
"proxyLogin": "Μια σελίδα εισόδου ή proxy απάντησε αντί για το Termix. Προσθέστε τα headers που χρειάζεται παρακάτω, ή ελέγξτε τη διεύθυνση.",
|
||||
"notTermix": "Δεν υπάρχει διακομιστής Termix σε αυτή τη διεύθυνση. Αν τρέχει σε μια διαδρομή, συμπεριλάβετε τη, όπως https://example.com/termix.",
|
||||
"versionMismatch": "Αυτός ο διακομιστής τρέχει παλαιότερη έκδοση του Termix. Αναβαθμίστε το στην 2.9 ή νεότερη για σύνδεση.",
|
||||
"differentAccount": "Συνδεθήκατε σε διαφορετικό λογαριασμό. Συνδεθείτε στον λογαριασμό με τον οποίο είναι συνδεδεμένη αυτή η συσκευή, ή αποσυνδέστε πρώτα."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Σύνδεση με διακομιστή Termix",
|
||||
"reloginTitle": "Σύνδεση ξανά",
|
||||
"savedServers": "Πρόσφατοι διακομιστές",
|
||||
"removeServer": "Αφαίρεση από τη λίστα",
|
||||
"serverDescription": "Εισάγετε τη διεύθυνση που χρησιμοποιείτε για να ανοίξετε το Termix στον browser σας.",
|
||||
"signinDescription": "Συνδεθείτε στο {{url}}",
|
||||
"chooseDescription": "Αυτή η συσκευή έχει ήδη δεδομένα. Τι πρέπει να συμβεί;",
|
||||
"finishingDescription": "Σύνδεση αυτής της συσκευής.",
|
||||
"serverUrl": "Διεύθυνση διακομιστή",
|
||||
"serverUrlHint": "Για παράδειγμα https://termix.example.com",
|
||||
"showProxySettings": "Ρυθμίσεις reverse proxy",
|
||||
"hideProxySettings": "Απόκρυψη ρυθμίσεων reverse proxy",
|
||||
"continue": "Συνέχεια",
|
||||
"back": "Πίσω",
|
||||
"mergeTitle": "Προσθήκη στον λογαριασμό μου",
|
||||
"mergeBody_one": "Το στοιχείο σε αυτή τη συσκευή προστίθεται στον λογαριασμό σας. Τα στοιχεία που έχει ήδη ο λογαριασμός σας παραμένουν ως έχουν στον διακομιστή.",
|
||||
"mergeBody_other": "Τα {{count}} στοιχεία σε αυτή τη συσκευή προστίθενται στον λογαριασμό σας. Τα στοιχεία που έχει ήδη ο λογαριασμός σας παραμένουν ως έχουν στον διακομιστή.",
|
||||
"replaceTitle": "Αντικατάσταση με τον λογαριασμό μου",
|
||||
"replaceBody_one": "Το στοιχείο σε αυτή τη συσκευή αφαιρείται, και αυτή η συσκευή παίρνει τα πάντα από τον λογαριασμό σας. Οι hosts που διατηρούνται μόνο σε αυτή τη συσκευή παραμένουν.",
|
||||
"replaceBody_other": "Τα {{count}} στοιχεία σε αυτή τη συσκευή αφαιρούνται, και αυτή η συσκευή παίρνει τα πάντα από τον λογαριασμό σας. Οι hosts που διατηρούνται μόνο σε αυτή τη συσκευή παραμένουν.",
|
||||
"link": "Σύνδεση",
|
||||
"linking": "Σύνδεση και εκτέλεση του πρώτου sync...",
|
||||
"linked": "Συνδεδεμένο. Τα δεδομένα σας συγχρονίζονται τώρα.",
|
||||
"signedInAgain": "Συνδεθήκατε. Ο sync εκτελείται ξανά."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Διακομιστές",
|
||||
"sshCredentials": "Διαπιστευτήρια",
|
||||
"sshFolders": "Φάκελοι",
|
||||
"userPreferences": "Προτιμήσεις",
|
||||
"pluginUserSettings": "Ρυθμίσεις πρόσθετων",
|
||||
"sharedHosts": "Διακομιστές που μοιράστηκαν μαζί σας",
|
||||
"sharedCredentials": "Διαπιστευτήρια που μοιράστηκαν μαζί σας",
|
||||
"vaultProfiles": "Προφίλ Vault",
|
||||
"snippetFolders": "Φάκελοι αποσπασμάτων",
|
||||
"snippets": "Αποσπάσματα",
|
||||
"dashboardServiceLinks": "Σύνδεσμοι υπηρεσιών",
|
||||
"homepageItems": "Στοιχεία αρχικής σελίδας",
|
||||
"networkTopology": "Χάρτης δικτύου",
|
||||
"workspaces": "Χώροι εργασίας",
|
||||
"hostDefaults": "Προεπιλογές διακομιστών",
|
||||
"hostDefaultsAdmin": "Προεπιλογές διακομιστών διακομιστή",
|
||||
"aiProviders": "Πάροχοι AI",
|
||||
"homepageLayouts": "Διατάξεις αρχικής σελίδας",
|
||||
"accountProfile": "Προφίλ λογαριασμού"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Φάκελος {{name}}",
|
||||
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user