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
3 changes: 2 additions & 1 deletion applets/weblite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"dependencies": {
"@jumpserver/web-proxy": "workspace:*",
"@nuxt/ui": "4.11.0",
"vue": "^3.5.35"
"vue": "^3.5.35",
"vue-i18n": "^11.4.8"
},
"devDependencies": {
"@electron/packager": "18.3.5",
Expand Down
3 changes: 3 additions & 0 deletions applets/weblite/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { webProxyNavigationPolicy } from "@jumpserver/web-proxy/script";

export function standaloneLaunch() {
return {
language: undefined as string | undefined,
targetUrl: "https://www.jumpserver.org/",
safeMode: false,
allowedUrls: [],
Expand All @@ -29,6 +30,7 @@ export function parseLaunch(value: unknown) {
if (typeof data.safe_mode !== "boolean") throw new Error("缺少安全模式配置");
const localSession = createLocalCredentialSession(target.toString(), data.login);
return {
language: typeof data.language === "string" ? data.language : undefined,
targetUrl: target.toString(),
safeMode: data.safe_mode,
allowedUrls,
Expand Down Expand Up @@ -67,6 +69,7 @@ function appletLaunchData(data: Record<string, unknown>) {
const anonymous = account.username === "@ANON";
const config = anonymous ? { autofill: "none" } : spec.autofill ? spec : setting;
return {
language: object(data.connect_options ?? {}).lang,
target_url: target.toString(),
safe_mode: setting.safe_mode ?? false,
allowed_urls: spec.allowed_urls,
Expand Down
1 change: 1 addition & 0 deletions applets/weblite/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async function start() {
if (closing || event.senderFrame !== win.webContents.mainFrame) throw new Error("无效的 Web applet 调用");
if (command === "bootstrap")
return {
language: launch.language?.trim() || app.getLocale(),
targetUrl: launch.targetUrl,
proxyUrl: "",
safeMode: launch.safeMode,
Expand Down
26 changes: 12 additions & 14 deletions applets/weblite/src/renderer/App.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
<script setup lang="ts">
import { createWebProxyBridge } from "@jumpserver/web-proxy/bridge";
import WebProxySurface from "@jumpserver/web-proxy/surface";
import { onMounted, ref } from "vue";
import { computed, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { toIntlLocale } from "../../../../i18n/language";
import { getUiLocale } from "../../../../i18n/ui";
defineProps<{ request: Record<string, any> }>();
const { locale } = useI18n();
const uiLocale = computed(() => getUiLocale(locale.value));
watchEffect(() => {
document.documentElement.lang = toIntlLocale(locale.value);
});
const host = (window as any).webApplet;
const bridge = createWebProxyBridge(host.invoke, async (name, handler) => host.listen(name, handler));
const request = ref();
onMounted(async () => {
request.value = await host.invoke("bootstrap");
});
</script>

<template>
<UApp class="h-full">
<WebProxySurface
v-if="request"
:request="request"
:bridge="bridge"
active
supported
:browsable="request.standalone"
/>
<UApp class="h-full" :locale="uiLocale">
<WebProxySurface :request="request" :bridge="bridge" active supported :browsable="request.standalone" />
</UApp>
</template>
15 changes: 14 additions & 1 deletion applets/weblite/src/renderer/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { createApp } from "vue";
import { createI18n } from "vue-i18n";
import { normalizeLanguageCode } from "../../../../i18n/language";
import ui from "@nuxt/ui/vue-plugin";
import App from "./App.vue";
import "./style.css";
createApp(App).use(ui).mount("#app");
async function start() {
// Resolve the launch language before mounting so no system-language frame flashes
// when a client launches WebLite with a different language.
const request = await (window as any).webApplet.invoke("bootstrap");
const i18n = createI18n({
legacy: false,
locale: normalizeLanguageCode(request.language || navigator.language),
fallbackLocale: "en"
});
createApp(App, { request }).use(i18n).use(ui).mount("#app");
}
void start();

Check warning on line 18 in applets/weblite/src/renderer/main.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer top-level await over an async function `start` call.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaC9aRzmQT3MxfJYTF0b&open=AaC9aRzmQT3MxfJYTF0b&pullRequest=1704
14 changes: 13 additions & 1 deletion applets/weblite/tests/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ test("applet retains an optional asset navigation allowlist and rejects malforme
"https://sso.example.com"
]);
for (const allowed_urls of [null, "*", ["*"], ["file:///tmp"], ["https://example.com/path"]])
assert.throws(() => parseLaunch({ ...launch, allowed_urls }), /白名单/);
assert.throws(() => parseLaunch({ ...launch, allowed_urls }), /WebProxy\.(AllowedSitesLimit|InvalidAllowedSite)/);
});
test("empty or terminal stdin starts an unrestricted standalone browser without credentials", async () => {
for (const input of [Readable.from([]), Object.assign(Readable.from([]), { isTTY: true })]) {
Expand Down Expand Up @@ -260,3 +260,15 @@ test("rejects invalid configuration and oversized pipes", async () => {
assert.throws(() => parseLaunch({ ...launch, ...patch }));
await assert.rejects(readLaunch(Readable.from([" ".repeat(1_048_577)])), /过长/);
});

test("launch preserves the client language and leaves standalone language to the OS", async () => {
for (const language of ["fr-CA", "en", "zh-hant", "pt_BR"]) {
assert.equal(parseLaunch({ ...applet, connect_options: { lang: language } }).language, language);
assert.equal(parseLaunch({ ...launch, language }).language, language);
}
for (const lang of [null, undefined, 42, {}]) {
assert.equal(parseLaunch({ ...applet, connect_options: { lang } }).language, undefined);
}
assert.equal(parseLaunch(applet).language, undefined);
assert.equal((await readLaunch(Readable.from([]))).language, undefined);
});
62 changes: 62 additions & 0 deletions applets/weblite/tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,65 @@ for (const platform of ["darwin", "win32"]) {
assert.ok(!dialogs[0].includes("must-not-appear-in-dialog"));
});
}

for (const [language, system, expected] of [
["fr-CA", "zh-CN", "fr-CA"],
["en", "fr-FR", "en"],
[undefined, "fr-FR", "fr-FR"],
[" ", "ja-JP", "ja-JP"]
]) {
test(`bootstrap resolves launch language ${language} before system ${system}`, async (t) => {
const profiles = new Set<string>();
t.after(async () => {
for (const profile of profiles) await rm(profile, { recursive: true, force: true });
});
let invoke;
let shown;
let fail;
const ready = new Promise<void>((resolve, reject) => {
shown = resolve;
fail = reject;
});
const contents = { mainFrame: {}, on() {}, setWindowOpenHandler() {} };
const electron = {
app: {
setPath: (_name, value) => profiles.add(value),
setName() {},
whenReady: async () => {},
getLocale: () => system,
exit: () => fail(new Error("Startup failed"))
},
Menu: { setApplicationMenu() {} },
ipcMain: {
handle: (_name, handler) => {
invoke = handler;
}
},
dialog: { showErrorBox() {} },
BrowserWindow: class {
webContents = contents;
on() {}
async loadFile() {}
maximize() {}
show() {
shown();
}
}
};
runInNewContext(outputText, {
exports: {},
__dirname: "/tmp/weblite",
process: { platform: "darwin", stdin: {} },
require(id: string) {
if (id === "electron") return electron;
if (id === "./launch") return { readLaunch: async () => ({ language, standalone: !language }) };
if (id === "@jumpserver/web-proxy/manager") return { createWebProxyManager: () => ({}) };
return require(id);
}
});
await ready;
const bootstrap = await invoke({ senderFrame: contents.mainFrame }, "bootstrap");
assert.equal(bootstrap.language, expected);
assert.equal(bootstrap.standalone, !language);
});
}
39 changes: 24 additions & 15 deletions electron/tests/web-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ test("private certificate trust is scoped to configured HTTPS hosts and authorit
for (const hostname of ["other.test", "plain.test", "sub.example.test", "example.test.evil.test"])
assert.equal(verify(hostname), -3, hostname);
assert.equal(verify("manual.test"), -3);
await assert.rejects(invoke("navigate_web_proxy_view", { targetUrl: "https://manual.test" }), /白名单/);
await assert.rejects(
invoke("navigate_web_proxy_view", { targetUrl: "https://manual.test" }),
/WebProxy.AddressNotAllowed/
);
assert.equal(verify("manual.test"), -3);
} finally {
clearInterval(managed.proxyHeartbeatTimer);
Expand All @@ -175,11 +178,11 @@ test("proxy connection failures identify the selected proxy during login and lat
contents.emit("did-fail-load", {}, -111, description, "https://example.test/login", true);
assert.equal(
finishAutofill.mock.calls.at(-1)!.arguments[2],
`登录页面加载失败:${description}(代理:http://localhost:5001)`
`WebProxy.LoginPageLoadFailed: ${description} (proxy: http://localhost:5001)`
);
managed.autofillPending = false;
contents.emit("did-fail-load", {}, -111, description, "https://example.test/login", true);
assert.equal(state.mock.calls.at(-1)!.arguments[1].error, `${description}(代理:http://localhost:5001)`);
assert.equal(state.mock.calls.at(-1)!.arguments[1].error, `${description} (proxy: http://localhost:5001)`);
}
contents.emit("did-fail-load", {}, -105, "ERR_NAME_NOT_RESOLVED", "https://example.test/login", true);
assert.equal(state.mock.calls.at(-1)!.arguments[1].error, "ERR_NAME_NOT_RESOLVED");
Expand Down Expand Up @@ -258,7 +261,7 @@ test("configured asset allowlist blocks links, redirects and popups while preser
const popup = contents.setWindowOpenHandler.mock.calls[0].arguments[0];
popup({ url: "https://outside.test" });
assert.equal(contents.loadURL.mock.callCount(), 1);
assert.match(state.mock.calls.at(-1).arguments[1].navigationError, /白名单/);
assert.match(state.mock.calls.at(-1).arguments[1].navigationError, /WebProxy.AddressNotAllowed/);
popup({ url: "https://sso.test/login" });
assert.equal(contents.loadURL.mock.callCount(), 2);
});
Expand All @@ -276,14 +279,17 @@ test("login script open commands respect the same asset navigation allowlist", a
frame() {}
}
);
await assert.rejects(runner.run(), /白名单/);
await assert.rejects(runner.run(), /WebProxy.AddressNotAllowed/);
assert.equal(contents.loadURL.mock.callCount(), 0);
});

test("safe mode blocks manual navigation and popups while allowing cross-origin links and redirects", async () => {
const { contents, preferences, invoke } = await setupNavigation(true);
assert.equal(preferences.devTools, false);
await assert.rejects(invoke("navigate_web_proxy_view", { targetUrl: "https://example.test/dashboard" }), /手动输入/);
await assert.rejects(
invoke("navigate_web_proxy_view", { targetUrl: "https://example.test/dashboard" }),
/WebProxy.ManualNavigationUnsupported/
);
for (const url of ["https://example.test/dashboard", "https://other.test/"]) {
assert.deepEqual(contents.setWindowOpenHandler.mock.calls[0].arguments[0]({ url }), { action: "deny" });
}
Expand Down Expand Up @@ -343,7 +349,7 @@ test("manual navigation remains disabled outside safe mode while page navigation
const { contents, preferences, invoke } = await setupNavigation(safeMode);
assert.equal(preferences.devTools, true);
for (const targetUrl of ["https://example.test/dashboard", "https://other.test/"]) {
await assert.rejects(invoke("navigate_web_proxy_view", { targetUrl }), /手动输入/);
await assert.rejects(invoke("navigate_web_proxy_view", { targetUrl }), /WebProxy.ManualNavigationUnsupported/);
}
contents.setWindowOpenHandler.mock.calls[0].arguments[0]({ url: "https://other.test/popup" });
assert.equal(contents.loadURL.mock.callCount(), 2);
Expand All @@ -365,7 +371,10 @@ test("standalone browser navigation accepts only HTTP pages without unlocking ma
await assert.rejects(invoke("navigate_web_proxy_view", { targetUrl }));

const restricted = await setupNavigation(false, ["https://sso.test"], true);
await assert.rejects(restricted.invoke("navigate_web_proxy_view", { targetUrl: "https://outside.test/" }), /白名单/);
await assert.rejects(
restricted.invoke("navigate_web_proxy_view", { targetUrl: "https://outside.test/" }),
/WebProxy.AddressNotAllowed/
);
});

test("script supports explicit SSO origins without an asset allowlist and validates terminal success", () => {
Expand Down Expand Up @@ -508,9 +517,9 @@ test("required script conditions still time out and cancelling optional verifica
{ active: () => true, state: () => {}, interaction: () => {}, frame: () => {} }
);
for (const command of ["code", "check", "success"]) {
await assert.rejects(runnerFor(command).run(), new RegExp(`(${command})超时`));
await assert.rejects(runnerFor(command).run(), new RegExp(`WebProxy.ScriptStepTimeout: .*${command}`));
}
await assert.rejects(runnerFor("interactive", false).run(), /(interactive)超时/);
await assert.rejects(runnerFor("interactive", false).run(), /WebProxy.ScriptStepTimeout: .*interactive/);
const cancelled = runnerFor("interactive");
const result = cancelled.run();
const reason = new Error("connection closed");
Expand All @@ -537,7 +546,7 @@ test("optional verification still requires completion once its target has appear
},
{ active: () => true, state: () => {}, interaction, frame: () => {} }
);
await assert.rejects(runner.run(), /人工验证超时/);
await assert.rejects(runner.run(), /WebProxy.ScriptVerificationTimeout/);
assert.ok(interaction.mock.calls[0].arguments[0] instanceof WebProxyInteraction);
assert.deepEqual(interaction.mock.calls.at(-1).arguments, [null, false]);
});
Expand Down Expand Up @@ -690,10 +699,10 @@ test("ends a stalled login even before form detection starts and retains the tim
assert.equal(webContents.stop.mock.callCount(), 1);
api.emitWebProxyState(managed, { loading: true });
assert.equal(events.at(-1).state.loading, false);
assert.match(events.at(-1).state.error, /60 秒/);
assert.match(events.at(-1).state.error, /WebProxy.SecureLoginTimeout/);
assert.equal(managed.view.setVisible.mock.calls.at(-1).arguments[0], false);
api.finishWebProxyAutofill(managed, "success", "late success");
assert.match(managed.autofillFailure, /60 秒/);
assert.match(managed.autofillFailure, /WebProxy.SecureLoginTimeout/);
});

test("waits for the safe preview before releasing credentials, then hides the live page", async () => {
Expand Down Expand Up @@ -981,7 +990,7 @@ test("interactive verification replaces both automatic login deadlines without e
managed.verificationReady();
t.mock.timers.tick(120_000);
assert.equal(managed.autofillPending, false);
assert.match(managed.autofillFailure, /3 分钟/);
assert.match(managed.autofillFailure, /WebProxy.ManualVerificationTimeout/);
assert.equal(interaction.dispose.mock.callCount(), 1);
assert.equal(managed.autofillVisibilityBlocked, true);
});
Expand Down Expand Up @@ -1112,7 +1121,7 @@ for (const success of ["", "id=success"]) {
cleared.resolve(true);
assert.equal(await completed, false);
assert.equal(managed.autofillVisibilityBlocked, true);
assert.match(managed.autofillFailure, /3 分钟/);
assert.match(managed.autofillFailure, /WebProxy.ManualVerificationTimeout/);
});
}

Expand Down
40 changes: 40 additions & 0 deletions i18n/language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type LangType = "zh" | "zh_hant" | "en" | "fr" | "ja" | "pt_br" | "es" | "ru" | "ko" | "vi";

const INTL_LOCALE_BY_LANGUAGE: Record<LangType, string> = {
zh: "zh-CN",
zh_hant: "zh-TW",
en: "en",
fr: "fr",
ja: "ja",
pt_br: "pt-BR",
es: "es",
ru: "ru",
ko: "ko",
vi: "vi"
};

/**
* @description 获取操作系统的语言
*/
export function normalizeLanguageCode(lang: string | null | undefined): LangType {
const normalized = (lang || "").trim().toLowerCase().replaceAll("_", "-");
if (!normalized) return "en";

if (normalized.startsWith("zh")) {
return /(?:^|-)hant(?:$|-)|^zh-(?:tw|hk|mo)(?:-|$)/.test(normalized) ? "zh_hant" : "zh";
}

const primary = normalized.split("-")[0] || "";
if (primary === "pt") return "pt_br";
if (["en", "fr", "ja", "es", "ru", "ko", "vi"].includes(primary)) return primary as LangType;
return "en";
}

export function toDjangoLanguageCode(lang: LangType) {
if (lang === "zh") return "zh-hans";
return lang.replaceAll("_", "-");
}

export function toIntlLocale(lang: string | null | undefined) {
return INTL_LOCALE_BY_LANGUAGE[normalizeLanguageCode(lang)];
}
Loading
Loading