Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ node_modules
.cache
.angular
.output
/output/
playwright-report
test-results
.env
Expand Down
14 changes: 10 additions & 4 deletions electron/src/desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ function menuLabels() {
statusBar: "底部状态栏",
batchCommand: "批量命令",
close: "关闭窗口",
closeTab: "关闭当前标签页",
minimize: "最小化窗口",
zoom: "缩放",
fullscreen: "进入全屏幕",
Expand Down Expand Up @@ -693,6 +694,7 @@ function menuLabels() {
statusBar: "Bottom Status Bar",
batchCommand: "Batch Command",
close: "Close Window",
closeTab: "Close Current Tab",
minimize: "Minimize Window",
zoom: "Zoom",
fullscreen: "Enter Full Screen",
Expand Down Expand Up @@ -770,7 +772,7 @@ function openAboutWindow() {
}

function sendMenuCommand(command) {
if (command !== "toggle-fullscreen-mode") {
if (command !== "toggle-fullscreen-mode" && command !== "close-current-tab") {
sendMainWindowEvent("desktop-menu-command", command);
return;
}
Expand All @@ -796,7 +798,7 @@ function buildMenu() {
{ type: "separator" },
{ label: labels.settings, accelerator: "CmdOrCtrl+,", click: openSettingsWindow },
{ label: labels.tools, accelerator: "CmdOrCtrl+Shift+,", click: () => sendMenuCommand("open-tools") },
{ label: labels.close, accelerator: "CmdOrCtrl+W", role: "close" },
{ label: labels.close, accelerator: "CmdOrCtrl+Shift+W", role: "close" },
{ label: labels.minimize, accelerator: "CmdOrCtrl+M", role: "minimize" }
];
if (process.platform === "darwin") {
Expand All @@ -820,7 +822,11 @@ function buildMenu() {
click: () => sendMenuCommand("open-local-shell")
},
{ type: "separator" },
{ label: labels.close, accelerator: "CmdOrCtrl+W", role: "close" }
{
label: labels.closeTab,
accelerator: "CmdOrCtrl+W",
click: () => sendMenuCommand("close-current-tab")
}
]
},
{
Expand Down Expand Up @@ -861,7 +867,7 @@ function buildMenu() {
{ label: labels.minimize, role: "minimize" },
{ label: labels.zoom, role: "zoom" },
{ type: "separator" },
{ label: labels.close, role: "close" }
{ label: labels.close, accelerator: "CmdOrCtrl+Shift+W", role: "close" }
]
},
{ label: labels.help, submenu: [] }
Expand Down
56 changes: 56 additions & 0 deletions electron/tests/menu-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import type { MenuItemConstructorOptions } from "electron";
import ts from "typescript";
import { editCopyAccelerator, menuCommandTargetLabel } from "../src/shared/menu-command.ts";

test("copy stays Cmd+C on macOS and does not steal Ctrl+C elsewhere", () => {
Expand All @@ -15,3 +19,55 @@ test("menu commands target the focused window and fall back to main", () => {
assert.equal(menuCommandTargetLabel(null), "main");
assert.equal(menuCommandTargetLabel(undefined), "main");
});

test("the native close-tab shortcut targets the focused workspace without closing its window", () => {
const source = readFileSync(new URL("../src/desktop/main.ts", import.meta.url), "utf8");
const functions = ["menuLabels", "sendMenuCommand", "buildMenu"].map((name) => {
const match = source.match(new RegExp(`^function ${name}\\([^]*?^}`, "m"));
assert.ok(match, `Missing ${name}`);
return match[0];
});
const { outputText } = ts.transpileModule(`${functions.join("\n")}\nbuildMenu();`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 }
});

for (const platform of ["darwin", "win32", "linux"]) {
let focusedLabel: string | null = "main";
let template: MenuItemConstructorOptions[] = [];
const events: unknown[][] = [];
runInNewContext(outputText, {
process: { platform },
productName: "JumpServer",
prefersZh: () => false,
editCopyAccelerator,
menuCommandTargetLabel,
openAboutWindow() {},
openSettingsWindow() {},
sendMainWindowEvent: () => assert.fail("Closing a tab must not activate the main window"),
BrowserWindow: {
getFocusedWindow: () => (focusedLabel ? { label: focusedLabel, isDestroyed: () => false } : null)
},
labelForWindow: (window: { label: string }) => window.label,
emitDesktopEvent: (...args: unknown[]) => events.push(args),
Menu: {
buildFromTemplate: (items: MenuItemConstructorOptions[]) => items,
setApplicationMenu: (items: MenuItemConstructorOptions[]) => (template = items)
}
});
const items = template.flatMap((menu) => menu.submenu as MenuItemConstructorOptions[]);
const closeTabs = items.filter((item) => item.accelerator === "CmdOrCtrl+W");
assert.equal(closeTabs.length, 1);
assert.equal(closeTabs[0].label, "Close Current Tab");
assert.equal(closeTabs[0].role, undefined);
assert.ok(closeTabs[0].click);
for (const label of ["main", "asset-1", null]) {
focusedLabel = label;
closeTabs[0].click(undefined, undefined, undefined);
assert.deepEqual(events.pop(), ["desktop-menu-command", "close-current-tab", label || "main"]);
}
const closeWindows = items.filter((item) => item.role === "close");
assert.ok(closeWindows.length > 0);
assert.ok(closeWindows.every((item) => item.accelerator === "CmdOrCtrl+Shift+W"));
assert.ok(items.some((item) => item.role === "quit" && item.accelerator === "CmdOrCtrl+Q"));
}
});
4 changes: 1 addition & 3 deletions ui/components/ConnectForm/connectForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,7 @@ const localConnectOptions = computed<Record<string, any>>({

const protocolTabItems = computed(() =>
sortProtocolNames(
(isDesktopRuntime() ? props.protocols : props.protocols.filter((protocol) => protocol?.public !== false)).map(
(protocol) => protocol.name
)
props.protocols.filter((protocol) => protocol?.public !== false).map((protocol) => protocol.name)
).map((name) => ({ label: name.toUpperCase(), value: name }))
);

Expand Down
30 changes: 30 additions & 0 deletions ui/composables/useAssetConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,36 @@ describe("hosted personal credential connections", () => {
});
afterEach(() => vi.unstubAllGlobals());

it.each([false, true])("does not restore hidden protocols from cached choices (desktop=%s)", async (desktop) => {
vi.stubGlobal("isDesktopRuntime", () => desktop);
await expect(
useAssetConnection().confirmConnection(
{ ...asset, permedProtocols: [{ name: "winrm", port: 5986, public: false }] },
{ ...info, protocol: "winrm", availableProtocols: ["winrm"] }
)
).rejects.toThrow("ConnectError.ProtocolUnavailable");
expect(connect).not.toHaveBeenCalled();
});

it.each([false, true])(
"selects a visible protocol instead of a hidden saved choice (desktop=%s)",
async (desktop) => {
vi.stubGlobal("isDesktopRuntime", () => desktop);
await useAssetConnection().confirmConnection(
{
...asset,
permedAccounts: [{ ...account, has_secret: true }],
permedProtocols: [
{ name: "winrm", port: 5986, public: false },
{ name: "ssh", port: 22, public: true }
]
},
{ ...info, protocol: "winrm" }
);
expect(connect.mock.calls[0]?.[2]).toBe("ssh");
}
);

it.each(["ssh", "sftp"])("preserves a saved credential after %s form normalization", async (protocol) => {
await useAssetConnection().confirmConnection(
{
Expand Down
17 changes: 10 additions & 7 deletions ui/composables/useAssetConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ export function useAssetConnection() {

const normalizeConnectionInfo = async (asset: AssetItem, connectionInfo: ConnectionFormInfo) => {
const assetProtocols = sortPermedProtocols(asset.permedProtocols || [])
.filter((protocol) => isDesktopRuntime() || protocol?.public !== false)
.filter((protocol) => protocol?.public !== false)
.map((protocol) => protocol.name);
const protocols = assetProtocols.length > 0 ? assetProtocols : connectionInfo.availableProtocols || [];
const protocols = asset.permedProtocols !== undefined ? assetProtocols : connectionInfo.availableProtocols || [];
const protocol = protocols.includes(connectionInfo.protocol) ? connectionInfo.protocol : protocols[0] || "";
const accounts = asset.permedAccounts || [];

Expand Down Expand Up @@ -242,12 +242,15 @@ export function useAssetConnection() {
const normalized = await normalizeConnectionInfo(asset, connectionInfo);
const selectedAccount = asset.permedAccounts?.find((account) => account.id === normalized.accountId);
if (
normalized.accountMode === "hosted" &&
needsInputSecret(selectedAccount) &&
!normalized.hostedSecret &&
!(normalized.personalCredentialId && !normalized.savePersonalCredential)
!normalized.protocol ||
(normalized.accountMode === "hosted" &&
needsInputSecret(selectedAccount) &&
!normalized.hostedSecret &&
!(normalized.personalCredentialId && !normalized.savePersonalCredential))
) {
const error = new Error(t("ConnectError.SecretRequired"));
const error = new Error(
t(normalized.protocol ? "ConnectError.SecretRequired" : "ConnectError.ProtocolUnavailable")
);
if (normalized.onSessionError) {
normalized.onSessionError(error);
return;
Expand Down
25 changes: 25 additions & 0 deletions ui/composables/useConnectionFormState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,31 @@ describe("personal credential connection form", () => {
vi.unstubAllGlobals();
});

it.each([false, true])("hides private protocols from selection and saved choices (desktop=%s)", (desktop) => {
vi.stubGlobal("isDesktopRuntime", () => desktop);
const scope = effectScope();
const state = scope.run(() => useConnectionFormState())!;
const target = {
...asset,
permedProtocols: [
{ name: "winrm", port: 5986, public: false },
{ name: "rdp", port: 3389, public: true },
{ name: "ssh", port: 22, public: true }
]
};
try {
state.initDraft(target, "winrm");
expect(state.draft.value.protocol).toBe("ssh");
expect(state.buildConnectionInfo(target).availableProtocols).toEqual(["ssh", "rdp"]);
const privateOnly = { ...target, permedProtocols: [target.permedProtocols[0]!] };
state.initDraft(privateOnly, "winrm");
expect(state.draft.value.protocol).toBe("");
expect(state.buildConnectionInfo(privateOnly).availableProtocols).toEqual([]);
} finally {
scope.stop();
}
});

it.each(["ssh", "sftp"])("loads personal accounts while a hosted account is selected for %s", async (protocol) => {
const credential = { id: "saved-key", username: "root", secret_type: "ssh_key", version: 3 };
const load = vi.fn().mockResolvedValue([credential]);
Expand Down
2 changes: 1 addition & 1 deletion ui/composables/useConnectionFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function useConnectionFormState() {
);

const getVisibleProtocols = (protocols: PermedProtocol[]) =>
isDesktopRuntime() ? protocols : protocols.filter((protocol) => protocol?.public !== false);
protocols.filter((protocol) => protocol?.public !== false);
const getManualInputLabel = () => t("Account.ManualInput");
const getAnonymousLabel = () => t("Account.Anonymous");
const getPersonalCredentialScope = (asset: AssetItem, protocol: string) =>
Expand Down
6 changes: 1 addition & 5 deletions ui/composables/useSessionWindowConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,7 @@ export function useSessionWindowConnect() {
throw new Error(t("ConnectError.PersonalCredentialNotFound"));
}
const protocol = typeof credential.protocol === "string" ? credential.protocol : credential.protocol.value;
if (
!asset.permedProtocols?.some(
(item) => item.name === protocol && (isDesktopRuntime() || item.public !== false)
)
) {
if (!asset.permedProtocols?.some((item) => item.name === protocol && item.public !== false)) {
throw new Error(t("ConnectError.ProtocolUnavailable"));
}
const hostedAccount =
Expand Down
2 changes: 1 addition & 1 deletion ui/composables/useWorkspaceAssistantSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ function normalizeCandidate(value: unknown) {

function uniqueProtocols(protocols: PermedProtocol[]) {
const names = protocols
.filter((protocol) => isDesktopRuntime() || protocol?.public !== false)
.filter((protocol) => protocol?.public !== false)
.map((protocol) => boundedString(protocol.name, 64).toLowerCase())
.filter(Boolean);
return [...new Set(names)];
Expand Down
Loading