diff --git a/src/main/index.ts b/src/main/index.ts index e1e1bf64..0d9d7253 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ /* eslint-disable no-shadow */ -import { app, ipcMain, globalShortcut, desktopCapturer } from "electron"; +import { app, ipcMain, globalShortcut, desktopCapturer, session } from "electron"; import { electronApp, optimizer } from "@electron-toolkit/utils"; import { WindowManager } from "./window-manager"; import { MenuManager } from "./menu-manager"; @@ -8,6 +8,57 @@ let windowManager: WindowManager; let menuManager: MenuManager; let isQuitting = false; +interface BackendAuthPayload { + baseUrl: string; + basicAuth: { + enabled: boolean; + username: string; + password: string; + }; +} + +interface BackendAuthState { + hostname: string; + port: string; + header?: string; +} + +const getDefaultPort = (protocol: string) => (protocol === 'https:' || protocol === 'wss:' ? '443' : '80'); + +let backendAuthState: BackendAuthState | null = null; + +const matchesBackend = (targetUrl: string) => { + if (!backendAuthState) return false; + try { + const parsed = new URL(targetUrl); + const port = parsed.port || getDefaultPort(parsed.protocol); + return parsed.hostname === backendAuthState.hostname && port === backendAuthState.port; + } catch (error) { + return false; + } +}; + +const updateBackendAuthState = (payload: BackendAuthPayload) => { + if (!payload?.baseUrl) { + backendAuthState = null; + return; + } + + try { + const parsed = new URL(payload.baseUrl); + const port = parsed.port || getDefaultPort(parsed.protocol); + backendAuthState = { + hostname: parsed.hostname, + port, + header: payload.basicAuth?.enabled + ? `Basic ${Buffer.from(`${payload.basicAuth.username}:${payload.basicAuth.password}`, 'utf-8').toString('base64')}` + : undefined, + }; + } catch (error) { + backendAuthState = null; + } +}; + function setupIPC(): void { ipcMain.handle("get-platform", () => process.platform); @@ -71,6 +122,32 @@ function setupIPC(): void { const sources = await desktopCapturer.getSources({ types: ['screen'] }); return sources[0].id; }); + + ipcMain.on('update-backend-auth', (_event, payload: BackendAuthPayload) => { + updateBackendAuthState(payload); + }); +} + +function setupBackendAuthInterceptor(): void { + const defaultSession = session?.defaultSession; + if (!defaultSession) { + return; + } + + defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { + if (backendAuthState && matchesBackend(details.url)) { + const requestHeaders = { ...details.requestHeaders }; + if (backendAuthState.header) { + requestHeaders.Authorization = backendAuthState.header; + } else { + delete requestHeaders.Authorization; + } + callback({ requestHeaders }); + return; + } + + callback({ requestHeaders: details.requestHeaders }); + }); } app.whenReady().then(() => { @@ -110,6 +187,7 @@ app.whenReady().then(() => { // } setupIPC(); + setupBackendAuthInterceptor(); app.on("activate", () => { const window = windowManager.getWindow(); diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index c227dbbd..25ab76ce 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -18,6 +18,10 @@ declare global { setMode: (mode: 'window' | 'pet') => void getConfigFiles: () => Promise updateConfigFiles: (files: any[]) => void + updateBackendAuth: (config: { + baseUrl: string + basicAuth: { enabled: boolean; username: string; password: string } + }) => void } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index f4c5dc84..204b4c91 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -66,6 +66,12 @@ const api = { updateConfigFiles: (files: ConfigFile[]) => { ipcRenderer.send('update-config-files', files); }, + updateBackendAuth: (config: { + baseUrl: string + basicAuth: { enabled: boolean; username: string; password: string } + }) => { + ipcRenderer.send('update-backend-auth', config); + }, }; if (process.contextIsolated) { diff --git a/src/renderer/src/components/canvas/background.tsx b/src/renderer/src/components/canvas/background.tsx index f0cca4a2..c75a8f23 100644 --- a/src/renderer/src/components/canvas/background.tsx +++ b/src/renderer/src/components/canvas/background.tsx @@ -53,4 +53,4 @@ const Background = memo(({ children }: { children?: React.ReactNode }) => { Background.displayName = 'Background'; -export default Background; +export default Background; \ No newline at end of file diff --git a/src/renderer/src/components/sidebar/setting/common.tsx b/src/renderer/src/components/sidebar/setting/common.tsx index aa10282c..da384e6b 100644 --- a/src/renderer/src/components/sidebar/setting/common.tsx +++ b/src/renderer/src/components/sidebar/setting/common.tsx @@ -86,6 +86,9 @@ interface InputFieldProps { onChange: (value: string) => void placeholder?: string help?: string + error?: string + disabled?: boolean + type?: string } // Reusable Components @@ -187,6 +190,9 @@ export function InputField({ onChange, placeholder, help, + error, + disabled = false, + type = 'text', }: InputFieldProps): JSX.Element { return ( } } + errorText={error} > onChange(e.target.value)} + type={type} + disabled={disabled} /> ); diff --git a/src/renderer/src/components/sidebar/setting/general.tsx b/src/renderer/src/components/sidebar/setting/general.tsx index 9fbbafa3..cc0d3de8 100644 --- a/src/renderer/src/components/sidebar/setting/general.tsx +++ b/src/renderer/src/components/sidebar/setting/general.tsx @@ -9,7 +9,7 @@ import { useWebSocket } from "@/context/websocket-context"; import { SelectField, SwitchField, InputField } from "./common"; interface GeneralProps { - onSave?: (callback: () => void) => () => void; + onSave?: (callback: () => boolean | void) => () => void; onCancel?: (callback: () => void) => () => void; } @@ -26,11 +26,37 @@ const useCollections = () => { }); const backgrounds = createListCollection({ - items: - backgroundFiles?.map((filename) => ({ - label: String(filename), - value: `/bg/${filename}`, - })) || [], + items: (backgroundFiles ?? []) + .map((raw) => { + const entry = raw as unknown as { name?: string; url?: string } | string | null | undefined; + if (entry == null) { + return null; + } + if (typeof entry === 'string') { + const trimmed = entry.trim(); + if (!trimmed) { + return null; + } + const label = trimmed.split('/').filter(Boolean).pop() ?? trimmed; + return { + label: label || trimmed, + value: trimmed, + }; + } + const name = entry.name?.trim(); + const url = entry.url?.trim(); + const fallbackValue = name || url || ''; + if (!fallbackValue) { + return null; + } + const labelSource = name || url; + const label = labelSource?.split('/').filter(Boolean).pop() ?? labelSource; + return { + label: label || fallbackValue, + value: name || fallbackValue, + }; + }) + .filter((item): item is { label: string; value: string } => Boolean(item?.value)), }); const characterPresets = createListCollection({ @@ -51,7 +77,18 @@ function General({ onSave, onCancel }: GeneralProps): JSX.Element { const { t, i18n } = useTranslation(); const bgUrlContext = useBgUrl(); const { confName, setConfName } = useConfig(); - const { wsUrl, setWsUrl, baseUrl, setBaseUrl } = useWebSocket(); + const { + wsUrl, + setWsUrl, + baseUrl, + setBaseUrl, + basicAuthEnabled, + setBasicAuthEnabled, + basicAuthUsername, + setBasicAuthUsername, + basicAuthPassword, + setBasicAuthPassword, + } = useWebSocket(); const collections = useCollections(); const { @@ -61,6 +98,7 @@ function General({ onSave, onCancel }: GeneralProps): JSX.Element { handleCharacterPresetChange, showSubtitle, setShowSubtitle, + basicAuthErrors, } = useGeneralSettings({ bgUrlContext, confName, @@ -71,6 +109,12 @@ function General({ onSave, onCancel }: GeneralProps): JSX.Element { onBaseUrlChange: setBaseUrl, onSave, onCancel, + basicAuthEnabled, + basicAuthUsername, + basicAuthPassword, + onBasicAuthEnabledChange: setBasicAuthEnabled, + onBasicAuthUsernameChange: setBasicAuthUsername, + onBasicAuthPasswordChange: setBasicAuthPassword, }); if (settings.language[0] !== i18n.language) { @@ -140,6 +184,31 @@ function General({ onSave, onCancel }: GeneralProps): JSX.Element { placeholder="Enter Base URL" /> + handleSettingChange("basicAuthEnabled", checked)} + /> + + handleSettingChange("basicAuthUsername", value)} + placeholder={t("settings.general.basicAuthUsernamePlaceholder")} + disabled={!settings.basicAuthEnabled} + error={settings.basicAuthEnabled ? basicAuthErrors.username : undefined} + /> + + handleSettingChange("basicAuthPassword", value)} + placeholder={t("settings.general.basicAuthPasswordPlaceholder")} + disabled={!settings.basicAuthEnabled} + error={settings.basicAuthEnabled ? basicAuthErrors.password : undefined} + type="password" + /> + void)[]>([]); + const [saveHandlers, setSaveHandlers] = useState<(() => boolean | void | Promise)[]>([]); const [cancelHandlers, setCancelHandlers] = useState<(() => void)[]>([]); const [activeTab, setActiveTab] = useState('general'); - const handleSaveCallback = useCallback((handler: () => void) => { + const handleSaveCallback = useCallback((handler: () => boolean | void | Promise) => { setSaveHandlers((prev) => [...prev, handler]); return (): void => { setSaveHandlers((prev) => prev.filter((h) => h !== handler)); @@ -49,9 +49,22 @@ function SettingUI({ open, onClose }: SettingUIProps): JSX.Element { }; }, []); - const handleSave = useCallback((): void => { - saveHandlers.forEach((handler) => handler()); - onClose(); + const handleSave = useCallback(async (): Promise => { + let hasError = false; + for (const handler of saveHandlers) { + try { + const result = await handler(); + if (result === false) { + hasError = true; + } + } catch (error) { + console.error('Settings save handler failed', error); + hasError = true; + } + } + if (!hasError) { + onClose(); + } }, [saveHandlers, onClose]); const handleCancel = useCallback((): void => { diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index afb705aa..bbff4773 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -3,7 +3,7 @@ import { Box, Button, Menu } from '@chakra-ui/react'; import { FiSettings, FiClock, FiPlus, FiChevronLeft, FiUsers, FiLayers } from 'react-icons/fi'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import { sidebarStyles } from './sidebar-styles'; import SettingUI from './setting/setting-ui'; import ChatHistoryPanel from './chat-history-panel'; @@ -12,6 +12,7 @@ import HistoryDrawer from './history-drawer'; import { useSidebar } from '@/hooks/sidebar/use-sidebar'; import GroupDrawer from './group-drawer'; import { ModeType } from '@/context/mode-context'; +import { OPEN_SETTINGS_EVENT } from '@/constants/events'; // Type definitions interface SidebarProps { @@ -146,6 +147,17 @@ function Sidebar({ isCollapsed = false, onToggle }: SidebarProps): JSX.Element { isElectron, } = useSidebar(); + useEffect(() => { + const handleOpenSettings = () => { + onSettingsOpen(); + }; + + window.addEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings); + return () => { + window.removeEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings); + }; + }, [onSettingsOpen]); + return ( diff --git a/src/renderer/src/constants/backend.ts b/src/renderer/src/constants/backend.ts new file mode 100644 index 00000000..cb74ae92 --- /dev/null +++ b/src/renderer/src/constants/backend.ts @@ -0,0 +1,4 @@ +export const DEFAULT_BACKEND_WS_URL = 'ws://127.0.0.1:12393/client-ws'; +export const DEFAULT_BACKEND_BASE_URL = 'http://127.0.0.1:12393'; +export const DEFAULT_BASIC_AUTH_USERNAME = 'admin'; +export const DEFAULT_BASIC_AUTH_PASSWORD = 'change-me'; diff --git a/src/renderer/src/constants/events.ts b/src/renderer/src/constants/events.ts new file mode 100644 index 00000000..ad008a4d --- /dev/null +++ b/src/renderer/src/constants/events.ts @@ -0,0 +1,3 @@ +export const OPEN_SETTINGS_EVENT = 'app-open-settings'; +export const AUTH_STATUS_EVENT = 'app-auth-status'; + diff --git a/src/renderer/src/context/bgurl-context.tsx b/src/renderer/src/context/bgurl-context.tsx index 2f279695..dc4aefa3 100644 --- a/src/renderer/src/context/bgurl-context.tsx +++ b/src/renderer/src/context/bgurl-context.tsx @@ -1,5 +1,5 @@ import { - createContext, useMemo, useContext, useState, useCallback, + createContext, useMemo, useContext, useState, useCallback, useEffect, } from 'react'; import { useLocalStorage } from '@/hooks/utils/use-local-storage'; import { useWebSocket } from './websocket-context'; @@ -40,18 +40,57 @@ const BgUrlContext = createContext(null); * @param {Object} props - Provider props * @param {React.ReactNode} props.children - Child components */ +const normalizeBackgroundUrl = (base: string, resource?: string) => { + if (resource === undefined || resource === null) { + return ''; + } + const normalizeScheme = (value: string) => { + if (!value) return value; + if (value.startsWith('ws://')) return `http://${value.slice(5)}`; + if (value.startsWith('wss://')) return `https://${value.slice(6)}`; + return value; + }; + + const safeBase = normalizeScheme(base); + const safeResource = normalizeScheme(resource); + + try { + return new URL(safeResource).toString(); + } catch { + try { + return new URL(safeResource, safeBase).toString(); + } catch { + return safeResource; + } + } +}; + export function BgUrlProvider({ children }: { children: React.ReactNode }) { const { baseUrl } = useWebSocket(); - const DEFAULT_BACKGROUND = `${baseUrl}/bg/ceiling-window-room-night.jpeg`; + const DEFAULT_BACKGROUND = normalizeBackgroundUrl(baseUrl, '/bg/ceiling-window-room-night.jpeg'); + console.debug('[BgUrl] Provider init', { baseUrl, DEFAULT_BACKGROUND }); - // Local storage for persistent background URL - const [backgroundUrl, setBackgroundUrl] = useLocalStorage( + const [storedBackgroundUrl, setStoredBackgroundUrl] = useLocalStorage( 'backgroundUrl', DEFAULT_BACKGROUND, ); + const backgroundUrl = useMemo( + () => normalizeBackgroundUrl(baseUrl, storedBackgroundUrl), + [baseUrl, storedBackgroundUrl], + ); + const setBackgroundUrl = useCallback((value: string) => { + const normalized = normalizeBackgroundUrl(baseUrl, value); + console.debug('[BgUrl] setBackgroundUrl', { value, normalized }); + setStoredBackgroundUrl(normalized); + }, [baseUrl, setStoredBackgroundUrl]); // State for background files list - const [backgroundFiles, setBackgroundFiles] = useState([]); + const [backgroundFilesState, setBackgroundFilesState] = useState([]); + const backgroundFiles = backgroundFilesState; + const setBackgroundFiles = useCallback((files: BackgroundFile[]) => { + console.debug('[BgUrl] setBackgroundFiles', { incoming: files }); + setBackgroundFilesState(files); + }, []); // Reset background to default const resetBackground = useCallback(() => { @@ -60,12 +99,16 @@ export function BgUrlProvider({ children }: { children: React.ReactNode }) { // Add new background file const addBackgroundFile = useCallback((file: BackgroundFile) => { - setBackgroundFiles((prev) => [...prev, file]); + console.debug('[BgUrl] addBackgroundFile', file); + setBackgroundFilesState((prev) => [ + ...prev, + file, + ]); }, []); // Remove background file const removeBackgroundFile = useCallback((name: string) => { - setBackgroundFiles((prev) => prev.filter((file) => file.name !== name)); + setBackgroundFilesState((prev) => prev.filter((file) => file.name !== name)); }, []); // Check if current background is default @@ -76,6 +119,21 @@ export function BgUrlProvider({ children }: { children: React.ReactNode }) { const [useCameraBackground, setUseCameraBackground] = useState(false); + useEffect(() => { + if (storedBackgroundUrl !== backgroundUrl) { + console.debug('[BgUrl] syncing storedBackgroundUrl', { storedBackgroundUrl, backgroundUrl }); + setStoredBackgroundUrl(backgroundUrl); + } + }, [storedBackgroundUrl, backgroundUrl, setStoredBackgroundUrl]); + + useEffect(() => { + console.debug('[BgUrl] backgroundFilesState updated', backgroundFilesState); + }, [backgroundFilesState]); + + useEffect(() => { + console.debug('[BgUrl] baseUrl changed', baseUrl); + }, [baseUrl]); + // Memoized context value const contextValue = useMemo(() => ({ backgroundUrl, @@ -88,7 +146,7 @@ export function BgUrlProvider({ children }: { children: React.ReactNode }) { isDefaultBackground, useCameraBackground, setUseCameraBackground, - }), [backgroundUrl, setBackgroundUrl, backgroundFiles, resetBackground, addBackgroundFile, removeBackgroundFile, isDefaultBackground, useCameraBackground]); + }), [backgroundUrl, setBackgroundUrl, backgroundFiles, setBackgroundFiles, resetBackground, addBackgroundFile, removeBackgroundFile, isDefaultBackground, useCameraBackground, baseUrl]); return ( diff --git a/src/renderer/src/context/live2d-config-context.tsx b/src/renderer/src/context/live2d-config-context.tsx index c5497ac6..98735e35 100644 --- a/src/renderer/src/context/live2d-config-context.tsx +++ b/src/renderer/src/context/live2d-config-context.tsx @@ -104,6 +104,13 @@ export const Live2DConfigContext = createContext(null) * @param {Object} props - Provider props * @param {React.ReactNode} props.children - Child components */ +const normalizeModelUrl = (url: string | undefined): string | undefined => { + if (!url) return url; + if (/^ws:\/\//i.test(url)) return url.replace(/^ws:\/\//i, 'http://'); + if (/^wss:\/\//i.test(url)) return url.replace(/^wss:\/\//i, 'https://'); + return url; +}; + export function Live2DConfigProvider({ children }: { children: React.ReactNode }) { const { confUid } = useConfig(); @@ -131,6 +138,7 @@ export function Live2DConfigProvider({ children }: { children: React.ReactNode } setModelInfoState({ ...info, + url: normalizeModelUrl(info.url) ?? '', kScale: finalScale, pointerInteractive: "pointerInteractive" in info diff --git a/src/renderer/src/context/websocket-context.tsx b/src/renderer/src/context/websocket-context.tsx index 4c6a59c6..b50655d2 100644 --- a/src/renderer/src/context/websocket-context.tsx +++ b/src/renderer/src/context/websocket-context.tsx @@ -1,10 +1,22 @@ /* eslint-disable react/jsx-no-constructed-context-values */ -import React, { useContext, useCallback } from 'react'; -import { wsService } from '@/services/websocket-service'; +import React, { useContext, useCallback, useEffect } from 'react'; +import { wsService, type WebSocketConnectionState } from '@/services/websocket-service'; import { useLocalStorage } from '@/hooks/utils/use-local-storage'; +import { + DEFAULT_BACKEND_WS_URL, + DEFAULT_BACKEND_BASE_URL, + DEFAULT_BASIC_AUTH_USERNAME, + DEFAULT_BASIC_AUTH_PASSWORD, +} from '@/constants/backend'; +import { deriveWsUrl, normalizeHttpOrigin, updateBackendConfig } from '@/services/backend-settings'; -const DEFAULT_WS_URL = 'ws://127.0.0.1:12393/client-ws'; -const DEFAULT_BASE_URL = 'http://127.0.0.1:12393'; +const DEFAULT_WS_URL = DEFAULT_BACKEND_WS_URL; +const DEFAULT_BASE_URL = DEFAULT_BACKEND_BASE_URL; +const DEFAULT_BASIC_AUTH = { + enabled: false, + username: DEFAULT_BASIC_AUTH_USERNAME, + password: DEFAULT_BASIC_AUTH_PASSWORD, +}; export interface HistoryInfo { uid: string; @@ -18,22 +30,34 @@ export interface HistoryInfo { interface WebSocketContextProps { sendMessage: (message: object) => void; - wsState: string; + wsState: WebSocketConnectionState; reconnect: () => void; wsUrl: string; setWsUrl: (url: string) => void; baseUrl: string; setBaseUrl: (url: string) => void; + basicAuthEnabled: boolean; + setBasicAuthEnabled: (enabled: boolean) => void; + basicAuthUsername: string; + setBasicAuthUsername: (username: string) => void; + basicAuthPassword: string; + setBasicAuthPassword: (password: string) => void; } export const WebSocketContext = React.createContext({ sendMessage: wsService.sendMessage.bind(wsService), wsState: 'CLOSED', - reconnect: () => wsService.connect(DEFAULT_WS_URL), + reconnect: () => wsService.reconnect(), wsUrl: DEFAULT_WS_URL, setWsUrl: () => {}, baseUrl: DEFAULT_BASE_URL, setBaseUrl: () => {}, + basicAuthEnabled: DEFAULT_BASIC_AUTH.enabled, + setBasicAuthEnabled: () => {}, + basicAuthUsername: DEFAULT_BASIC_AUTH.username, + setBasicAuthUsername: () => {}, + basicAuthPassword: DEFAULT_BASIC_AUTH.password, + setBasicAuthPassword: () => {}, }); export function useWebSocket() { @@ -50,19 +74,72 @@ export const defaultBaseUrl = DEFAULT_BASE_URL; export function WebSocketProvider({ children }: { children: React.ReactNode }) { const [wsUrl, setWsUrl] = useLocalStorage('wsUrl', DEFAULT_WS_URL); const [baseUrl, setBaseUrl] = useLocalStorage('baseUrl', DEFAULT_BASE_URL); + const [basicAuthEnabled, setBasicAuthEnabled] = useLocalStorage('basicAuthEnabled', DEFAULT_BASIC_AUTH.enabled); + const [basicAuthUsername, setBasicAuthUsername] = useLocalStorage('basicAuthUsername', DEFAULT_BASIC_AUTH.username); + const [basicAuthPassword, setBasicAuthPassword] = useLocalStorage('basicAuthPassword', DEFAULT_BASIC_AUTH.password); const handleSetWsUrl = useCallback((url: string) => { setWsUrl(url); - wsService.connect(url); }, [setWsUrl]); + useEffect(() => { + const normalizedBase = normalizeHttpOrigin(baseUrl); + if (normalizedBase !== baseUrl) { + setBaseUrl(normalizedBase); + return; + } + + const authConfig = { + enabled: basicAuthEnabled, + username: basicAuthUsername, + password: basicAuthPassword, + }; + updateBackendConfig({ + baseUrl: normalizedBase, + wsUrl, + basicAuth: authConfig, + }); + const targetUrl = deriveWsUrl(normalizedBase, wsUrl); + wsService.connect(targetUrl, { basicAuth: authConfig }); + }, [ + baseUrl, + setBaseUrl, + wsUrl, + basicAuthEnabled, + basicAuthUsername, + basicAuthPassword, + ]); + const value = { sendMessage: wsService.sendMessage.bind(wsService), wsState: 'CLOSED', - reconnect: () => wsService.connect(wsUrl), + reconnect: () => { + const normalizedBase = normalizeHttpOrigin(baseUrl); + if (normalizedBase !== baseUrl) { + setBaseUrl(normalizedBase); + return; + } + const authConfig = { + enabled: basicAuthEnabled, + username: basicAuthUsername, + password: basicAuthPassword, + }; + updateBackendConfig({ + baseUrl: normalizedBase, + wsUrl, + basicAuth: authConfig, + }); + wsService.reconnect(); + }, wsUrl, setWsUrl: handleSetWsUrl, baseUrl, setBaseUrl, + basicAuthEnabled, + setBasicAuthEnabled, + basicAuthUsername, + setBasicAuthUsername, + basicAuthPassword, + setBasicAuthPassword, }; return ( diff --git a/src/renderer/src/hooks/canvas/use-ws-status.ts b/src/renderer/src/hooks/canvas/use-ws-status.ts index 766194ab..a6fd9121 100644 --- a/src/renderer/src/hooks/canvas/use-ws-status.ts +++ b/src/renderer/src/hooks/canvas/use-ws-status.ts @@ -33,6 +33,13 @@ export const useWSStatus = () => { isDisconnected: false, handleClick, }; + case 'UNAUTHORIZED': + return { + color: 'red.500', + textKey: 'wsStatus.authFailed', + isDisconnected: true, + handleClick, + }; default: return { color: 'red.500', diff --git a/src/renderer/src/hooks/sidebar/setting/use-general-settings.ts b/src/renderer/src/hooks/sidebar/setting/use-general-settings.ts index 0633b539..b38854dd 100644 --- a/src/renderer/src/hooks/sidebar/setting/use-general-settings.ts +++ b/src/renderer/src/hooks/sidebar/setting/use-general-settings.ts @@ -1,6 +1,6 @@ /* eslint-disable import/order */ /* eslint-disable no-use-before-define */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { BgUrlContextState } from '@/context/bgurl-context'; import { defaultBaseUrl, defaultWsUrl } from '@/context/websocket-context'; import { useSubtitle } from '@/context/subtitle-context'; @@ -26,6 +26,9 @@ interface GeneralSettings { showSubtitle: boolean imageCompressionQuality: number; imageMaxWidth: number; + basicAuthEnabled: boolean; + basicAuthUsername: string; + basicAuthPassword: string; } interface UseGeneralSettingsProps { @@ -36,10 +39,213 @@ interface UseGeneralSettingsProps { wsUrl: string onWsUrlChange: (url: string) => void onBaseUrlChange: (url: string) => void - onSave?: (callback: () => void) => () => void + onSave?: (callback: () => boolean | void) => () => void onCancel?: (callback: () => void) => () => void + basicAuthEnabled: boolean + basicAuthUsername: string + basicAuthPassword: string + onBasicAuthEnabledChange: (enabled: boolean) => void + onBasicAuthUsernameChange: (username: string) => void + onBasicAuthPasswordChange: (password: string) => void } +interface BasicAuthErrors { + username?: string; + password?: string; +} + +const arraysShallowEqual = (a: string[], b: string[]) => ( + a.length === b.length && a.every((value, index) => value === b[index]) +); + +const settingsEqual = (prev: GeneralSettings, next: GeneralSettings) => ( + arraysShallowEqual(prev.language, next.language) + && prev.customBgUrl === next.customBgUrl + && arraysShallowEqual(prev.selectedBgUrl, next.selectedBgUrl) + && prev.backgroundUrl === next.backgroundUrl + && arraysShallowEqual(prev.selectedCharacterPreset, next.selectedCharacterPreset) + && prev.useCameraBackground === next.useCameraBackground + && prev.wsUrl === next.wsUrl + && prev.baseUrl === next.baseUrl + && prev.showSubtitle === next.showSubtitle + && prev.imageCompressionQuality === next.imageCompressionQuality + && prev.imageMaxWidth === next.imageMaxWidth + && prev.basicAuthEnabled === next.basicAuthEnabled + && prev.basicAuthUsername === next.basicAuthUsername + && prev.basicAuthPassword === next.basicAuthPassword +); + +type BackgroundFile = BgUrlContextState['backgroundFiles'][number]; + +type BackgroundEntry = BackgroundFile | string; + +const toBackgroundEntry = (value: BackgroundEntry | null | undefined): BackgroundEntry | undefined => { + if (value == null) return undefined; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (typeof value === 'object') { + return value; + } + return undefined; +}; + +const getEntryName = (entry: BackgroundEntry | undefined): string => { + if (!entry) return ''; + if (typeof entry === 'string') return entry.trim(); + return entry.name?.trim() || ''; +}; + +const getEntryUrl = (entry: BackgroundEntry | undefined): string => { + if (!entry) return ''; + if (typeof entry === 'string') return entry.trim(); + return entry.url?.trim() || ''; +}; + +const normalizeScheme = (value: string): string => { + if (!value) return value; + if (/^ws:\/\//i.test(value)) { + return value.replace(/^ws:\/\//i, 'http://'); + } + if (/^wss:\/\//i.test(value)) { + return value.replace(/^wss:\/\//i, 'https://'); + } + return value; +}; + +const ensureTrailingSlash = (value: string): string => ( + value.endsWith('/') ? value : `${value}/` +); + +const stripProtocolAndHost = (value: string): string => value.replace(/^https?:\/\/[^/]+/i, ''); + +const stripLeadingSlashes = (value: string): string => value.replace(/^[/\\]+/, ''); + +const stripBgPrefix = (value: string): string => value.replace(/^bg[/\\]/i, ''); + +const computeBackgroundOptionValue = (entry: BackgroundEntry): string => { + const name = getEntryName(entry); + if (name) { + return name; + } + const url = getEntryUrl(entry); + if (!url) { + return ''; + } + const filename = url.split('/').filter(Boolean).pop(); + return filename || url; +}; + +const matchBackgroundFile = ( + candidateRaw: string, + files?: BackgroundEntry[], +): BackgroundEntry | undefined => { + const entries = files + ?.map((value) => toBackgroundEntry(value)) + .filter((value): value is BackgroundEntry => Boolean(value)); + + if (!entries?.length) { + return undefined; + } + const candidate = candidateRaw.trim(); + if (!candidate) { + return undefined; + } + + const candidateWithoutHost = stripProtocolAndHost(candidate); + const candidateWithoutLeading = stripLeadingSlashes(candidateWithoutHost); + const candidateCore = stripBgPrefix(candidateWithoutLeading); + + return entries.find((entry) => { + const sources = [getEntryUrl(entry), getEntryName(entry)].filter(Boolean); + if (!sources.length) { + return false; + } + return sources.some((source) => { + const trimmedSource = source.trim(); + const sourceWithoutHost = stripProtocolAndHost(trimmedSource); + const sourceWithoutLeading = stripLeadingSlashes(sourceWithoutHost); + const sourceCore = stripBgPrefix(sourceWithoutLeading); + + return candidate === trimmedSource + || candidateWithoutLeading === sourceWithoutLeading + || candidateCore === sourceCore; + }); + }); +}; + +const normalizeBackgroundResourcePath = ( + value: string, + fallbackToBgDirectory: boolean, +): string => { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + if (trimmed.startsWith('/')) { + return trimmed; + } + if (trimmed.startsWith('bg/')) { + return `/${trimmed}`; + } + if (trimmed.startsWith('./') || trimmed.startsWith('../')) { + return trimmed; + } + if (fallbackToBgDirectory) { + if (trimmed.includes('/')) { + return `/${trimmed}`; + } + return `/bg/${trimmed}`; + } + return trimmed; +}; + +const resolveBackgroundUrl = ( + candidate: string | undefined, + baseUrl: string, + files?: BackgroundEntry[], + matchedFile?: BackgroundEntry, +): string | undefined => { + const trimmedCandidate = candidate?.trim(); + if (!trimmedCandidate) { + return undefined; + } + const normalizedBase = ensureTrailingSlash(normalizeScheme(baseUrl)); + const entries = files + ?.map((value) => toBackgroundEntry(value)) + .filter((value): value is BackgroundEntry => Boolean(value)); + const file = matchedFile ?? matchBackgroundFile(trimmedCandidate, entries); + const rawSource = [ + getEntryUrl(file), + getEntryName(file), + trimmedCandidate, + ].find((value) => Boolean(value && value.trim())) || trimmedCandidate; + + const rawValue = normalizeScheme(rawSource); + + if (/^https?:\/\//i.test(rawValue)) { + return rawValue; + } + + const resourcePath = normalizeBackgroundResourcePath(rawValue, Boolean(file)); + if (!resourcePath) { + return undefined; + } + + try { + return new URL(resourcePath, normalizedBase).toString(); + } catch (error) { + console.error('Failed to resolve background URL:', { + candidate: trimmedCandidate, + resourcePath, + baseUrl: normalizedBase, + error, + }); + return resourcePath; + } +}; + const loadInitialCompressionQuality = (): number => { const storedQuality = localStorage.getItem(IMAGE_COMPRESSION_QUALITY_KEY); if (storedQuality) { @@ -72,6 +278,12 @@ export const useGeneralSettings = ({ onBaseUrlChange, onSave, onCancel, + basicAuthEnabled, + basicAuthUsername, + basicAuthPassword, + onBasicAuthEnabledChange, + onBasicAuthUsernameChange, + onBasicAuthPasswordChange, }: UseGeneralSettingsProps) => { const { showSubtitle, setShowSubtitle } = useSubtitle(); const { setUseCameraBackground } = bgUrlContext || {}; @@ -81,9 +293,23 @@ export const useGeneralSettings = ({ const getCurrentBgKey = (): string[] => { if (!bgUrlContext?.backgroundUrl) return []; - const currentBgUrl = bgUrlContext.backgroundUrl; - const path = currentBgUrl.replace(baseUrl, ''); - return path.startsWith('/bg/') ? [path] : []; + const currentBgUrl = bgUrlContext.backgroundUrl.trim(); + const entries = (bgUrlContext.backgroundFiles ?? []) as unknown as BackgroundEntry[]; + + if (entries.length > 0) { + const matched = entries.find((entry) => { + const source = typeof entry === 'string' + ? entry + : entry?.url ?? entry?.name ?? ''; + const absolute = resolveBackgroundUrl(source, baseUrl, entries, entry); + return absolute === currentBgUrl; + }); + if (matched) { + return [computeBackgroundOptionValue(matched)]; + } + } + + return []; }; const getCurrentCharacterFilename = (): string[] => { @@ -106,23 +332,63 @@ export const useGeneralSettings = ({ showSubtitle, imageCompressionQuality: loadInitialCompressionQuality(), imageMaxWidth: loadInitialImageMaxWidth(), + basicAuthEnabled, + basicAuthUsername, + basicAuthPassword, }; const [settings, setSettings] = useState(initialSettings); const [originalSettings, setOriginalSettings] = useState(initialSettings); const originalConfName = confName; + const [basicAuthErrors, setBasicAuthErrors] = useState({}); + + const validateBasicAuth = useCallback(( + enabled: boolean, + username: string, + password: string, + ): boolean => { + if (!enabled) { + setBasicAuthErrors({}); + return true; + } + + const errors: BasicAuthErrors = {}; + + if (!username.trim()) { + errors.username = i18n.t('settings.general.basicAuthUsernameRequired'); + } + + if (!password.trim()) { + errors.password = i18n.t('settings.general.basicAuthPasswordRequired'); + } + + setBasicAuthErrors(errors); + return Object.keys(errors).length === 0; + }, [setBasicAuthErrors, i18n.language]); useEffect(() => { + console.debug('[useGeneralSettings] settings effect triggered', settings); setShowSubtitle(settings.showSubtitle); - const newBgUrl = settings.customBgUrl || settings.selectedBgUrl[0]; - if (newBgUrl && bgUrlContext) { - const fullUrl = newBgUrl.startsWith('http') ? newBgUrl : `${baseUrl}${newBgUrl}`; - bgUrlContext.setBackgroundUrl(fullUrl); + const newBgSelection = settings.customBgUrl || settings.selectedBgUrl[0]; + if (bgUrlContext) { + const resolvedBackground = resolveBackgroundUrl( + newBgSelection, + baseUrl, + (bgUrlContext.backgroundFiles ?? []) as unknown as BackgroundEntry[], + ); + if (resolvedBackground && resolvedBackground !== bgUrlContext.backgroundUrl) { + bgUrlContext.setBackgroundUrl(resolvedBackground); + } } onWsUrlChange(settings.wsUrl); onBaseUrlChange(settings.baseUrl); + validateBasicAuth( + settings.basicAuthEnabled, + settings.basicAuthUsername, + settings.basicAuthPassword, + ); // Apply language change if it differs from current language if (settings.language && settings.language[0] && settings.language[0] !== i18n.language) { @@ -130,7 +396,15 @@ export const useGeneralSettings = ({ } localStorage.setItem(IMAGE_COMPRESSION_QUALITY_KEY, settings.imageCompressionQuality.toString()); localStorage.setItem(IMAGE_MAX_WIDTH_KEY, settings.imageMaxWidth.toString()); - }, [settings, bgUrlContext, baseUrl, onWsUrlChange, onBaseUrlChange, setShowSubtitle]); + }, [ + settings, + bgUrlContext, + baseUrl, + onWsUrlChange, + onBaseUrlChange, + setShowSubtitle, + validateBasicAuth, + ]); useEffect(() => { if (confName) { @@ -146,29 +420,25 @@ export const useGeneralSettings = ({ } }, [confName]); - // Add save/cancel effect - useEffect(() => { - if (!onSave || !onCancel) return; - - const cleanupSave = onSave(() => { - handleSave(); - }); - - const cleanupCancel = onCancel(() => { - handleCancel(); - }); - - return () => { - cleanupSave?.(); - cleanupCancel?.(); - }; - }, [onSave, onCancel]); - const handleSettingChange = ( key: keyof GeneralSettings, value: GeneralSettings[keyof GeneralSettings], ): void => { - setSettings((prev) => ({ ...prev, [key]: value })); + setSettings((prev) => { + const next = { ...prev, [key]: value } as GeneralSettings; + if ( + key === 'basicAuthEnabled' + || key === 'basicAuthUsername' + || key === 'basicAuthPassword' + ) { + validateBasicAuth( + next.basicAuthEnabled, + next.basicAuthUsername, + next.basicAuthPassword, + ); + } + return next; + }); if (key === 'wsUrl') { onWsUrlChange(value as string); @@ -176,15 +446,42 @@ export const useGeneralSettings = ({ if (key === 'baseUrl') { onBaseUrlChange(value as string); } + if (key === 'basicAuthEnabled') { + onBasicAuthEnabledChange(value as boolean); + } + if (key === 'basicAuthUsername') { + onBasicAuthUsernameChange(value as string); + } + if (key === 'basicAuthPassword') { + onBasicAuthPasswordChange(value as string); + } // Immediately change language when it's updated if (key === 'language' && Array.isArray(value) && value.length > 0) { i18n.changeLanguage(value[0]); } }; - const handleSave = (): void => { + const handleSave = useCallback((): boolean => { + const isValid = validateBasicAuth( + settings.basicAuthEnabled, + settings.basicAuthUsername, + settings.basicAuthPassword, + ); + if (!isValid) { + return false; + } + + const changedSinceLastSave = !settingsEqual(originalSettings, settings); setOriginalSettings(settings); - }; + + if (changedSinceLastSave && typeof window !== 'undefined') { + window.setTimeout(() => { + window.location.reload(); + }, 200); + } + + return true; + }, [settings, originalSettings, validateBasicAuth, setOriginalSettings]); const handleCancel = (): void => { setSettings(originalSettings); @@ -197,6 +494,14 @@ export const useGeneralSettings = ({ } onWsUrlChange(originalSettings.wsUrl); onBaseUrlChange(originalSettings.baseUrl); + onBasicAuthEnabledChange(originalSettings.basicAuthEnabled); + onBasicAuthUsernameChange(originalSettings.basicAuthUsername); + onBasicAuthPasswordChange(originalSettings.basicAuthPassword); + validateBasicAuth( + originalSettings.basicAuthEnabled, + originalSettings.basicAuthUsername, + originalSettings.basicAuthPassword, + ); // Restore original character preset if (originalConfName) { @@ -211,6 +516,21 @@ export const useGeneralSettings = ({ } }; + useEffect(() => { + if (!onSave || !onCancel) return; + + const cleanupSave = onSave(handleSave); + + const cleanupCancel = onCancel(() => { + handleCancel(); + }); + + return () => { + cleanupSave?.(); + cleanupCancel?.(); + }; + }, [onSave, onCancel, handleSave, handleCancel]); + const handleCharacterPresetChange = (value: string[]): void => { const selectedFilename = value[0]; const selectedConfig = configFiles.find((config) => config.filename === selectedFilename); @@ -256,5 +576,6 @@ export const useGeneralSettings = ({ handleCharacterPresetChange, showSubtitle, setShowSubtitle, + basicAuthErrors, }; }; diff --git a/src/renderer/src/locales/en/translation.json b/src/renderer/src/locales/en/translation.json index 96cb2c82..8469f1df 100644 --- a/src/renderer/src/locales/en/translation.json +++ b/src/renderer/src/locales/en/translation.json @@ -1,139 +1,151 @@ { - "common": { - "save": "Save", - "cancel": "Cancel", - "settings": "Settings", - "close": "Close", - "accept": "Accept" - }, - "settings": { - "tabs": { - "general": "General", - "live2d": "Live2D", - "asr": "ASR", - "tts": "TTS", - "agent": "Agent", - "about": "About" - }, - "general": { - "language": "Language", - "useCameraBackground": "Use Camera Background", - "showSubtitle": "Show Subtitle", - "backgroundImage": "Background Image", - "customBgUrlPlaceholder": "Enter image URL", - "customBgUrl": "Or enter a custom background URL", - "characterPreset": "Character Preset", - "wsUrl": "WebSocket URL", - "baseUrl": "Base URL", - "imageCompressionQuality": "Image Compression Quality", - "imageCompressionQualityPlaceholder": "Enter compression quality (0-100)", - "imageCompressionQualityHelp": "JPEG compression quality (0.1-1.0). Default is 0.8 to reduce file size when transmitting images to AI models, as we don't compress images during API transmission which could result in large file sizes.", - "imageMaxWidth": "Image Max Width", - "imageMaxWidthPlaceholder": "Enter maximum width in pixels", - "imageMaxWidthHelp": "Maximum width for image resizing. Images exceeding this width will be proportionally scaled down. Set to 0 for no size limit. This feature exists because some AI models may have limitations when processing very large images. However, most AI models can handle images automatically, so the default is 0 (no restriction) to preserve your original images." - }, - "live2d": { - "pointerInteractive": "Pointer Interactive", - "scrollToResize": "Enable Scroll to Resize" - }, - "asr": { - "autoStopMic": "Auto Stop Mic When AI Start Speaking", - "autoStopMicDesc": "Automatically stops microphone when AI begins speaking to prevent audio feedback", - "autoStartMicOnConvEnd": "Auto Start Mic When Conversation End", - "autoStartMicOnConvEndDesc": "Automatically restarts microphone when AI finishes speaking for seamless conversation", - "autoStartMicOn": "Auto Start Mic When AI Interrupted", - "autoStartMicOnDesc": "Automatically restarts microphone when you interrupt AI for continuous interaction", - "positiveSpeechThreshold": "Speech Prob Threshold", - "positiveSpeechThresholdDesc": "Minimum confidence level (1-100) required to detect speech. Higher values reduce false positives", - "negativeSpeechThreshold": "Negative Speech Threshold", - "negativeSpeechThresholdDesc": "Confidence level (0-100) below which speech detection stops. Lower values make detection less sensitive", - "redemptionFrames": "Redemption Frames", - "redemptionFramesDesc": "Number of consecutive frames (1-100) needed to confirm speech detection. Higher values reduce noise triggers" - }, - "agent": { - "allowProactiveSpeak": "Allow AI to Speak Proactively", - "idleSecondsToSpeak": "Idle seconds allow AI to speak", - "allowButtonTrigger": "Prompt AI to Speak via Raise Hand Button" - }, - "about": { - "title": "Open LLM VTuber Frontend", - "version": "Version", - "projectLinks": "Project Links", - "github": "GitHub", - "documentation": "Documentation", - "license": "License", - "copyright": "Copyright", - "viewLicense": "View License" - } - }, - "footer": { - "typeYourMessage": "Type your message...", - "cameraControl": "Click to start camera", - "cameraStopping": "Click to stop camera", - "screenControl": "Click to start screen capture", - "screenStopping": "Click to stop screen capture" - }, - "sidebar": { - "camera": "Camera", - "screen": "Screen", - "browser": "Browser", - "live": "Live", - "noMessages": "No messages yet. Start a conversation!", - "noBrowserSession": "No active browser session", - "browserSession": "Browser Session" - }, - "group": { - "management": "Group Management", - "yourUuid": "Your UUID", - "inviteMember": "Invite Member", - "enterMemberUuid": "Enter member UUID", - "invite": "Invite", - "members": "Members", - "you": "You", - "leaveGroup": "Leave Group", - "removeMember": "Remove Member", - "leave": "Leave" - }, - "history": { - "chatHistoryList": "Chat History List", - "noMessages": "No messages" - }, - "notification": { - "characterLoaded": "New Character Loaded", - "characterSwitched": "Character switched", - "historyLoaded": "History loaded", - "newConversation": "New Conversation Started", - "newChatHistory": "New chat history created", - "historyDeleteSuccess": "History deleted successfully", - "historyDeleteFail": "Failed to delete history" - }, - "error": { - "cameraApiNotSupported": "Camera API is not supported on this device", - "noCameraFound": "No camera found on this device", - "failedStartCamera": "Failed to start camera", - "failedStartBackgroundCamera": "Failed to start background camera", - "failedStartScreenCapture": "Failed to start screen capture", - "failedStartVAD": "Failed to start VAD", - "llmCantHear": "The LLM can't hear you.", - "audioPlayback": "Audio playback error", - "enterValidUuid": "Please enter a valid UUID", - "cannotDeleteCurrentHistory": "Cannot delete current chat history", - "failedCapture": "Failed to capture {{source}} frame", - "failedParseWebSocket": "Failed to parse WebSocket message", - "websocketNotOpen": "WebSocket is not open.", - "vadMisfire": "Voice detected but too brief. Try speaking louder/longer, or adjust settings (lower speech threshold, lower negative threshold, reduce redemption frames)." - }, - "aiState": { - "idle": "idle", - "thinking-speaking": "thinking/speaking", - "interrupted": "interrupted", - "loading": "loading", - "listening": "listening", - "waiting": "waiting" - }, - "wsStatus": { - "connected": "Connected", - "connecting": "Connecting", - "clickToReconnect": "Click to Reconnect" - } -} \ No newline at end of file + "common": { + "save": "Save", + "cancel": "Cancel", + "settings": "Settings", + "close": "Close", + "accept": "Accept" + }, + "settings": { + "tabs": { + "general": "General", + "live2d": "Live2D", + "asr": "ASR", + "tts": "TTS", + "agent": "Agent", + "about": "About" + }, + "general": { + "language": "Language", + "useCameraBackground": "Use Camera Background", + "showSubtitle": "Show Subtitle", + "backgroundImage": "Background Image", + "customBgUrlPlaceholder": "Enter image URL", + "customBgUrl": "Or enter a custom background URL", + "characterPreset": "Character Preset", + "wsUrl": "WebSocket URL", + "baseUrl": "Base URL", + "basicAuthEnabled": "Enable Basic HTTP Auth", + "basicAuthUsername": "Username", + "basicAuthUsernamePlaceholder": "Enter username", + "basicAuthPassword": "Password", + "basicAuthPasswordPlaceholder": "Enter password", + "basicAuthUsernameRequired": "Username is required when Basic Auth is enabled", + "basicAuthPasswordRequired": "Password is required when Basic Auth is enabled", + "imageCompressionQuality": "Image Compression Quality", + "imageCompressionQualityPlaceholder": "Enter compression quality (0-100)", + "imageCompressionQualityHelp": "JPEG compression quality (0.1-1.0). Default is 0.8 to reduce file size when transmitting images to AI models, as we don\u0027t compress images during API transmission which could result in large file sizes.", + "imageMaxWidth": "Image Max Width", + "imageMaxWidthPlaceholder": "Enter maximum width in pixels", + "imageMaxWidthHelp": "Maximum width for image resizing. Images exceeding this width will be proportionally scaled down. Set to 0 for no size limit. This feature exists because some AI models may have limitations when processing very large images. However, most AI models can handle images automatically, so the default is 0 (no restriction) to preserve your original images." + }, + "live2d": { + "pointerInteractive": "Pointer Interactive", + "scrollToResize": "Enable Scroll to Resize" + }, + "asr": { + "autoStopMic": "Auto Stop Mic When AI Start Speaking", + "autoStopMicDesc": "Automatically stops microphone when AI begins speaking to prevent audio feedback", + "autoStartMicOnConvEnd": "Auto Start Mic When Conversation End", + "autoStartMicOnConvEndDesc": "Automatically restarts microphone when AI finishes speaking for seamless conversation", + "autoStartMicOn": "Auto Start Mic When AI Interrupted", + "autoStartMicOnDesc": "Automatically restarts microphone when you interrupt AI for continuous interaction", + "positiveSpeechThreshold": "Speech Prob Threshold", + "positiveSpeechThresholdDesc": "Minimum confidence level (1-100) required to detect speech. Higher values reduce false positives", + "negativeSpeechThreshold": "Negative Speech Threshold", + "negativeSpeechThresholdDesc": "Confidence level (0-100) below which speech detection stops. Lower values make detection less sensitive", + "redemptionFrames": "Redemption Frames", + "redemptionFramesDesc": "Number of consecutive frames (1-100) needed to confirm speech detection. Higher values reduce noise triggers" + }, + "agent": { + "allowProactiveSpeak": "Allow AI to Speak Proactively", + "idleSecondsToSpeak": "Idle seconds allow AI to speak", + "allowButtonTrigger": "Prompt AI to Speak via Raise Hand Button" + }, + "about": { + "title": "Open LLM VTuber Frontend", + "version": "Version", + "projectLinks": "Project Links", + "github": "GitHub", + "documentation": "Documentation", + "license": "License", + "copyright": "Copyright", + "viewLicense": "View License" + } + }, + "footer": { + "typeYourMessage": "Type your message...", + "cameraControl": "Click to start camera", + "cameraStopping": "Click to stop camera", + "screenControl": "Click to start screen capture", + "screenStopping": "Click to stop screen capture" + }, + "sidebar": { + "camera": "Camera", + "screen": "Screen", + "browser": "Browser", + "live": "Live", + "noMessages": "No messages yet. Start a conversation!", + "noBrowserSession": "No active browser session", + "browserSession": "Browser Session" + }, + "group": { + "management": "Group Management", + "yourUuid": "Your UUID", + "inviteMember": "Invite Member", + "enterMemberUuid": "Enter member UUID", + "invite": "Invite", + "members": "Members", + "you": "You", + "leaveGroup": "Leave Group", + "removeMember": "Remove Member", + "leave": "Leave" + }, + "history": { + "chatHistoryList": "Chat History List", + "noMessages": "No messages" + }, + "notification": { + "characterLoaded": "New Character Loaded", + "characterSwitched": "Character switched", + "historyLoaded": "History loaded", + "newConversation": "New Conversation Started", + "newChatHistory": "New chat history created", + "historyDeleteSuccess": "History deleted successfully", + "historyDeleteFail": "Failed to delete history" + }, + "error": { + "cameraApiNotSupported": "Camera API is not supported on this device", + "noCameraFound": "No camera found on this device", + "failedStartCamera": "Failed to start camera", + "failedStartBackgroundCamera": "Failed to start background camera", + "failedStartScreenCapture": "Failed to start screen capture", + "failedStartVAD": "Failed to start VAD", + "llmCantHear": "The LLM can\u0027t hear you.", + "audioPlayback": "Audio playback error", + "enterValidUuid": "Please enter a valid UUID", + "cannotDeleteCurrentHistory": "Cannot delete current chat history", + "failedCapture": "Failed to capture {{source}} frame", + "failedParseWebSocket": "Failed to parse WebSocket message", + "websocketNotOpen": "WebSocket is not open.", + "vadMisfire": "Voice detected but too brief. Try speaking louder/longer, or adjust settings (lower speech threshold, lower negative threshold, reduce redemption frames).", + "openSettings": "Open Settings", + "authFailed": "Authentication failed", + "authFailedDescription": "Update your Basic HTTP Auth credentials in Settings ? General and try again." + }, + "aiState": { + "idle": "idle", + "thinking-speaking": "thinking/speaking", + "interrupted": "interrupted", + "loading": "loading", + "listening": "listening", + "waiting": "waiting" + }, + "wsStatus": { + "connected": "Connected", + "connecting": "Connecting", + "clickToReconnect": "Click to Reconnect", + "authFailed": "Auth failed - Click to reconnect" + } +} + diff --git a/src/renderer/src/locales/zh/translation.json b/src/renderer/src/locales/zh/translation.json index 575a7e93..19de89b3 100644 --- a/src/renderer/src/locales/zh/translation.json +++ b/src/renderer/src/locales/zh/translation.json @@ -1,136 +1,137 @@ -{ +{ "common": { - "save": "保存", - "cancel": "取消", - "settings": "设置", - "close": "关闭", - "accept": "接受" + "save": "Σ┐¥σ¡ÿ", + "cancel": "σÅûµ╢ê", + "settings": "Φ«╛τ╜«", + "close": "σà│Θù¡", + "accept": "µÄÑσÅù" }, "settings": { "tabs": { - "general": "常规", + "general": "σ╕╕Φºä", "live2d": "Live2D", - "asr": "识别", - "tts": "合成", - "agent": "代理", - "about": "关于" + "asr": "Φ»åσê½", + "tts": "σÉêµêÉ", + "agent": "Σ╗úτÉå", + "about": "σà│Σ║Ä" }, "general": { - "language": "语言", - "useCameraBackground": "使用摄像头背景", - "showSubtitle": "显示字幕", - "backgroundImage": "背景图片", - "customBgUrlPlaceholder": "输入图片URL", - "customBgUrl": "或输入自定义背景URL", - "characterPreset": "角色预设", - "wsUrl": "WebSocket地址", - "baseUrl": "基础URL", - "imageCompressionQuality": "图片压缩质量", - "imageCompressionQualityHelp": "JPEG压缩质量(0.1-1.0)。默认为0.8以减小文件体积,因为我们在向AI模型传输图片时没有进行压缩,可能导致文件过大。", - "imageMaxWidth": "图片最大宽度", - "imageMaxWidthHelp": "图片缩放的最大宽度。超过此宽度的图片会按比例缩小。设为0表示不限制大小。此功能存在的原因是某些AI模型在处理超大尺寸图片时可能存在限制。但是大部分AI模型都能自动处理图片,因此默认值为0(不限制),以保持您原始图片的完整性。" + "language": "Φ»¡Φ¿Ç", + "useCameraBackground": "Σ╜┐τö¿µæäσâÅσñ┤ΦâîµÖ»", + "showSubtitle": "µÿ╛τñ║σ¡ùσ╣ò", + "backgroundImage": "ΦâîµÖ»σ¢╛τëç", + "customBgUrlPlaceholder": "Φ╛ôσàÑσ¢╛τëçURL", + "customBgUrl": "µêûΦ╛ôσàÑΦç¬σ«ÜΣ╣ëΦâîµÖ»URL", + "characterPreset": "ΦºÆΦë▓ΘóäΦ«╛", + "wsUrl": "WebSocketσ£░σ¥Ç", + "baseUrl": "σƒ║τíÇURL", + "imageCompressionQuality": "σ¢╛τëçσÄïτ╝⌐Φ┤¿ΘçÅ", + "imageCompressionQualityHelp": "JPEGσÄïτ╝⌐Φ┤¿ΘçÅ(0.1-1.0)πÇéΘ╗ÿΦ«ñΣ╕║0.8Σ╗ÑσçÅσ░ŵûçΣ╗╢Σ╜ôτº»∩╝îσ¢áΣ╕║µêæΣ╗¼σ£¿σÉæAIµ¿íσ₧ïΣ╝áΦ╛ôσ¢╛τëçµù╢µ▓íµ£ëΦ┐¢ΦíîσÄïτ╝⌐∩╝îσÅ»Φâ╜σ»╝Φç┤µûçΣ╗╢Φ┐çσñºπÇé", + "imageMaxWidth": "σ¢╛τëçµ£Çσñºσ«╜σ║ª", + "imageMaxWidthHelp": "σ¢╛τëçτ╝⌐µö╛τÜäµ£Çσñºσ«╜σ║ªπÇéΦ╢àΦ┐絡ñσ«╜σ║ªτÜäσ¢╛τëçΣ╝ܵîëµ»öΣ╛ïτ╝⌐σ░ÅπÇéΦ«╛Σ╕║0Φí¿τñ║Σ╕ìΘÖÉσê╢σñºσ░ÅπÇ鵡ñσèƒΦâ╜σ¡ÿσ£¿τÜäσăσ¢áµÿ»µƒÉΣ║¢AIµ¿íσ₧ïσ£¿σñäτÉåΦ╢àσñºσ░║σ»╕σ¢╛τëçµù╢σÅ»Φâ╜σ¡ÿσ£¿ΘÖÉσê╢πÇéΣ╜åµÿ»σñºΘâ¿σêåAIµ¿íσ₧ïΘâ╜Φâ╜Φç¬σè¿σñäτÉåσ¢╛τëç∩╝îσ¢áµ¡ñΘ╗ÿΦ«ñσÇ╝Σ╕║0∩╝êΣ╕ìΘÖÉσê╢∩╝ë∩╝îΣ╗ÑΣ┐¥µîüµé¿σăσºïσ¢╛τëçτÜäσ«îµò┤µÇºπÇé" }, "live2d": { - "pointerInteractive": "鼠标交互", - "scrollToResize": "启用滚轮缩放" + "pointerInteractive": "Θ╝áµáçΣ║ñΣ║Æ", + "scrollToResize": "σÉ»τö¿µ╗ÜΦ╜«τ╝⌐µö╛" }, "asr": { - "autoStopMic": "AI开始说话时自动关闭麦克风", - "autoStopMicDesc": "当AI开始说话时自动关闭麦克风,防止音频反馈", - "autoStartMicOnConvEnd": "对话结束时自动开启麦克风", - "autoStartMicOnConvEndDesc": "AI说话结束时自动重新开启麦克风,实现无缝对话", - "autoStartMicOn": "AI被打断时自动开启麦克风", - "autoStartMicOnDesc": "当您打断AI时自动重新开启麦克风,保持连续交互", - "positiveSpeechThreshold": "语音识别阈值", - "positiveSpeechThresholdDesc": "检测语音所需的最低置信度(1-100),数值越高越能减少误检测", - "negativeSpeechThreshold": "负面语音阈值", - "negativeSpeechThresholdDesc": "停止语音检测的置信度下限(0-100),数值越低检测越不敏感", - "redemptionFrames": "验证帧数", - "redemptionFramesDesc": "确认语音检测所需的连续帧数(1-100),数值越高越能减少噪音触发" + "autoStopMic": "AIσ╝ÇσºïΦ»┤Φ»¥µù╢Φç¬σè¿σà│Θù¡Θ║ªσàïΘúÄ", + "autoStopMicDesc": "σ╜ôAIσ╝ÇσºïΦ»┤Φ»¥µù╢Φç¬σè¿σà│Θù¡Θ║ªσàïΘúÄ∩╝îΘÿ▓µ¡óΘƒ│ΘóæσÅìΘªê", + "autoStartMicOnConvEnd": "σ»╣Φ»¥τ╗ôµ¥ƒµù╢Φç¬σè¿σ╝ÇσÉ»Θ║ªσàïΘúÄ", + "autoStartMicOnConvEndDesc": "AIΦ»┤Φ»¥τ╗ôµ¥ƒµù╢Φç¬σè¿Θçìµû░σ╝ÇσÉ»Θ║ªσàïΘúÄ∩╝îσ«₧τÄ░µùáτ╝¥σ»╣Φ»¥", + "autoStartMicOn": "AIΦó½µëôµû¡µù╢Φç¬σè¿σ╝ÇσÉ»Θ║ªσàïΘúÄ", + "autoStartMicOnDesc": "σ╜ôµé¿µëôµû¡AIµù╢Φç¬σè¿Θçìµû░σ╝ÇσÉ»Θ║ªσàïΘúÄ∩╝îΣ┐¥µîüΦ┐₧τ╗¡Σ║ñΣ║Æ", + "positiveSpeechThreshold": "Φ»¡Θƒ│Φ»åσê½ΘÿêσÇ╝", + "positiveSpeechThresholdDesc": "µúǵ╡ïΦ»¡Θƒ│µëÇΘ£ÇτÜäµ£ÇΣ╜Äτ╜«Σ┐íσ║ª(1-100)∩╝îµò░σÇ╝Φ╢èΘ½ÿΦ╢èΦâ╜σçÅσ░æΦ»»µúǵ╡ï", + "negativeSpeechThreshold": "Φ┤ƒΘ¥óΦ»¡Θƒ│ΘÿêσÇ╝", + "negativeSpeechThresholdDesc": "σü£µ¡óΦ»¡Θƒ│µúǵ╡ïτÜäτ╜«Σ┐íσ║ªΣ╕ïΘÖÉ(0-100)∩╝îµò░σÇ╝Φ╢èΣ╜ĵúǵ╡ïΦ╢èΣ╕ìµòŵäƒ", + "redemptionFrames": "Θ¬îΦ»üσ╕ºµò░", + "redemptionFramesDesc": "τí«Φ«ñΦ»¡Θƒ│µúǵ╡ïµëÇΘ£ÇτÜäΦ┐₧τ╗¡σ╕ºµò░(1-100)∩╝îµò░σÇ╝Φ╢èΘ½ÿΦ╢èΦâ╜σçÅσ░æσÖ¬Θƒ│ΦºªσÅæ" }, "agent": { - "allowProactiveSpeak": "允许AI主动发言", - "idleSecondsToSpeak": "空闲多少秒后AI可发言", - "allowButtonTrigger": "通过举手按钮提示AI发言" + "allowProactiveSpeak": "σàüΦ«╕AIΣ╕╗σè¿σÅæΦ¿Ç", + "idleSecondsToSpeak": "τ⌐║Θù▓σñÜσ░æτºÆσÉÄAIσÅ»σÅæΦ¿Ç", + "allowButtonTrigger": "ΘÇÜΦ┐çΣ╕╛µëïµîëΘÆ«µÅÉτñ║AIσÅæΦ¿Ç" }, "about": { - "title": "Open LLM VTuber 前端", - "version": "版本", - "projectLinks": "项目链接", + "title": "Open LLM VTuber σëìτ½»", + "version": "τëêµ£¼", + "projectLinks": "Θí╣τ¢«Θô╛µÄÑ", "github": "GitHub", - "documentation": "文档", - "copyright": "版权", - "viewLicense": "查看许可证" + "documentation": "µûçµíú", + "copyright": "τëêµ¥â", + "viewLicense": "µƒÑτ£ïΦ«╕σÅ»Φ»ü" } }, "footer": { - "typeYourMessage": "输入您的消息...", - "cameraControl": "点击启动摄像头", - "cameraStopping": "点击停止摄像头", - "screenControl": "点击开始屏幕共享", - "screenStopping": "点击停止屏幕共享" + "typeYourMessage": "Φ╛ôσàѵé¿τÜäµ╢êµü»...", + "cameraControl": "τé╣σç╗σÉ»σ迵æäσâÅσñ┤", + "cameraStopping": "τé╣σç╗σü£µ¡óµæäσâÅσñ┤", + "screenControl": "τé╣σç╗σ╝Çσºïσ▒Åσ╣òσà▒Σ║½", + "screenStopping": "τé╣σç╗σü£µ¡óσ▒Åσ╣òσà▒Σ║½" }, "sidebar": { - "camera": "摄像头", - "screen": "屏幕", - "browser": "浏览器", - "live": "直播", - "noMessages": "暂无消息。开始对话吧!", - "noBrowserSession": "无活跃浏览器会话", - "browserSession": "浏览器会话" + "camera": "µæäσâÅσñ┤", + "screen": "σ▒Åσ╣ò", + "browser": "µ╡ÅΦºêσÖ¿", + "live": "τ¢┤µÆ¡", + "noMessages": "µÜéµùáµ╢êµü»πÇéσ╝Çσºïσ»╣Φ»¥σɺ∩╝ü", + "noBrowserSession": "µùáµ┤╗Φ╖âµ╡ÅΦºêσÖ¿Σ╝ÜΦ»¥", + "browserSession": "µ╡ÅΦºêσÖ¿Σ╝ÜΦ»¥" }, "group": { - "management": "群组管理", - "yourUuid": "您的UUID", - "inviteMember": "邀请成员", - "enterMemberUuid": "输入成员UUID", - "invite": "邀请", - "members": "成员", - "you": "您", - "leaveGroup": "离开群组", - "removeMember": "移除成员", - "leave": "离开" + "management": "τ╛ñτ╗äτ«íτÉå", + "yourUuid": "µé¿τÜäUUID", + "inviteMember": "ΘéÇΦ»╖µêÉσæÿ", + "enterMemberUuid": "Φ╛ôσàѵêÉσæÿUUID", + "invite": "ΘéÇΦ»╖", + "members": "µêÉσæÿ", + "you": "µé¿", + "leaveGroup": "τª╗σ╝Çτ╛ñτ╗ä", + "removeMember": "τº╗ΘÖñµêÉσæÿ", + "leave": "τª╗σ╝Ç" }, "history": { - "chatHistoryList": "聊天历史列表", - "noMessages": "暂无消息" + "chatHistoryList": "Φüèσñ⌐σÄåσÅ▓σêùΦí¿", + "noMessages": "µÜéµùáµ╢êµü»" }, "notification": { - "characterLoaded": "新角色已加载", - "characterSwitched": "角色已切换", - "historyLoaded": "历史记录已加载", - "newConversation": "新对话已开始", - "newChatHistory": "新聊天历史已创建", - "historyDeleteSuccess": "历史记录删除成功", - "historyDeleteFail": "删除历史记录失败" + "characterLoaded": "µû░ΦºÆΦë▓σ╖▓σèáΦ╜╜", + "characterSwitched": "ΦºÆΦë▓σ╖▓σêçµìó", + "historyLoaded": "σÄåσÅ▓Φ«░σ╜òσ╖▓σèáΦ╜╜", + "newConversation": "µû░σ»╣Φ»¥σ╖▓σ╝Çσºï", + "newChatHistory": "µû░Φüèσñ⌐σÄåσÅ▓σ╖▓σê¢σ╗║", + "historyDeleteSuccess": "σÄåσÅ▓Φ«░σ╜òσêáΘÖñµêÉσèƒ", + "historyDeleteFail": "σêáΘÖñσÄåσÅ▓Φ«░σ╜òσñ▒Φ┤Ñ" }, "error": { - "cameraApiNotSupported": "此设备不支持摄像头API", - "noCameraFound": "未找到摄像头设备", - "failedStartCamera": "启动摄像头失败", - "failedStartBackgroundCamera": "启动背景摄像头失败", - "failedStartScreenCapture": "启动屏幕捕获失败", - "failedStartVAD": "启动语音活动检测失败", - "llmCantHear": "AI无法听到您的声音", - "audioPlayback": "音频播放错误", - "enterValidUuid": "请输入有效的UUID", - "cannotDeleteCurrentHistory": "无法删除当前聊天记录", - "failedCapture": "捕获{{source}}帧失败", - "failedParseWebSocket": "解析WebSocket消息失败", - "websocketNotOpen": "WebSocket未连接", - "vadMisfire": "检测到语音但过于简短,请尝试提高音量或说得更久一些,或调整识别设置(降低语音识别阈值、降低负面语音阈值、减少验证帧数)" + "cameraApiNotSupported": "µ¡ñΦ«╛σñçΣ╕ìµö»µîüµæäσâÅσñ┤API", + "noCameraFound": "µ£¬µë╛σê░µæäσâÅσñ┤Φ«╛σñç", + "failedStartCamera": "σÉ»σ迵æäσâÅσñ┤σñ▒Φ┤Ñ", + "failedStartBackgroundCamera": "σÉ»σè¿ΦâîµÖ»µæäσâÅσñ┤σñ▒Φ┤Ñ", + "failedStartScreenCapture": "σÉ»σè¿σ▒Åσ╣òµìòΦÄ╖σñ▒Φ┤Ñ", + "failedStartVAD": "σÉ»σè¿Φ»¡Θƒ│µ┤╗σ迵úǵ╡ïσñ▒Φ┤Ñ", + "llmCantHear": "AIµùáµ│òσɼσê░µé¿τÜäσú░Θƒ│", + "audioPlayback": "Θƒ│Θ󿵯¡µö╛ΘöÖΦ»»", + "enterValidUuid": "Φ»╖Φ╛ôσàѵ£ëµòêτÜäUUID", + "cannotDeleteCurrentHistory": "µùáµ│òσêáΘÖñσ╜ôσëìΦüèσñ⌐Φ«░σ╜ò", + "failedCapture": "µìòΦÄ╖{{source}}σ╕ºσñ▒Φ┤Ñ", + "failedParseWebSocket": "Φºúµ₧ÉWebSocketµ╢êµü»σñ▒Φ┤Ñ", + "websocketNotOpen": "WebSocketµ£¬Φ┐₧µÄÑ", + "vadMisfire": "µúǵ╡ïσê░Φ»¡Θƒ│Σ╜åΦ┐çΣ║Äτ«Çτƒ¡∩╝îΦ»╖σ░¥Φ»òµÅÉΘ½ÿΘƒ│ΘçŵêûΦ»┤σ╛ùµ¢┤Σ╣àΣ╕ÇΣ║¢∩╝îµêûΦ░âµò┤Φ»åσê½Φ«╛τ╜«∩╝êΘÖìΣ╜ÄΦ»¡Θƒ│Φ»åσê½ΘÿêσÇ╝πÇüΘÖìΣ╜ÄΦ┤ƒΘ¥óΦ»¡Θƒ│ΘÿêσÇ╝πÇüσçÅσ░æΘ¬îΦ»üσ╕ºµò░∩╝ë" }, "aiState": { - "idle": "空闲", - "thinking-speaking": "思考/说话中", - "interrupted": "已打断", - "loading": "加载中", - "listening": "聆听中", - "waiting": "等待中" + "idle": "τ⌐║Θù▓", + "thinking-speaking": "µÇ¥ΦÇâ/Φ»┤Φ»¥Σ╕¡", + "interrupted": "σ╖▓µëôµû¡", + "loading": "σèáΦ╜╜Σ╕¡", + "listening": "ΦüåσɼΣ╕¡", + "waiting": "τ¡ëσ╛àΣ╕¡" }, "wsStatus": { - "connected": "已连接", - "connecting": "连接中", - "clickToReconnect": "点击重新连接" + "connected": "σ╖▓Φ┐₧µÄÑ", + "connecting": "Φ┐₧µÄÑΣ╕¡", + "clickToReconnect": "τé╣σç╗Θçìµû░Φ┐₧µÄÑ", + "authFailed": "认证失败,点击重新连接" } -} \ No newline at end of file +} diff --git a/src/renderer/src/services/auth-notifier.ts b/src/renderer/src/services/auth-notifier.ts new file mode 100644 index 00000000..02c9e317 --- /dev/null +++ b/src/renderer/src/services/auth-notifier.ts @@ -0,0 +1,144 @@ +import { toaster } from '@/components/ui/toaster'; +import { OPEN_SETTINGS_EVENT, AUTH_STATUS_EVENT } from '@/constants/events'; +import { getBackendConfig } from '@/services/backend-settings'; +import { getTranslator } from '@/utils/i18n-helper'; + +let lastNotificationTimestamp = 0; +const NOTIFICATION_COOLDOWN_MS = 3000; +const AUTH_ERROR_STATUSES = new Set([401, 403]); + +type RequestLike = RequestInfo | URL; + +const isAuthErrorStatus = (status: number | undefined) => ( + typeof status === 'number' && AUTH_ERROR_STATUSES.has(status) +); + +const tryResolveUrl = (input: RequestLike): string | null => { + if (input instanceof Request) { + return input.url; + } + if (input instanceof URL) { + return input.toString(); + } + if (typeof input === 'string') { + return input; + } + return null; +}; + +const getHostSafely = (rawUrl: string, baseFallback?: string): string | null => { + try { + return new URL(rawUrl).host; + } catch { + if (baseFallback) { + try { + return new URL(rawUrl, baseFallback).host; + } catch { + return null; + } + } + return null; + } +}; + +const shouldMonitorRequest = (input: RequestLike): boolean => { + const config = getBackendConfig(); + if (!config.basicAuth.enabled) { + return false; + } + const rawUrl = tryResolveUrl(input); + if (!rawUrl) { + return false; + } + const backendHost = getHostSafely(config.baseUrl); + if (!backendHost) { + return false; + } + const requestHost = getHostSafely(rawUrl, config.baseUrl); + return requestHost === backendHost; +}; + +let fetchInterceptorInstalled = false; +let authFailureActive = false; + +export type AuthStatus = 'authorized' | 'unauthorized'; + +export const emitAuthStatus = (status: AuthStatus) => { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent(AUTH_STATUS_EVENT, { + detail: { status }, + })); +}; + +export const notifyAuthFailure = () => { + authFailureActive = true; + emitAuthStatus('unauthorized'); + + const now = Date.now(); + if (now - lastNotificationTimestamp < NOTIFICATION_COOLDOWN_MS) { + return; + } + + lastNotificationTimestamp = now; + const translate = getTranslator(); + + toaster.create({ + title: translate('error.authFailed'), + description: translate('error.authFailedDescription'), + type: 'error', + duration: 5000, + action: { + label: translate('error.openSettings'), + onClick: () => { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(OPEN_SETTINGS_EVENT)); + } + }, + }, + }); +}; + +export const notifyAuthRecovered = () => { + if (!authFailureActive) { + return; + } + authFailureActive = false; + emitAuthStatus('authorized'); +}; + +export const ensureAuthFetchInterceptor = () => { + if (fetchInterceptorInstalled || typeof window === 'undefined' || typeof window.fetch !== 'function') { + return; + } + + const originalFetch = window.fetch.bind(window); + const patchedFetch: typeof window.fetch = async (input, init) => { + const monitor = shouldMonitorRequest(input); + try { + const response = await originalFetch(input, init); + if (monitor) { + if (isAuthErrorStatus(response.status)) { + notifyAuthFailure(); + } else if (response.ok) { + notifyAuthRecovered(); + } + } + return response; + } catch (error) { + if (monitor) { + notifyAuthFailure(); + } + throw error; + } + }; + + window.fetch = patchedFetch; + fetchInterceptorInstalled = true; +}; + +if (typeof window !== 'undefined') { + ensureAuthFetchInterceptor(); +} diff --git a/src/renderer/src/services/backend-settings.ts b/src/renderer/src/services/backend-settings.ts new file mode 100644 index 00000000..cb471635 --- /dev/null +++ b/src/renderer/src/services/backend-settings.ts @@ -0,0 +1,57 @@ +import { BasicAuthConfig, buildAuthorizationHeader, getBackendConfig } from '@/services/backend-settings'; +import { notifyAuthFailure, notifyAuthRecovered } from '@/services/auth-notifier'; + +interface AuthorizedFetchOptions extends RequestInit { + useAuth?: boolean; + baseUrlOverride?: string; + authOverride?: BasicAuthConfig; +} + +const resolveUrl = (input: string, baseUrl: string): string => { + try { + return new URL(input).toString(); + } catch { + return new URL(input, baseUrl).toString(); + } +}; + +const buildHeaders = (originalHeaders: HeadersInit | undefined) => new Headers(originalHeaders ?? {}); + +const isAuthErrorStatus = (status: number) => status === 401 || status === 403; + +export const authorizedFetch = async ( + input: string, + options: AuthorizedFetchOptions = {}, +): Promise => { + const { + useAuth = true, + baseUrlOverride, + authOverride, + headers, + ...restOptions + } = options; + + const config = getBackendConfig(); + const targetUrl = resolveUrl(input, baseUrlOverride ?? config.baseUrl); + const initialHeaders = buildHeaders(headers); + + if (useAuth) { + const authHeader = buildAuthorizationHeader(authOverride ?? config.basicAuth); + if (authHeader) { + initialHeaders.set('Authorization', authHeader); + } + } + + const response = await fetch(targetUrl, { + ...restOptions, + headers: initialHeaders, + }); + + if (isAuthErrorStatus(response.status)) { + notifyAuthFailure(); + } else if (response.ok && useAuth) { + notifyAuthRecovered(); + } + + return response; +}; diff --git a/src/renderer/src/services/http-client.ts b/src/renderer/src/services/http-client.ts new file mode 100644 index 00000000..4e707a6a --- /dev/null +++ b/src/renderer/src/services/http-client.ts @@ -0,0 +1,85 @@ +import { BasicAuthConfig, buildAuthorizationHeader, getBackendConfig } from '@/services/backend-settings'; +import { notifyAuthFailure, notifyAuthRecovered } from '@/services/auth-notifier'; + +interface AuthorizedFetchOptions extends RequestInit { + retryOnAuth?: boolean; + useAuth?: boolean; + baseUrlOverride?: string; + authOverride?: BasicAuthConfig; +} + +const resolveUrl = (input: string, baseUrl: string): string => { + try { + return new URL(input).toString(); + } catch { + return new URL(input, baseUrl).toString(); + } +}; + +const buildHeaders = (originalHeaders: HeadersInit | undefined) => new Headers(originalHeaders ?? {}); + +const isAuthErrorStatus = (status: number) => status === 401 || status === 403; + +export const authorizedFetch = async ( + input: string, + options: AuthorizedFetchOptions = {}, +): Promise => { + const { + retryOnAuth = true, + useAuth = true, + baseUrlOverride, + authOverride, + headers, + ...restOptions + } = options; + + const config = getBackendConfig(); + const targetUrl = resolveUrl(input, baseUrlOverride ?? config.baseUrl); + const initialHeaders = buildHeaders(headers); + + if (useAuth) { + const authHeader = buildAuthorizationHeader(authOverride ?? config.basicAuth); + if (authHeader) { + initialHeaders.set('Authorization', authHeader); + } + } + + const executeFetch = (headersToUse: Headers) => fetch(targetUrl, { + ...restOptions, + headers: headersToUse, + }); + + let response = await executeFetch(initialHeaders); + + if (!isAuthErrorStatus(response.status) || !retryOnAuth || response.status === 403) { + if (isAuthErrorStatus(response.status)) { + notifyAuthFailure(); + } else if (response.ok && useAuth) { + notifyAuthRecovered(); + } + return response; + } + + if (!useAuth) { + notifyAuthFailure(); + return response; + } + + const refreshedConfig = getBackendConfig(); + const retryHeaders = buildHeaders(headers); + const retryAuthHeader = buildAuthorizationHeader(authOverride ?? refreshedConfig.basicAuth); + + if (retryAuthHeader) { + retryHeaders.set('Authorization', retryAuthHeader); + } + + response = await executeFetch(retryHeaders); + + if (isAuthErrorStatus(response.status)) { + notifyAuthFailure(); + } else if (response.ok && useAuth) { + notifyAuthRecovered(); + } + + return response; +}; diff --git a/src/renderer/src/services/websocket-handler.tsx b/src/renderer/src/services/websocket-handler.tsx index 5283617d..dba6d92c 100644 --- a/src/renderer/src/services/websocket-handler.tsx +++ b/src/renderer/src/services/websocket-handler.tsx @@ -3,7 +3,7 @@ // eslint-disable-next-line object-curly-newline import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { wsService, MessageEvent } from '@/services/websocket-service'; +import { wsService, MessageEvent, type WebSocketConnectionState } from '@/services/websocket-service'; import { WebSocketContext, HistoryInfo, defaultWsUrl, defaultBaseUrl, } from '@/context/websocket-context'; @@ -21,12 +21,40 @@ import { useLocalStorage } from '@/hooks/utils/use-local-storage'; import { useGroup } from '@/context/group-context'; import { useInterrupt } from '@/hooks/utils/use-interrupt'; import { useBrowser } from '@/context/browser-context'; +import { deriveWsUrl, normalizeHttpOrigin, updateBackendConfig } from '@/services/backend-settings'; +import { ensureAuthFetchInterceptor } from '@/services/auth-notifier'; +import { AUTH_STATUS_EVENT } from '@/constants/events'; + +const normalizeResourceUrl = (base: string, resource: string) => { + const normalizeScheme = (value: string) => { + if (/^ws:\/\//i.test(value)) return value.replace(/^ws:\/\//i, 'http://'); + if (/^wss:\/\//i.test(value)) return value.replace(/^wss:\/\//i, 'https://'); + return value; + }; + + const safeBase = normalizeScheme(base); + const safeResource = normalizeScheme(resource); + + try { + return new URL(safeResource).toString(); + } catch { + try { + return new URL(safeResource, safeBase).toString(); + } catch { + return safeResource; + } + } +}; function WebSocketHandler({ children }: { children: React.ReactNode }) { const { t } = useTranslation(); - const [wsState, setWsState] = useState('CLOSED'); + const [wsState, setWsState] = useState('CLOSED'); + const [hasAuthFailure, setHasAuthFailure] = useState(false); const [wsUrl, setWsUrl] = useLocalStorage('wsUrl', defaultWsUrl); const [baseUrl, setBaseUrl] = useLocalStorage('baseUrl', defaultBaseUrl); + const [basicAuthEnabled, setBasicAuthEnabled] = useLocalStorage('basicAuthEnabled', false); + const [basicAuthUsername, setBasicAuthUsername] = useLocalStorage('basicAuthUsername', 'admin'); + const [basicAuthPassword, setBasicAuthPassword] = useLocalStorage('basicAuthPassword', 'change-me'); const { aiState, setAiState, backendSynthComplete, setBackendSynthComplete } = useAiState(); const { setModelInfo } = useLive2DConfig(); const { setSubtitleText } = useSubtitle(); @@ -41,10 +69,36 @@ function WebSocketHandler({ children }: { children: React.ReactNode }) { const { interrupt } = useInterrupt(); const { setBrowserViewData } = useBrowser(); + useEffect(() => { + ensureAuthFetchInterceptor(); + }, []); + useEffect(() => { autoStartMicOnConvEndRef.current = autoStartMicOnConvEnd; }, [autoStartMicOnConvEnd]); + useEffect(() => { + const listener: EventListener = (event) => { + const detail = (event as CustomEvent<{ status?: 'authorized' | 'unauthorized' }>).detail; + if (detail?.status === 'unauthorized') { + setHasAuthFailure(true); + } else if (detail?.status === 'authorized') { + setHasAuthFailure(false); + } + }; + + window.addEventListener(AUTH_STATUS_EVENT, listener); + return () => { + window.removeEventListener(AUTH_STATUS_EVENT, listener); + }; + }, []); + + useEffect(() => { + if (!basicAuthEnabled) { + setHasAuthFailure(false); + } + }, [basicAuthEnabled]); + useEffect(() => { if (pendingModelInfo && confUid) { setModelInfo(pendingModelInfo); @@ -111,13 +165,16 @@ function WebSocketHandler({ children }: { children: React.ReactNode }) { if (message.client_uid) { setSelfUid(message.client_uid); } - setPendingModelInfo(message.model_info); - // setModelInfo(message.model_info); - // We don't know when the confRef in live2d-config-context will be updated, so we set a delay here for convenience - if (message.model_info && !message.model_info.url.startsWith("http")) { - const modelUrl = baseUrl + message.model_info.url; - // eslint-disable-next-line no-param-reassign - message.model_info.url = modelUrl; + if (message.model_info) { + const normalizedModelInfo = { + ...message.model_info, + url: normalizeResourceUrl(baseUrl, message.model_info.url), + }; + setPendingModelInfo(normalizedModelInfo); + // setModelInfo(message.model_info); + // We don't know when the confRef in live2d-config-context will be updated, so we set a delay here for convenience + } else { + setPendingModelInfo(undefined); } setAiState('idle'); @@ -149,7 +206,10 @@ function WebSocketHandler({ children }: { children: React.ReactNode }) { break; case 'background-files': if (message.files) { + console.log('[WS Handler] Received background files', message.files); bgUrlContext?.setBackgroundFiles(message.files); + } else { + console.log('[WS Handler] Received background-files message with no files payload'); } break; case 'audio': @@ -292,8 +352,34 @@ function WebSocketHandler({ children }: { children: React.ReactNode }) { }, [aiState, addAudioTask, appendHumanMessage, baseUrl, bgUrlContext, setAiState, setConfName, setConfUid, setConfigFiles, setCurrentHistoryUid, setHistoryList, setMessages, setModelInfo, setSubtitleText, startMic, stopMic, setSelfUid, setGroupMembers, setIsOwner, backendSynthComplete, setBackendSynthComplete, clearResponse, handleControlMessage, appendOrUpdateToolCallMessage, interrupt, setBrowserViewData, t]); useEffect(() => { - wsService.connect(wsUrl); - }, [wsUrl]); + const normalizedBase = normalizeHttpOrigin(baseUrl); + if (normalizedBase !== baseUrl) { + setBaseUrl(normalizedBase); + return; + } + + const authConfig = { + enabled: basicAuthEnabled, + username: basicAuthUsername, + password: basicAuthPassword, + }; + + updateBackendConfig({ + baseUrl: normalizedBase, + wsUrl, + basicAuth: authConfig, + }); + + const targetUrl = deriveWsUrl(normalizedBase, wsUrl); + wsService.connect(targetUrl, { basicAuth: authConfig }); + }, [ + baseUrl, + setBaseUrl, + wsUrl, + basicAuthEnabled, + basicAuthUsername, + basicAuthPassword, + ]); useEffect(() => { const stateSubscription = wsService.onStateChange(setWsState); @@ -304,15 +390,52 @@ function WebSocketHandler({ children }: { children: React.ReactNode }) { }; }, [wsUrl, handleWebSocketMessage]); + const effectiveWsState: WebSocketConnectionState = hasAuthFailure ? 'UNAUTHORIZED' : wsState; + const webSocketContextValue = useMemo(() => ({ sendMessage: wsService.sendMessage.bind(wsService), - wsState, - reconnect: () => wsService.connect(wsUrl), + wsState: effectiveWsState, + reconnect: () => { + const normalizedBase = normalizeHttpOrigin(baseUrl); + if (normalizedBase !== baseUrl) { + setBaseUrl(normalizedBase); + return; + } + const authConfig = { + enabled: basicAuthEnabled, + username: basicAuthUsername, + password: basicAuthPassword, + }; + updateBackendConfig({ + baseUrl: normalizedBase, + wsUrl, + basicAuth: authConfig, + }); + wsService.reconnect(); + }, wsUrl, setWsUrl, baseUrl, setBaseUrl, - }), [wsState, wsUrl, baseUrl]); + basicAuthEnabled, + setBasicAuthEnabled, + basicAuthUsername, + setBasicAuthUsername, + basicAuthPassword, + setBasicAuthPassword, + }), [ + effectiveWsState, + wsUrl, + baseUrl, + basicAuthEnabled, + basicAuthUsername, + basicAuthPassword, + setWsUrl, + setBaseUrl, + setBasicAuthEnabled, + setBasicAuthUsername, + setBasicAuthPassword, + ]); return ( diff --git a/src/renderer/src/services/websocket-service.tsx b/src/renderer/src/services/websocket-service.tsx index ad2b79f3..14768d35 100644 --- a/src/renderer/src/services/websocket-service.tsx +++ b/src/renderer/src/services/websocket-service.tsx @@ -6,6 +6,13 @@ import { ModelInfo } from '@/context/live2d-config-context'; import { HistoryInfo } from '@/context/websocket-context'; import { ConfigFile } from '@/context/character-config-context'; import { toaster } from '@/components/ui/toaster'; +import { + BasicAuthConfig, + deriveWsUrl, + getBackendConfig, +} from '@/services/backend-settings'; +import { getTranslator } from '@/utils/i18n-helper'; +import { notifyAuthFailure, notifyAuthRecovered } from '@/services/auth-notifier'; export interface DisplayText { text: string; @@ -18,6 +25,14 @@ interface BackgroundFile { url: string; } +interface WebSocketConnectOptions { + baseUrl?: string; + wsUrl?: string; + protocols?: string[]; + basicAuth?: BasicAuthConfig; + force?: boolean; +} + export interface AudioPayload { type: 'audio'; audio?: string; @@ -94,16 +109,9 @@ export interface MessageEvent { }; } -// Get translation function for error messages -const getTranslation = () => { - try { - const i18next = require('i18next').default; - return i18next.t.bind(i18next); - } catch (e) { - // Fallback if i18next is not available - return (key: string) => key; - } -}; +export type WebSocketConnectionState = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED' | 'UNAUTHORIZED'; + +const UNAUTHORIZED_CLOSE_CODES = new Set([4001, 4003, 4010, 4401, 4403]); class WebSocketService { private static instance: WebSocketService; @@ -112,9 +120,27 @@ class WebSocketService { private messageSubject = new Subject(); - private stateSubject = new Subject<'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'>(); + private stateSubject = new Subject(); + + private currentState: WebSocketConnectionState = 'CLOSED'; + + private lastConnectionDetails: { url: string; basicAuthEnabled: boolean; authFingerprint: string } | null = null; + + private isConnecting = false; + + private reconnectTimer: ReturnType | null = null; + + private reconnectAttempts = 0; + + private shouldReconnect = true; + + private pendingManualConnect: (() => void) | null = null; + + private lastResolvedUrl: string | null = null; + + private lastAuthConfig: BasicAuthConfig | null = null; - private currentState: 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED' = 'CLOSED'; + private authFailureNotified = false; static getInstance() { if (!WebSocketService.instance) { @@ -138,51 +164,258 @@ class WebSocketService { }); } - connect(url: string) { - if (this.ws?.readyState === WebSocket.CONNECTING || - this.ws?.readyState === WebSocket.OPEN) { - this.disconnect(); + private clearReconnectTimer() { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private scheduleReconnect() { + if (!this.shouldReconnect || this.reconnectTimer !== null || !this.lastResolvedUrl) { + return; + } + + const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000); + this.reconnectAttempts += 1; + + const nextAuth = this.lastAuthConfig ? { ...this.lastAuthConfig } : undefined; + const nextUrl = this.lastResolvedUrl; + + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + const options: WebSocketConnectOptions = {}; + if (nextAuth) { + options.basicAuth = nextAuth; + } + this.connect(nextUrl, options); + }, delay); + } + + private clearSocketHandlers(socket: WebSocket) { + socket.onopen = null; + socket.onmessage = null; + socket.onclose = null; + socket.onerror = null; + } + + private closeSocket(code = 1000, reason = 'Client closing connection') { + if (!this.ws) { + return; + } + + if (this.ws.readyState === WebSocket.CLOSED) { + return; } try { - this.ws = new WebSocket(url); - this.currentState = 'CONNECTING'; - this.stateSubject.next('CONNECTING'); + this.currentState = 'CLOSING'; + this.stateSubject.next('CLOSING'); + this.ws.close(code, reason); + } catch (error) { + console.error('Failed to close WebSocket:', error); + } + } - this.ws.onopen = () => { - this.currentState = 'OPEN'; - this.stateSubject.next('OPEN'); - this.initializeConnection(); - }; + private handleOpen = (event: Event) => { + const socket = event.target as WebSocket | null; + if (socket && socket !== this.ws) { + return; + } - this.ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - this.messageSubject.next(message); - } catch (error) { - console.error('Failed to parse WebSocket message:', error); - toaster.create({ - title: `${getTranslation()('error.failedParseWebSocket')}: ${error}`, - type: "error", - duration: 2000, - }); - } - }; + if (!this.ws) { + return; + } - this.ws.onclose = () => { - this.currentState = 'CLOSED'; - this.stateSubject.next('CLOSED'); - }; + this.isConnecting = false; + this.currentState = 'OPEN'; + this.stateSubject.next('OPEN'); + this.clearReconnectTimer(); + this.reconnectAttempts = 0; + this.shouldReconnect = true; + this.authFailureNotified = false; + notifyAuthRecovered(); + this.initializeConnection(); + }; + + private handleMessage = (event: globalThis.MessageEvent) => { + if (!this.ws || event.target !== this.ws) { + return; + } + + try { + const message = JSON.parse(event.data); + this.messageSubject.next(message); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + const translate = getTranslator(); + toaster.create({ + title: `${translate('error.failedParseWebSocket')}: ${error}`, + type: "error", + duration: 2000, + }); + } + }; - this.ws.onerror = () => { + private handleClose = (event: CloseEvent) => { + const socket = event.target as WebSocket | null; + if (socket && socket !== this.ws) { + return; + } + + if (this.ws) { + this.clearSocketHandlers(this.ws); + } + this.ws = null; + this.isConnecting = false; + this.currentState = 'CLOSED'; + this.stateSubject.next('CLOSED'); + this.maybeNotifyAuthFailure({ code: event.code, reason: event.reason }); + this.clearReconnectTimer(); + + const manualReconnect = this.pendingManualConnect; + this.pendingManualConnect = null; + + if (manualReconnect) { + manualReconnect(); + return; + } + + if (!this.shouldReconnect) { + this.shouldReconnect = true; + return; + } + + this.scheduleReconnect(); + }; + + private handleError = (event: Event) => { + const socket = event.target as WebSocket | null; + if (socket && socket !== this.ws) { + return; + } + + if (!this.ws) { + return; + } + this.isConnecting = false; + this.currentState = 'CLOSED'; + this.stateSubject.next('CLOSED'); + this.maybeNotifyAuthFailure(); + }; + + connect(url?: string, options: WebSocketConnectOptions = {}) { + const backendConfig = getBackendConfig(); + const resolvedUrl = url + ?? deriveWsUrl(options.baseUrl ?? backendConfig.baseUrl, options.wsUrl ?? backendConfig.wsUrl); + const basicAuthConfig: BasicAuthConfig = options.basicAuth ?? backendConfig.basicAuth; + const authFingerprint = basicAuthConfig?.enabled + ? `${basicAuthConfig.username}:${basicAuthConfig.password}` + : ''; + const shouldForceDueToChange = Boolean( + this.ws + && this.lastConnectionDetails + && (this.lastConnectionDetails.url !== resolvedUrl + || this.lastConnectionDetails.authFingerprint !== authFingerprint), + ); + const requestedForce = Boolean(options.force); + const shouldForce = requestedForce || shouldForceDueToChange; + + this.lastResolvedUrl = resolvedUrl; + this.lastAuthConfig = basicAuthConfig ? { ...basicAuthConfig } : null; + + if (!shouldForce) { + if (this.isConnecting) { + return; + } + if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { + return; + } + } + + const openSocket = () => { + this.pendingManualConnect = null; + this.clearReconnectTimer(); + this.shouldReconnect = true; + this.isConnecting = true; + this.currentState = 'CONNECTING'; + this.stateSubject.next('CONNECTING'); + this.authFailureNotified = false; + + const protocols = options.protocols ?? []; + try { + this.ws = protocols.length > 0 ? new WebSocket(resolvedUrl, protocols) : new WebSocket(resolvedUrl); + } catch (error) { + console.error('Failed to connect to WebSocket:', error); + this.isConnecting = false; this.currentState = 'CLOSED'; this.stateSubject.next('CLOSED'); + this.maybeNotifyAuthFailure(); + return; + } + + this.lastConnectionDetails = { + url: resolvedUrl, + basicAuthEnabled: Boolean(basicAuthConfig?.enabled), + authFingerprint, }; - } catch (error) { - console.error('Failed to connect to WebSocket:', error); - this.currentState = 'CLOSED'; - this.stateSubject.next('CLOSED'); + + if (this.ws) { + this.ws.onopen = this.handleOpen; + this.ws.onmessage = this.handleMessage; + this.ws.onclose = this.handleClose; + this.ws.onerror = this.handleError; + } + }; + + if (shouldForce && this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { + this.pendingManualConnect = openSocket; + this.shouldReconnect = false; + this.clearReconnectTimer(); + this.closeSocket(1000, 'Client reconnecting'); + return; + } + + if (this.ws && this.ws.readyState === WebSocket.CLOSING) { + this.pendingManualConnect = openSocket; + this.shouldReconnect = false; + this.clearReconnectTimer(); + return; + } + + openSocket(); + } + + reconnect() { + const options: WebSocketConnectOptions = { force: true }; + if (this.lastAuthConfig) { + options.basicAuth = { ...this.lastAuthConfig }; + } + this.connect(this.lastResolvedUrl ?? undefined, options); + } + + private maybeNotifyAuthFailure(details?: { code?: number; reason?: string }) { + if (this.authFailureNotified) { + return; + } + + const reason = (details?.reason ?? '').toLowerCase(); + const code = details?.code; + const isUnauthorized = (code && UNAUTHORIZED_CLOSE_CODES.has(code)) + || reason.includes('unauthorized') + || reason.includes('401') + || reason.includes('403'); + + if (!isUnauthorized) { + return; + } + + this.authFailureNotified = true; + if (this.currentState !== 'UNAUTHORIZED') { + this.currentState = 'UNAUTHORIZED'; + this.stateSubject.next('UNAUTHORIZED'); } + notifyAuthFailure(); } sendMessage(message: object) { @@ -190,8 +423,9 @@ class WebSocketService { this.ws.send(JSON.stringify(message)); } else { console.warn('WebSocket is not open. Unable to send message:', message); + const translate = getTranslator(); toaster.create({ - title: getTranslation()('error.websocketNotOpen'), + title: translate('error.websocketNotOpen'), type: 'error', duration: 2000, }); @@ -202,13 +436,23 @@ class WebSocketService { return this.messageSubject.subscribe(callback); } - onStateChange(callback: (state: 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED') => void) { + onStateChange(callback: (state: WebSocketConnectionState) => void) { return this.stateSubject.subscribe(callback); } - disconnect() { - this.ws?.close(); - this.ws = null; + disconnect(code = 1000, reason = 'Client disconnect') { + this.shouldReconnect = false; + this.pendingManualConnect = null; + this.clearReconnectTimer(); + this.isConnecting = false; + + if (!this.ws) { + this.currentState = 'CLOSED'; + this.stateSubject.next('CLOSED'); + return; + } + + this.closeSocket(code, reason); } getCurrentState() { diff --git a/src/renderer/src/utils/i18n-helper.ts b/src/renderer/src/utils/i18n-helper.ts new file mode 100644 index 00000000..38d51777 --- /dev/null +++ b/src/renderer/src/utils/i18n-helper.ts @@ -0,0 +1,8 @@ +export const getTranslator = () => { + try { + const i18next = require('i18next').default; + return i18next.t.bind(i18next); + } catch (error) { + return (key: string) => key; + } +};