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 .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@

JMS_KOKO_DEV_URL=http://localhost:5050

# JMS_LION_DEV_URL=http://localhost:8081
# Lion 默认复用 JMS_KOKO_DEV_URL;仅兼容独立调试时覆盖
# JMS_LION_DEV_URL=http://localhost:5050
# JMS_CHEN_DEV_URL=http://localhost:8082
39 changes: 32 additions & 7 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const jumpServerTarget = process.env.JMS_CORE_DEV_URL || "http://localhost:8080";
const kokoTarget = process.env.JMS_KOKO_DEV_URL || "http://localhost:5050";
const lionTarget = process.env.JMS_LION_DEV_URL || "http://localhost:8081";
// JMS_LION_DEV_URL remains a compatibility override; Lion is served by Koko by default.
const lionTarget = process.env.JMS_LION_DEV_URL || kokoTarget;
const chenTarget = process.env.JMS_CHEN_DEV_URL || "http://localhost:8082";
const faceliveTarget = process.env.JMS_FACELIVE_DEV_URL || "http://localhost:5173";
const kaelTarget = process.env.JMS_KAEL_DEV_URL || "http://localhost:5172";
Expand Down Expand Up @@ -163,13 +164,26 @@ export default defineNuxtConfig({
rewrite: (path) => path.replace(/^\/luna/, ""),
configure: bindProxyErrorHandler("luna-lion-ws")
},
"/luna/lion/": {
"/luna/lion/api/": {
target: lionTarget,
secure: false,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/luna/, ""),
configure: bindProxyErrorHandler("luna-lion-api")
},
"/luna/lion/token/": {
target: lionTarget,
secure: false,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/luna/, ""),
configure: bindProxyErrorHandler("luna-lion-token")
},
"/luna/lion/health/": {
target: lionTarget,
secure: false,
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/luna/, ""),
configure: bindProxyErrorHandler("luna-lion-http")
configure: bindProxyErrorHandler("luna-lion-health")
},
"/lion/ws/": {
target: lionTarget.replace(/^http/i, "ws"),
Expand All @@ -178,12 +192,23 @@ export default defineNuxtConfig({
changeOrigin: true,
configure: bindProxyErrorHandler("lion-ws")
},
"/lion/": {
"/lion/api/": {
target: lionTarget,
secure: false,
changeOrigin: true,
configure: bindProxyErrorHandler("lion-api")
},
"/lion/token/": {
target: lionTarget,
secure: false,
changeOrigin: true,
configure: bindProxyErrorHandler("lion-token")
},
"/lion/health/": {
target: lionTarget,
secure: false,
ws: true,
changeOrigin: true,
configure: bindProxyErrorHandler("lion-http")
configure: bindProxyErrorHandler("lion-health")
},
"/chen/ws/": {
target: chenTarget.replace(/^http/i, "ws"),
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"window-state:default",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-write-image",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
Expand Down
32 changes: 8 additions & 24 deletions ui/components/lion/LionProvider.vue
Original file line number Diff line number Diff line change
@@ -1,44 +1,28 @@
<script setup lang="ts">
import { message as lionMessages } from "@/lion/locales/modules";
import { withBasePath } from "@/lion/utils/base";
import { LanguageCode } from "@/lion/utils/config";
import { message as lionMessages, loadRemoteTranslations } from "@/lion/locales";

import "@/lion/styles/base.css";

const { mergeLocaleMessage } = useI18n();

const loaded = ref(false);
const normalizedLangCode = LanguageCode.toLowerCase();
for (const [code, value] of Object.entries(lionMessages)) {
mergeLocaleMessage(code, value);
}

onMounted(async () => {
for (const [code, value] of Object.entries(lionMessages)) {
mergeLocaleMessage(code, value);
}

try {
const response = await fetch(
`${withBasePath("/api/v1/settings/i18n/lion/")}?lang=${encodeURIComponent(normalizedLangCode)}&flat=0`,
{
credentials: "include"
}
);

if (response.ok) {
const translations = await response.json();
for (const [key, value] of Object.entries(translations)) {
mergeLocaleMessage(key, value as Record<string, any>);
}
const translations = await loadRemoteTranslations();
for (const [key, value] of Object.entries(translations)) {
mergeLocaleMessage(key, value);
}
} catch (error) {
console.error("load lion i18n failed", error);
} finally {
loaded.value = true;
}
});
</script>

<template>
<div v-if="loaded" class="h-full w-full overflow-hidden">
<div class="lion-surface h-full w-full overflow-hidden">
<slot />
</div>
</template>
4 changes: 2 additions & 2 deletions ui/lion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Lion 是 RDP/VNC 远程桌面 connector,结构与 koko 对齐:

- `views/` — 独立页面(`/lion/connect`、`/lion/share`、`/lion/monitor`)
- `views/` — 独立页面(`/lion/connect`、`/lion/share/:id`、`/lion/monitor`)
- `workspaces/` — 内嵌到 Luna workspace 的 session surface
- `shared/connectors/capabilities.ts` — 声明 `web_rdp_native` 连接方式
- `shared/connectors/registry.ts` — 解析到 `RemoteSessionSurface.vue`

开发代理见根目录 `nuxt.config.ts`:`/lion/`、`/lion/ws/` 转发到 `localhost:8081`。API/WS 固定走站点根路径 `/lion/*`(不带 `/luna` 前缀)。
开发代理见根目录 `nuxt.config.ts`:Lion 的 API、Token、Health 和 WebSocket 路径默认转发到 Koko(`JMS_KOKO_DEV_URL`,默认 `localhost:5050`)。`JMS_LION_DEV_URL` 仅作为兼容覆盖保留。页面路径由 Client 自己渲染,不转发给 Koko;API/WS 固定走站点根路径 `/lion/*`(不带 `/luna` 前缀)。
102 changes: 89 additions & 13 deletions ui/lion/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,102 @@
import { withBasePath, withLionUrl } from "@/lion/utils/base";
import { apiRequest } from "@/composables/useApiRequest";
import { withLionUrl } from "@/lion/utils/base";

const postJson = (url: string, data: any) => {
return fetch(url, {
export interface SuggestionUser {
id: string;
name: string;
username: string;
}

export interface SuggestionUserPage {
count?: number;
next?: string | null;
previous?: string | null;
results: SuggestionUser[];
}

export interface ShareApiResponse {
success?: boolean;
message?: string;
id?: string;
verify_code?: string;
[key: string]: unknown;
}

export interface ShareSessionResponse extends ShareApiResponse {
action_permission?: {
value?: string;
};
session?: {
id?: string;
[key: string]: unknown;
};
}

export interface LionRequestAuth {
ticket?: string;
token?: string;
}

const withRequestAuth = (url: string, auth?: LionRequestAuth) => {
if (!auth?.ticket && !auth?.token) return url;
const target = new URL(url, window.location.origin);
if (auth.ticket) target.searchParams.set("ticket", auth.ticket);
if (auth.token) target.searchParams.set("token", auth.token);
return target.toString();
};

const parseResponse = async <T>(response: Response): Promise<T> => {
const text = await response.text();
let data: unknown = null;

if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}

if (!response.ok) {
const message =
data && typeof data === "object" && "message" in data
? String((data as { message?: unknown }).message || "")
: text;
throw new Error(message || `HTTP ${response.status}`);
}

return data as T;
};

const postJson = async <T>(url: string, data: unknown): Promise<T> => {
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
});
};

const getSuggestionUsers = (query: string) => {
const url = new URL(withBasePath("/api/v1/users/users/suggestions/"), window.location.origin);
url.searchParams.set("search", query);
return fetch(url.toString(), { credentials: "include" });
return await parseResponse<T>(response);
};

const createShareURL = (data: any, endpointUrl?: string) => postJson(withLionUrl("/api/share/", endpointUrl), data);
const getShareSession = (id: string, data: any, endpointUrl?: string) =>
postJson(withLionUrl(`/api/share/${id}/`, endpointUrl), data);
const removeShareUser = (data: any, endpointUrl?: string) =>
postJson(withLionUrl("/api/share/remove/", endpointUrl), data);
const getSuggestionUsers = (query: string, page = 1, limit = 10) =>
apiRequest<SuggestionUserPage | SuggestionUser[]>({
method: "GET",
path: "/api/v1/users/users/suggestions/",
query: {
search: query,
page,
limit
}
});

const createShareURL = (data: unknown, endpointUrl?: string, auth?: LionRequestAuth) =>
postJson<ShareApiResponse>(withRequestAuth(withLionUrl("/api/share/", endpointUrl), auth), data);
const getShareSession = (id: string, data: unknown, endpointUrl?: string, auth?: LionRequestAuth) =>
postJson<ShareSessionResponse>(withRequestAuth(withLionUrl(`/api/share/${id}/`, endpointUrl), auth), data);
const removeShareUser = (data: unknown, endpointUrl?: string, auth?: LionRequestAuth) =>
postJson<ShareApiResponse>(withRequestAuth(withLionUrl("/api/share/remove/", endpointUrl), auth), data);

export { createShareURL, getShareSession, getSuggestionUsers, removeShareUser };
8 changes: 2 additions & 6 deletions ui/lion/components/CardContainer/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,11 @@ const expanded = ref(true);

<template>
<UCard :ui="{ body: 'p-4' }">
<button
type="button"
class="flex w-full items-center justify-between gap-2 text-left"
@click="expanded = !expanded"
>
<UButton block color="neutral" variant="ghost" class="justify-between px-0 text-left" @click="expanded = !expanded">
<slot v-if="!title" name="custom-header" />
<span v-else class="text-sm font-medium">{{ title }}</span>
<UIcon :name="expanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-left'" class="size-4" />
</button>
</UButton>

<div v-show="expanded" class="mt-3">
<slot />
Expand Down
40 changes: 34 additions & 6 deletions ui/lion/components/ClipBoardText.vue
Original file line number Diff line number Diff line change
@@ -1,33 +1,58 @@
<script lang="ts" setup>
import { useDebounceFn } from "@vueuse/core";
import { ref } from "vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import CardContainer from "@/lion/components/CardContainer/index.vue";
import { readClipboardText } from "@/lion/utils/clipboard";

const props = defineProps<{
remoteText?: string;
disabled?: boolean;
copyDisabled?: boolean;
pasteDisabled?: boolean;
pastePolicyDisabled?: boolean;
textLimit?: number;
}>();
const emit = defineEmits(["update:text"]);
const { t } = useI18n();
const toast = useToast();

const inputValue = ref<string>("");
const isLoading = ref<boolean>(false);
const showRemoteText = ref<boolean>(false);
const maxLength = computed(() => (props.textLimit && props.textLimit > 0 ? props.textLimit : undefined));
const inputLength = computed(() => Array.from(inputValue.value).length);

const validateText = (text: string) => {
if (props.pastePolicyDisabled) {
toast.add({ title: t("ClipboardPasteDeniedByPolicy"), color: "warning" });
return false;
}
if (props.disabled || props.pasteDisabled) {
toast.add({ title: `${t("Paste")} ${t("NoPermission")}`, color: "warning" });
return false;
}
if (maxLength.value && Array.from(text).length > maxLength.value) {
toast.add({ title: `${t("Paste")} ${t("ClipboardTextLimitExceeded")}: ${maxLength.value}`, color: "warning" });
return false;
}
return true;
};

const handleInput = useDebounceFn((value: string) => {
if (!validateText(value)) return;
emit("update:text", value);
}, 300);

const loadClipboardText = async () => {
try {
isLoading.value = true;
const text = await readClipboardText();
if (!validateText(text)) return;
inputValue.value = text;
await handleInput(text);
} catch (error) {
console.log("Failed to read clipboard text:", error);
console.debug("Failed to read clipboard text:", error);
} finally {
isLoading.value = false;
}
Expand All @@ -48,28 +73,31 @@ const handleFocus = async () => {
<CardContainer :title="t('Clipboard')">
<div class="mb-3 flex items-center justify-between gap-3">
<span class="text-sm">{{ t("ShowRemoteClip") }}</span>
<USwitch v-model="showRemoteText" :disabled="props.disabled" />
<USwitch v-model="showRemoteText" :disabled="props.disabled || props.copyDisabled" />
</div>

<UTextarea
v-model="inputValue"
:rows="4"
:maxlength="4096"
autoresize
:disabled="props.disabled"
:disabled="props.disabled || props.pasteDisabled || props.pastePolicyDisabled"
:placeholder="t('AutoPasteOnClick')"
class="w-full"
@update:model-value="handleInput"
@focus="handleFocus"
/>
<div class="mt-1 text-right text-xs text-muted">
{{ inputLength }}
<template v-if="maxLength">/ {{ maxLength }}</template>
</div>

<UTextarea
v-if="showRemoteText"
:model-value="props.remoteText"
:rows="4"
readonly
autoresize
:disabled="props.disabled"
:disabled="props.disabled || props.copyDisabled"
class="mt-3 w-full"
/>
</CardContainer>
Expand Down
Loading
Loading