Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions electron/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

This directory contains the JumpServer desktop runtime.

Desktop connections first check anonymous
`GET /api/v1/settings/client/compatibility/` in the main process. Both
`app.getVersion() >= Core.MIN_CLIENT_VERSION` and
`Core.VERSION >= MIN_CORE_VERSION` must hold. The client's minimum lives in
`src/auth/compatibility.ts` and starts at `5.1.0`. Core represents the whole
server release, including Koko, Chen and Kael. Developers decide whether a
contract change breaks old clients; reviewers check that decision. Compatible
releases leave the minimums unchanged. There is no component version matrix,
automatic updater or automated compatibility gate.

Release builds already run `scripts/set-version.js` to inject the complete
version into the Electron package. Core and Luna development versions default to
`5.1.0`; release builds still inject their actual versions. Do not reuse an
existing release number for new capabilities.
Comparisons follow SemVer, including patch, prerelease and metadata. A lowercase
`X.Y.Z-lts` stable channel label (also before `+metadata`) equals `X.Y.Z`;
other suffixes remain prereleases. A leading `v` is accepted.

Login, saved-account restoration and site switching share the same preflight.
Failed or obsolete checks cannot activate a session. Missing endpoints, invalid
responses, timeouts and TLS failures pause the new connection and offer retry.
They never fall back to the legacy version array. Old clients get a button that
opens the selected site's `/core/download/` in the system browser, retaining the
deployment prefix. Old Core versions require an administrator upgrade.

Run `pnpm test:electron`, `pnpm test:web`, `pnpm lint:check` and `pnpm typecheck`.
For desktop review, use local test servers and release-versioned test builds:
check both minimum boundaries, each single failure and both failures, an old Core
returning 404, retry after a network failure, and a prefixed download URL. Keep A
connected while B fails a check, then rapidly switch B/C and verify only the
latest result can activate. These checks must not access production accounts.

Run the current development shell with:

