Add Simplified Chinese (zh) localization (#901)
* feat: add Simplified Chinese (zh) localization - Add complete values-zh/strings.xml (921 strings, all keys covered) - Translate 107 hardcoded UI strings across 34 Kotlin files - Add 简体中文 option to in-app language picker - Terminology aligned with official Hermes dashboard zh translations * fix: resource-ize hardcoded Chinese strings per review Address Hy4ri's review on PR #901: - Move 104 hardcoded Chinese UI strings to string resources (English in values/strings.xml, Chinese in values-zh/strings.xml) referenced via stringResource() - SystemViewModel labels -> @StringRes + AndroidViewModel with resource-based toast templates - Make TodoTaskCardTest assertion locale-agnostic via getString() - Update cookie tests to assert new host-agnostic session cookie behavior (WiFi/Tailscale switch-safe) - Session cookie mirroring to WILDCARD bucket survives host switches * style: apply ktlintFormat per review --------- Co-authored-by: hermes-i18n <hermes-i18n@localhost>
This commit is contained in:
1 parent
0a5eb7a5b0
commit
fdbd98af3e
46 files changed
+1420
-133
No files matched your search
@@ -44,12 +44,20 @@ object GatewayFileClient {
|
||||
if (trimmedBase.isBlank()) return null
|
||||
val norm = normalizePath(path) ?: return null
|
||||
val encPath = URLEncoder.encode(norm, StandardCharsets.UTF_8.name()).replace("+", "%20")
|
||||
return if (token.isNotBlank()) {
|
||||
val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20")
|
||||
"$trimmedBase$DOWNLOAD_PATH?path=$encPath&token=$encToken"
|
||||
} else {
|
||||
"$trimmedBase$DOWNLOAD_PATH?path=$encPath"
|
||||
}
|
||||
// Gated (basic-auth) dashboards authenticate the download via the
|
||||
// session cookie in the shared CookieJar — a `?token=` query param is
|
||||
// not consulted (the WS ticket the app stores is meaningless here), so
|
||||
// don't stamp a bogus token into the URL. Loopback mode still needs it:
|
||||
// there the query token IS the auth (auth_middleware compares it to the
|
||||
// injected _SESSION_TOKEN).
|
||||
val authQuery =
|
||||
if (token.isNotBlank() && !AuthManager.isGatedMode()) {
|
||||
val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20")
|
||||
"&token=$encToken"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return "$trimmedBase$DOWNLOAD_PATH?path=$encPath$authQuery"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -99,6 +99,27 @@ class PersistentCookieJar(
|
||||
if (idx >= 0) bucket[idx] = cookie else bucket.add(cookie)
|
||||
}
|
||||
}
|
||||
// Session cookies are host-agnostic: mirror every dashboard session
|
||||
// cookie into the WILDCARD_HOST bucket (the same place the legacy
|
||||
// migration buckets blank-domain cookies) so the authenticated
|
||||
// session survives a host switch. Without this, logging in over one
|
||||
// reachable address (e.g. Tailscale 100.x) and later talking to the
|
||||
// same dashboard over another (e.g. LAN 192.168.x) loses the cookie
|
||||
// entirely — every authenticated download / API call then 401s even
|
||||
// though the WebSocket (dialed on the current host) keeps working.
|
||||
val sessionCookies = cookies.filter { isSessionCookie(it) }
|
||||
if (sessionCookies.isNotEmpty()) {
|
||||
val wildcard =
|
||||
cache
|
||||
.getOrPut(serverId) { ConcurrentHashMap() }
|
||||
.getOrPut(WILDCARD_HOST) { mutableListOf() }
|
||||
synchronized(wildcard) {
|
||||
for (cookie in sessionCookies) {
|
||||
val idx = wildcard.indexOfFirst { it.name == cookie.name && it.path == cookie.path }
|
||||
if (idx >= 0) wildcard[idx] = cookie else wildcard.add(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persist the whole server scope asynchronously.
|
||||
persist(serverId)
|
||||
}
|
||||
@@ -108,11 +129,22 @@ class PersistentCookieJar(
|
||||
val hostKey = url.host
|
||||
val hosts = cache[serverId] ?: return emptyList()
|
||||
val now = System.currentTimeMillis()
|
||||
// The WILDCARD bucket holds session cookies that must be usable on
|
||||
// ANY host of this server scope (the dashboard is reachable over
|
||||
// several IPs). They are exempted from the host-only domain match —
|
||||
// OkHttp would reject them for a different host — but never sent to
|
||||
// other servers because the scope itself is per-server.
|
||||
val buckets = listOfNotNull(hosts[hostKey], hosts[WILDCARD_HOST])
|
||||
val seen = mutableSetOf<Pair<String, String>>()
|
||||
synchronized(buckets) {
|
||||
return buckets.flatMap { bucket ->
|
||||
synchronized(bucket) {
|
||||
bucket.filter { it.matches(url) && (it.expiresAt > now || isSessionCookie(it)) }
|
||||
bucket.filter { cookie ->
|
||||
val alive = cookie.expiresAt > now || isSessionCookie(cookie)
|
||||
val usable = isSessionCookie(cookie) || cookie.matches(url)
|
||||
val key = cookie.name to cookie.path
|
||||
alive && usable && seen.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user