Reproduce the MiMo Desktop login surface server-side so headless/Docker deployments can link a Xiaomi account without the Desktop client. The account session (passToken) is captured during the proxied login and stored per connection. - Five account clusters (cn/sgp/ams/ru/in): per-region mimo-server host and SSO sid, unknown region falls back to sgp - mimo-v2.6-pro/flash/pro-ultraspeed dual-route models: account-service route when desktop credentials exist, cloud API (sk- key) otherwise; drops obsolete mimo-x-*-preview ids - Desktop ServiceTokenManager 2-phase handshake (single serviceLogin with target sid, raw 64-bit nonce preserved), per-region session cache - reasoning_effort bridged to output_config.effort; i18n runtime now observes characterData mutations so React text rewrites get translated - Security hardening on the login proxy: session travels only in the httpOnly cookie (never in the URL), proxy branch requires dashboard auth, authorization/proxy-authorization never forwarded upstream, and upstream Set-Cookie is not replayed onto the app origin
178 lines
5.0 KiB
JavaScript
178 lines
5.0 KiB
JavaScript
"use client";
|
|
|
|
import { DEFAULT_LOCALE, LOCALE_COOKIE, normalizeLocale } from "./config";
|
|
|
|
let translationMap = {};
|
|
let currentLocale = DEFAULT_LOCALE;
|
|
let reloadCallbacks = [];
|
|
|
|
// Read locale from cookie
|
|
function getLocaleFromCookie() {
|
|
if (typeof document === "undefined") return DEFAULT_LOCALE;
|
|
const cookie = document.cookie
|
|
.split(";")
|
|
.find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`));
|
|
const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : DEFAULT_LOCALE;
|
|
return normalizeLocale(value);
|
|
}
|
|
|
|
// Load translation map
|
|
async function loadTranslations(locale) {
|
|
if (locale === "en") {
|
|
translationMap = {};
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/i18n/literals/${locale}.json`);
|
|
translationMap = await response.json();
|
|
} catch (err) {
|
|
console.error("Failed to load translations:", err);
|
|
translationMap = {};
|
|
}
|
|
}
|
|
|
|
// Translate text - exported for use in components
|
|
export function translate(text) {
|
|
if (!text || typeof text !== "string") return text;
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return text;
|
|
if (currentLocale === "en") return text;
|
|
return translationMap[trimmed] || text;
|
|
}
|
|
|
|
// Get current locale - exported for use in components
|
|
export function getCurrentLocale() {
|
|
return currentLocale;
|
|
}
|
|
|
|
// Register callback for locale changes
|
|
export function onLocaleChange(callback) {
|
|
reloadCallbacks.push(callback);
|
|
return () => {
|
|
reloadCallbacks = reloadCallbacks.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
// Process text node
|
|
function processTextNode(node) {
|
|
const current = node.nodeValue;
|
|
if (!current || !current.trim()) return;
|
|
|
|
// Skip if parent is script, style, code, or structural elements
|
|
const parent = node.parentElement;
|
|
if (!parent) return;
|
|
|
|
// Skip if parent or any ancestor has data-i18n-skip attribute
|
|
let element = parent;
|
|
while (element) {
|
|
if (element.hasAttribute && element.hasAttribute('data-i18n-skip')) {
|
|
return;
|
|
}
|
|
element = element.parentElement;
|
|
}
|
|
|
|
const tagName = parent.tagName?.toLowerCase();
|
|
|
|
// Skip elements that don't allow text nodes
|
|
const skipTags = [
|
|
"script", "style", "code", "pre",
|
|
"colgroup", "table", "thead", "tbody", "tfoot", "tr",
|
|
"select", "datalist", "optgroup"
|
|
];
|
|
|
|
if (skipTags.includes(tagName)) return;
|
|
|
|
// React reuses text nodes and rewrites their value on re-render (a
|
|
// characterData mutation, no childList event). When the current value is
|
|
// neither our last translation nor the recorded original, it is fresh
|
|
// source text — re-capture it as the new original before translating.
|
|
const isOurTranslation = node._translated != null && current === node._translated;
|
|
const isSameAsOriginal = current === node._originalText;
|
|
if (!isOurTranslation && !isSameAsOriginal) {
|
|
node._originalText = current;
|
|
}
|
|
if (node._originalText == null) node._originalText = current;
|
|
|
|
// Translate from the recorded original so locale switches stay idempotent
|
|
const translated = translate(node._originalText);
|
|
node._translated = translated;
|
|
|
|
// Only update if different to avoid unnecessary DOM mutations
|
|
if (translated !== node.nodeValue) {
|
|
node.nodeValue = translated;
|
|
}
|
|
}
|
|
|
|
// Process all text nodes in element
|
|
function processElement(element) {
|
|
if (!element) return;
|
|
|
|
const walker = document.createTreeWalker(
|
|
element,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
let node;
|
|
const nodesToProcess = [];
|
|
|
|
// Collect all nodes first to avoid live collection issues
|
|
while ((node = walker.nextNode())) {
|
|
nodesToProcess.push(node);
|
|
}
|
|
|
|
// Process collected nodes
|
|
nodesToProcess.forEach(processTextNode);
|
|
}
|
|
|
|
// Initialize runtime i18n
|
|
export async function initRuntimeI18n() {
|
|
if (typeof window === "undefined") return;
|
|
|
|
currentLocale = getLocaleFromCookie();
|
|
await loadTranslations(currentLocale);
|
|
|
|
// Process existing DOM
|
|
processElement(document.body);
|
|
|
|
// Watch for new nodes AND in-place text rewrites. React reuses text nodes on
|
|
// re-render (only nodeValue changes → a characterData mutation with no
|
|
// childList event), so observing childList alone leaves later-updated labels
|
|
// untranslated.
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === "characterData") {
|
|
processTextNode(mutation.target);
|
|
return;
|
|
}
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
processElement(node);
|
|
} else if (node.nodeType === Node.TEXT_NODE) {
|
|
processTextNode(node);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
characterData: true,
|
|
});
|
|
}
|
|
|
|
// Reload translations when locale changes
|
|
export async function reloadTranslations() {
|
|
currentLocale = getLocaleFromCookie();
|
|
await loadTranslations(currentLocale);
|
|
|
|
// Notify all registered callbacks
|
|
reloadCallbacks.forEach(callback => callback());
|
|
|
|
// Re-process entire DOM (will use stored original text)
|
|
processElement(document.body);
|
|
}
|