```sh
Expand Down
4 changes: 2 additions & 2 deletions electron/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jumpserver-client-electron",
"productName": "JumpServer",
"version": "5.0.0",
"version": "5.1.0",
"private": true,
"description": "Electron runtime for JumpServer",
"author": "JumpServer",
Expand All @@ -11,7 +11,7 @@
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"test": "node --import tsx --test tests/auth-language.test.ts tests/auth-request-site.test.ts tests/application-config.test.ts tests/client-protocol.test.ts tests/debug-log.test.ts tests/executable-path.test.ts tests/face-manager.test.ts tests/face-socket-policy.test.ts tests/ffmpeg-plugin.test.ts tests/forge-artifacts.test.ts tests/menu-command.test.ts tests/oauth-callback.test.ts tests/product-name.test.ts tests/replay-codec.test.ts tests/replay-transcoder-archive.test.ts tests/ssh-helper.test.ts tests/url.test.ts tests/web-proxy.test.ts && go -C ssh-helper test ./...",
"test": "node --import tsx --test tests/auth-language.test.ts tests/auth-request-site.test.ts tests/auth-compatibility.test.ts tests/client-compatibility.test.ts tests/application-config.test.ts tests/client-protocol.test.ts tests/debug-log.test.ts tests/executable-path.test.ts tests/face-manager.test.ts tests/face-socket-policy.test.ts tests/ffmpeg-plugin.test.ts tests/forge-artifacts.test.ts tests/menu-command.test.ts tests/oauth-callback.test.ts tests/product-name.test.ts tests/replay-codec.test.ts tests/replay-transcoder-archive.test.ts tests/ssh-helper.test.ts tests/url.test.ts tests/web-proxy.test.ts && go -C ssh-helper test ./...",
"test:web-proxy:interaction": "electron tests/fixtures/web-proxy-interaction-app",
"typecheck": "tsc -p tsconfig.json --pretty false",
"postinstall": "node ../scripts/fix-electron-permissions.mjs"
Expand Down
149 changes: 149 additions & 0 deletions electron/src/auth/compatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { SiteCompatibility } from "../../../ui/types/clientCompatibility";
import { parseUrl } from "../shared/url";

// Raise only when the client starts depending on a breaking server change.
export const MIN_CORE_VERSION = "5.1.0";
const COMPATIBILITY_PATH = "/api/v1/settings/client/compatibility/";

function parseVersion(value: unknown) {
if (typeof value !== "string" || value.length > 256) return null;
// JumpServer's terminal -lts suffix labels the stable release channel.
// Other suffixes retain their SemVer prerelease meaning (including rc1/beta8).
const normalized = value
.trim()
.replace(/^v/, "")
.replace(/^(\d+\.\d+\.\d+)-lts(?=\+|$)/, "$1");
const match = normalized.match(

Check warning on line 16 in electron/src/auth/compatibility.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaEo2sk_Ar7Y89NJvfGT&open=AaEo2sk_Ar7Y89NJvfGT&pullRequest=1755
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/

Check warning on line 17 in electron/src/auth/compatibility.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its complexity from 32 to the 20 allowed.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaEo2sk_Ar7Y89NJvfGU&open=AaEo2sk_Ar7Y89NJvfGU&pullRequest=1755
);
if (!match) return null;
const prerelease = match[4]?.split(".") || [];
if (prerelease.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) return null;
return { release: match.slice(1, 4).map(BigInt), prerelease };
}

export function compareVersions(left: unknown, right: unknown): number | null {

Check failure on line 25 in electron/src/auth/compatibility.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 29 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaEo2sk_Ar7Y89NJvfGV&open=AaEo2sk_Ar7Y89NJvfGV&pullRequest=1755
const a = parseVersion(left);
const b = parseVersion(right);
if (!a || !b) return null;
for (let i = 0; i < 3; i += 1) {
if (a.release[i] !== b.release[i]) return a.release[i] > b.release[i] ? 1 : -1;
}
if (!a.prerelease.length || !b.prerelease.length) {
return Number(!a.prerelease.length) - Number(!b.prerelease.length);
}
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i += 1) {
const x = a.prerelease[i];
const y = b.prerelease[i];
if (x === y) continue;
if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
const xNumeric = /^\d+$/.test(x);
const yNumeric = /^\d+$/.test(y);
if (xNumeric && yNumeric) return BigInt(x) > BigInt(y) ? 1 : -1;
if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
return x > y ? 1 : -1;
}
return 0;
}

export function normalizeCompatibilitySite(value: unknown): string {
if (
typeof value !== "string" ||
!/^https?:\/\//i.test(value) ||
Array.from(value).some((character) => character.charCodeAt(0) <= 32 || character === "\\")

Check warning on line 53 in electron/src/auth/compatibility.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#codePointAt()` over `String#charCodeAt()`.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaEo2sk_Ar7Y89NJvfGW&open=AaEo2sk_Ar7Y89NJvfGW&pullRequest=1755
) {
throw new Error("Invalid site URL");
}
const url = parseUrl(value);
if (!url.hostname || url.username || url.password || /[?#]/.test(url.href)) throw new Error("Invalid site URL");
return url.toString().replace(/\/+$/, "");

Check warning on line 59 in electron/src/auth/compatibility.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaEo2sk_Ar7Y89NJvfGX&open=AaEo2sk_Ar7Y89NJvfGX&pullRequest=1755
}

export function clientDownloadUrl(site: unknown): string {
return `${normalizeCompatibilitySite(site)}/core/download/`;
}

export async function checkSiteCompatibility(

Check failure on line 66 in electron/src/auth/compatibility.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaEo2sk_Ar7Y89NJvfGY&open=AaEo2sk_Ar7Y89NJvfGY&pullRequest=1755
site: unknown,
clientVersion: string,
fetchResponse: typeof fetch = globalThis.fetch,
timeout = 10_000
): Promise<SiteCompatibility> {
const result: SiteCompatibility = {
status: "unknown",
site: typeof site === "string" ? site : "",
clientVersion,
minCoreVersion: MIN_CORE_VERSION,
failures: []
};
try {
result.site = normalizeCompatibilitySite(site);
} catch {
return { ...result, reason: "invalid-site" };
}
if (!parseVersion(clientVersion)) return { ...result, reason: "invalid-client-version" };

let response: Response;
let body: string;
try {
// Node's fetch has no Electron cookies or certificate trust overrides.
// Never use authService.fetchSite here: it permanently trusts entered hosts.
response = await fetchResponse(`${result.site}${COMPATIBILITY_PATH}`, {
headers: { Accept: "application/json", "Cache-Control": "no-cache" },
credentials: "omit",
redirect: "error",
cache: "no-store",
signal: AbortSignal.timeout(timeout)
});
if (response.status === 404) return { ...result, reason: "endpoint-missing" };
if (!response.ok) return { ...result, reason: "http" };
body = "";
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
let bytes = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024) {
await reader.cancel();
return { ...result, reason: "invalid-response" };
}
body += decoder.decode(chunk.value, { stream: true });
}
body += decoder.decode();
} finally {
reader.releaseLock();
}
}
} catch (error) {
const failure = error as { name?: string; cause?: { code?: string } };
const reason = ["TimeoutError", "AbortError"].includes(failure?.name || "")
? "timeout"
: /CERT|TLS|SELF_SIGNED/.test(failure?.cause?.code || "")
? "tls"
: "network";

Check warning on line 127 in electron/src/auth/compatibility.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaEo2sk_Ar7Y89NJvfGZ&open=AaEo2sk_Ar7Y89NJvfGZ&pullRequest=1755
return { ...result, reason };
}

try {
const data = JSON.parse(body);
if (
!data ||
data.schema_version !== 1 ||

Check warning on line 135 in electron/src/auth/compatibility.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaEo2sk_Ar7Y89NJvfGa&open=AaEo2sk_Ar7Y89NJvfGa&pullRequest=1755
!parseVersion(data.core_version) ||
!parseVersion(data.min_client_version)
) {
throw new Error("Invalid compatibility response");
}
result.coreVersion = data.core_version;
result.minClientVersion = data.min_client_version;
if (compareVersions(clientVersion, data.min_client_version)! < 0) result.failures.push("client-too-old");
if (compareVersions(data.core_version, MIN_CORE_VERSION)! < 0) result.failures.push("core-too-old");
return { ...result, status: result.failures.length ? "incompatible" : "compatible" };
} catch {
return { ...result, reason: "invalid-response" };
}
}
Loading
Loading