diff --git a/CLAUDE.md b/CLAUDE.md index ade7b1fa..5d91e412 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Routes defined in `composeApp/.../app/navigation/GithubStoreGraph.kt`, wired in |--------|---------|--------------| | `core/domain` | Shared contracts | Repository interfaces (`FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository`, `ThemesRepository`, `ProxyRepository`, `RateLimitRepository`), models (`GithubRepoSummary`, `GithubRelease`, `InstalledApp`, `ProxyConfig`, `InstallerType`, `ShizukuAvailability`), system interfaces (`Installer`, `InstallerInfoExtractor`, `InstallerStatusProvider`, `PackageMonitor`) | | `core/data` | Shared implementations | `HttpClientFactory` (Ktor + interceptors), `AppDatabase` (Room), `ProxyManager`, `TokenStore`, `LocalizationManager`, platform-specific clients (OkHttp for Android, CIO for Desktop), Shizuku integration (Android: `ShizukuServiceManager`, `ShizukuInstallerWrapper`, `ShizukuInstallerServiceImpl`, `AndroidInstallerStatusProvider`; Desktop: `DesktopInstallerStatusProvider`) | -| `core/presentation` | Shared UI | `GithubStoreTheme` (Material 3), reusable components (`RepositoryCard`, `GithubStoreButton`), formatting utils, localized strings (11 languages) | +| `core/presentation` | Shared UI | `GithubStoreTheme` (Material 3), reusable components (`RepositoryCard`, `GithubStoreButton`), formatting utils, localized strings (13 languages) | ## Tech Stack diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt index 6c16fa4f..93b90641 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt @@ -13,14 +13,28 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.util.Consumer +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import org.koin.android.ext.android.inject +import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.data.utils.AndroidShareManager +import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.githubstore.app.deeplink.DeepLinkParser +private const val LANGUAGE_PREF_READ_TIMEOUT_MS = 2000L + class MainActivity : ComponentActivity() { private var deepLinkUri by mutableStateOf(null) private val shareManager: ShareManager by inject() + private val tweaksRepository: TweaksRepository by inject() + private val localizationManager: LocalizationManager by inject() override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -29,10 +43,54 @@ class MainActivity : ComponentActivity() { // Register activity result launcher for file picker (must be before STARTED) (shareManager as? AndroidShareManager)?.registerActivityResultLauncher(this) + // Apply the persisted language override BEFORE Compose kicks off + // so the very first frame resolves strings against the user's + // choice. `runBlocking` is acceptable here — DataStore reads are + // cheap and we only block once per Activity creation (including + // the post-language-swap recreate() path below). Without this, + // recreate() would briefly flash the old locale before settling. + // + // The 2s timeout + catch-all is defence against a stalled or + // corrupted DataStore: we'd rather boot in system language than + // leave the Activity stuck before super.onCreate(), which would + // hang the whole app with no visible error. + runBlocking { + val tag = + try { + withTimeoutOrNull(LANGUAGE_PREF_READ_TIMEOUT_MS) { + tweaksRepository.getAppLanguage().first() + } + } catch (_: Exception) { + null + } + localizationManager.setActiveLanguageTag(tag) + } + super.onCreate(savedInstanceState) handleIncomingIntent(intent) + // Watch for runtime language changes from the Tweaks picker. + // Drop the initial emission (already applied above) and + // recreate() on any subsequent change — Android preserves + // `rememberSaveable` / ViewModel state through recreate, so + // scroll offsets, nav stack, and form fields all survive while + // every string re-resolves against the new locale. `key()` in + // the composition can't pull off the same trick: it changes + // the composite-key hash under it, which breaks + // `rememberSaveable` lookups and snaps LazyColumns back to 0. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + tweaksRepository + .getAppLanguage() + .drop(1) + .collect { newTag -> + localizationManager.setActiveLanguageTag(newTag) + recreate() + } + } + } + setContent { DisposableEffect(Unit) { val listener = diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt new file mode 100644 index 00000000..0ddfdfa2 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt @@ -0,0 +1,125 @@ +package zed.rainxch.githubstore + +import java.io.File +import java.io.FileOutputStream +import java.io.PrintStream +import java.io.PrintWriter +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +object CrashReporter { + private const val MAX_SESSION_LOG_BYTES = 5L * 1024 * 1024 + + private val logDir: File by lazy { resolveLogDir().also { it.mkdirs() } } + + fun install() { + val teed = + runCatching { + val file = File(logDir, "session.log") + rotateIfLarge(file) + PrintStream(FileOutputStream(file, true), true, Charsets.UTF_8) + .also { stream -> + System.setOut(TeePrintStream(System.out, stream)) + System.setErr(TeePrintStream(System.err, stream)) + } + }.getOrNull() + + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { writeCrashDump(thread, throwable) } + runCatching { throwable.printStackTrace(System.err) } + } + + if (teed != null) { + println("=== GitHub Store session ${Instant.now()} ===") + println( + "OS=${System.getProperty("os.name")} ${System.getProperty("os.version")} " + + "(${System.getProperty("os.arch")})", + ) + println( + "Java=${System.getProperty("java.version")} (${System.getProperty("java.vendor")})", + ) + println("LogDir=${logDir.absolutePath}") + } + } + + private fun writeCrashDump( + thread: Thread, + throwable: Throwable, + ) { + val file = File(logDir, "crash-${timestamp()}.log") + PrintWriter(file, Charsets.UTF_8).use { writer -> + writer.println("=== GitHub Store crash ===") + writer.println("Time: ${Instant.now()}") + writer.println("Thread: ${thread.name}") + writer.println( + "OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")} " + + "(${System.getProperty("os.arch")})", + ) + writer.println( + "Java: ${System.getProperty("java.version")} (${System.getProperty("java.vendor")})", + ) + writer.println() + throwable.printStackTrace(writer) + } + } + + private fun rotateIfLarge(file: File) { + if (!file.exists() || file.length() <= MAX_SESSION_LOG_BYTES) return + val rotated = File(file.parentFile, "session.1.log") + if (rotated.exists()) rotated.delete() + file.renameTo(rotated) + } + + private fun resolveLogDir(): File { + val home = File(System.getProperty("user.home")) + val osName = System.getProperty("os.name").orEmpty().lowercase() + return when { + "mac" in osName -> { + File(home, "Library/Logs/GitHub-Store") + } + + "win" in osName -> { + val localAppData = System.getenv("LOCALAPPDATA")?.let(::File) ?: home + File(localAppData, "GitHub-Store/logs") + } + + else -> { + val stateHome = + System.getenv("XDG_STATE_HOME")?.let(::File) + ?: File(home, ".local/state") + File(stateHome, "GitHub-Store/logs") + } + } + } + + private fun timestamp(): String = + DateTimeFormatter + .ofPattern("yyyyMMdd-HHmmss-SSS") + .withZone(ZoneId.systemDefault()) + .format(Instant.now()) +} + +private class TeePrintStream( + private val primary: PrintStream, + private val secondary: PrintStream, +) : PrintStream(primary) { + override fun write(b: Int) { + primary.write(b) + runCatching { secondary.write(b) } + } + + override fun write( + buf: ByteArray, + off: Int, + len: Int, + ) { + primary.write(buf, off, len) + runCatching { secondary.write(buf, off, len) } + } + + override fun flush() { + primary.flush() + runCatching { secondary.flush() } + } +} diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 84e22c48..e9fc4acd 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -12,8 +12,14 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.koin.core.context.GlobalContext +import zed.rainxch.core.data.services.LocalizationManager +import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.githubstore.app.desktop.KeyboardNavigation import zed.rainxch.githubstore.app.desktop.KeyboardNavigationEvent import zed.rainxch.githubstore.app.di.initKoin @@ -23,7 +29,14 @@ import zed.rainxch.githubstore.core.presentation.res.app_name import java.awt.Desktop import kotlin.system.exitProcess +private const val LANGUAGE_PREF_READ_TIMEOUT_MS = 2000L + fun main(args: Array) { + // Install first so anything that blows up during Koin init or + // resource loading leaves a diagnosable trail on disk (see + // `CrashReporter.resolveLogDir` for the per-OS path). + CrashReporter.install() + // Reduce JVM DNS cache TTL so network changes (VPN on/off) are picked up quickly. // Default JVM caches positive lookups for 30s and negative lookups forever, // which breaks connectivity when a VPN changes DNS/routing mid-session. @@ -32,6 +45,31 @@ fun main(args: Array) { initKoin() + // Apply persisted UI language before any Compose code runs — same + // reasoning as on Android (see `MainActivity.onCreate`). Desktop + // Compose has no runtime `recreate()` equivalent, so mid-session + // language swaps surface as a "restart required" snackbar from the + // Tweaks screen; this block just covers the cold-start path so + // users see their chosen language immediately on next launch. + // + // Timeout guards against a stalled DataStore read blocking window + // creation and deep-link dispatch — we fall back to system language + // rather than hang the launch. + runBlocking { + val koin = GlobalContext.get() + val tweaksRepo = koin.get() + val localization = koin.get() + val tag = + try { + withTimeoutOrNull(LANGUAGE_PREF_READ_TIMEOUT_MS) { + tweaksRepo.getAppLanguage().first() + } + } catch (_: Exception) { + null + } + localization.setActiveLanguageTag(tag) + } + val deepLinkArg = args.firstOrNull() if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt index e7e132d5..faf08242 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt @@ -3,6 +3,13 @@ package zed.rainxch.core.data.services import java.util.Locale class AndroidLocalizationManager : zed.rainxch.core.data.services.LocalizationManager { + /** + * Snapshot of the original JVM locale at construction time, so + * [setActiveLanguageTag] with a null argument can restore it even + * after prior overrides have modified `Locale.getDefault()`. + */ + private val systemDefault: Locale = Locale.getDefault() + override fun getCurrentLanguageCode(): String { val locale = Locale.getDefault() val language = locale.language @@ -15,4 +22,15 @@ class AndroidLocalizationManager : zed.rainxch.core.data.services.LocalizationMa } override fun getPrimaryLanguageCode(): String = Locale.getDefault().language + + override fun setActiveLanguageTag(tag: String?) { + val normalized = tag?.trim().orEmpty() + val target = + if (normalized.isEmpty()) { + systemDefault + } else { + Locale.forLanguageTag(normalized) + } + Locale.setDefault(target) + } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index 7c1f968d..36ad81b1 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import zed.rainxch.core.domain.model.AppLanguages import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.FontTheme @@ -211,6 +212,28 @@ class TweaksRepositoryImpl( } } + override fun getAppLanguage(): Flow = + preferences.data.map { prefs -> + // Treat blank *or* unknown tags as "unset" — guards against + // stale writes from older builds that shipped a language + // we no longer bundle resources for, which would otherwise + // pin the UI to an unresolvable locale. + prefs[APP_LANGUAGE_KEY] + ?.trim() + ?.takeIf { it.isNotEmpty() && AppLanguages.containsTag(it) } + } + + override suspend fun setAppLanguage(tag: String?) { + preferences.edit { prefs -> + val normalized = tag?.trim().orEmpty() + if (normalized.isEmpty() || !AppLanguages.containsTag(normalized)) { + prefs.remove(APP_LANGUAGE_KEY) + } else { + prefs[APP_LANGUAGE_KEY] = normalized + } + } + } + companion object { private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L @@ -231,5 +254,6 @@ class TweaksRepositoryImpl( private val TRANSLATION_PROVIDER_KEY = stringPreferencesKey("translation_provider") private val YOUDAO_APP_KEY = stringPreferencesKey("youdao_app_key") private val YOUDAO_APP_SECRET = stringPreferencesKey("youdao_app_secret") + private val APP_LANGUAGE_KEY = stringPreferencesKey("app_language") } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt index d07ce3b2..2ff6606e 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt @@ -11,4 +11,17 @@ interface LocalizationManager { * Returns the primary language code without region (e.g., "zh" from "zh-CN") */ fun getPrimaryLanguageCode(): String + + /** + * Overrides the process-wide JVM `Locale.getDefault()` used by + * Compose Resources' `LocalComposeEnvironment` for string + * resolution. Passing `null` (or blank) restores the original + * system locale captured at instance construction. + * + * Must be called from the composition side (see `App()`) *before* + * the `key(appLanguage)`-wrapped content remounts, so the new + * locale is picked up when `stringResource` re-reads + * `Locale.current` on recomposition. + */ + fun setActiveLanguageTag(tag: String?) } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt index e2656ae0..83eacefc 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt @@ -3,6 +3,13 @@ package zed.rainxch.core.data.services import java.util.Locale class DesktopLocalizationManager : LocalizationManager { + /** + * Snapshot of the original JVM locale at construction time, so + * [setActiveLanguageTag] with a null argument can restore it even + * after prior overrides have modified `Locale.getDefault()`. + */ + private val systemDefault: Locale = Locale.getDefault() + override fun getCurrentLanguageCode(): String { val locale = Locale.getDefault() val language = locale.language @@ -15,4 +22,15 @@ class DesktopLocalizationManager : LocalizationManager { } override fun getPrimaryLanguageCode(): String = Locale.getDefault().language + + override fun setActiveLanguageTag(tag: String?) { + val normalized = tag?.trim().orEmpty() + val target = + if (normalized.isEmpty()) { + systemDefault + } else { + Locale.forLanguageTag(normalized) + } + Locale.setDefault(target) + } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt new file mode 100644 index 00000000..3acfed51 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt @@ -0,0 +1,49 @@ +package zed.rainxch.core.domain.model + +/** + * A user-selectable UI language for the app. Each entry corresponds to a + * `values-` directory that ships with the Compose resources + * bundle, so the [tag] must match what the Android-style locale qualifier + * resolves to (e.g. `zh-rCN` → language tag `zh-CN`). + * + * [displayName] is intentionally hard-coded in the native script so the + * picker is readable regardless of the currently active UI language — a + * user stuck in the wrong language needs to recognise their own language + * to escape. + */ +data class AppLanguage( + /** IETF BCP 47 language tag (e.g. `en`, `zh-CN`, `pt-BR`). */ + val tag: String, + /** Native-script label, e.g. `简体中文`, `Español`. */ + val displayName: String, +) + +/** + * Registry of languages the app currently ships translations for. Keep + * in sync with `core/presentation/src/commonMain/composeResources/values-*` + * directories. Order is the order shown in the Tweaks picker (English + * first as the source-of-truth language, rest alphabetised by tag). + */ +object AppLanguages { + val ALL: List = + listOf( + AppLanguage("en", "English"), + AppLanguage("ar", "العربية"), + AppLanguage("bn", "বাংলা"), + AppLanguage("es", "Español"), + AppLanguage("fr", "Français"), + AppLanguage("hi", "हिन्दी"), + AppLanguage("it", "Italiano"), + AppLanguage("ja", "日本語"), + AppLanguage("ko", "한국어"), + AppLanguage("pl", "Polski"), + AppLanguage("ru", "Русский"), + AppLanguage("tr", "Türkçe"), + AppLanguage("zh-CN", "简体中文"), + ) + + fun findByTag(tag: String?): AppLanguage? = + if (tag.isNullOrBlank()) null else ALL.find { it.tag == tag } + + fun containsTag(tag: String?): Boolean = findByTag(tag) != null +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index 1624be46..abd7ea7a 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -75,4 +75,15 @@ interface TweaksRepository { fun getYoudaoAppSecret(): Flow suspend fun setYoudaoAppSecret(appSecret: String) + + /** + * Selected UI language as a BCP 47 tag (e.g. `zh-CN`). Emits + * `null` when the user hasn't picked one — which means "follow + * whatever the JVM/Android locale is" at app start. `null` is + * distinct from `""`: the former is the unset state, the latter + * would be a malformed user choice we don't support. + */ + fun getAppLanguage(): Flow + + suspend fun setAppLanguage(tag: String?) } diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index a5fddde0..1b4a6b08 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -113,6 +113,13 @@ المظهر + اللغة + تجاوز لغة واجهة التطبيق. + لغة التطبيق + يغير القوائم والأزرار والرسائل في جميع أنحاء التطبيق. لا يغير المحتوى القادم من GitHub. + اتباع النظام + أعد التشغيل لتطبيق اللغة الجديدة. + إعادة التشغيل الشبكة حول diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 62750d1d..6b8feb18 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -113,6 +113,13 @@ চেহারা + ভাষা + অ্যাপের UI ভাষা ওভাররাইড করুন। + অ্যাপের ভাষা + সম্পূর্ণ অ্যাপের মেনু, বোতাম এবং বার্তা পরিবর্তন করে। GitHub থেকে আসা বিষয়বস্তু পরিবর্তন করে না। + সিস্টেম অনুসরণ করুন + নতুন ভাষা প্রয়োগ করতে পুনরায় চালু করুন। + পুনরায় চালু করুন সম্পর্কে নেটওয়ার্ক diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index 5e903895..55793062 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -95,6 +95,13 @@ Perfil APARIENCIA + IDIOMA + Reemplaza el idioma de la interfaz. + Idioma de la aplicación + Cambia los menús, botones y mensajes en toda la aplicación. No cambia el contenido que viene de GitHub. + Seguir el sistema + Reinicia para aplicar el nuevo idioma. + Reiniciar ACERCA DE RED diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 8c6dd770..6dc20b16 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -95,6 +95,13 @@ Profil APPARENCE + LANGUE + Remplace la langue de l\'interface. + Langue de l\'application + Modifie les menus, boutons et messages dans toute l\'application. Ne modifie pas le contenu provenant de GitHub. + Suivre le système + Redémarrez pour appliquer la nouvelle langue. + Redémarrer À PROPOS RÉSEAU diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 2d34bc25..abba20bb 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -113,6 +113,13 @@ उपस्थिति + भाषा + ऐप की UI भाषा को बदलें। + ऐप भाषा + पूरे ऐप में मेनू, बटन और संदेश बदलता है। GitHub से आने वाली सामग्री नहीं बदलती। + सिस्टम का पालन करें + नई भाषा लागू करने के लिए पुनरारंभ करें। + पुनरारंभ करें के बारे में नेटवर्क diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 2c036bf2..81e17b63 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -113,6 +113,13 @@ ASPETTO + LINGUA + Sovrascrivi la lingua dell\'interfaccia. + Lingua dell\'App + Cambia menu, pulsanti e messaggi in tutta l\'app. Non modifica i contenuti provenienti da GitHub. + Segui il sistema + Riavvia per applicare la nuova lingua. + Riavvia INFORMAZIONI RETE diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 50f05095..7412b147 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -95,6 +95,13 @@ プロフィール 外観 + 言語 + アプリのUI言語を上書きします。 + アプリの言語 + アプリ全体のメニュー、ボタン、メッセージを変更します。GitHubからのコンテンツは変更されません。 + システムに従う + 新しい言語を適用するには再起動してください。 + 再起動 情報 ネットワーク diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index e77a7558..ef957582 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -111,6 +111,13 @@ 외관 + 언어 + 앱의 UI 언어를 재정의합니다. + 앱 언어 + 앱 전체의 메뉴, 버튼, 메시지를 변경합니다. GitHub의 콘텐츠는 변경하지 않습니다. + 시스템 따름 + 새 언어를 적용하려면 다시 시작하세요. + 다시 시작 정보 네트워크 diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index b4303bb2..87d47f6e 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -96,6 +96,13 @@ Profil WYGLĄD + JĘZYK + Zmień język interfejsu aplikacji. + Język aplikacji + Zmienia menu, przyciski i komunikaty w całej aplikacji. Nie zmienia treści pochodzącej z GitHub. + Taki jak system + Uruchom ponownie, aby zastosować nowy język. + Uruchom ponownie O APLIKACJI SIEĆ diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index c39cde52..eded63c4 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -95,6 +95,13 @@ Профиль ВНЕШНИЙ ВИД + ЯЗЫК + Переопределить язык интерфейса приложения. + Язык приложения + Изменяет меню, кнопки и сообщения во всём приложении. Не изменяет содержимое с GitHub. + Как в системе + Перезапустите, чтобы применить новый язык. + Перезапустить О ПРИЛОЖЕНИИ СЕТЬ diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index 32f9ebb9..5c5afc99 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -112,6 +112,13 @@ GÖRÜNÜM + DİL + Uygulama arayüz dilini geçersiz kılar. + Uygulama dili + Uygulamadaki menüleri, düğmeleri ve mesajları değiştirir. GitHub\'dan gelen içeriği değiştirmez. + Sistemi izle + Yeni dili uygulamak için yeniden başlatın. + Yeniden başlat HAKKINDA diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 43d60e48..fe4c6bc8 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -97,6 +97,13 @@ 个人资料 外观 + 语言 + 覆盖应用界面语言。 + 应用语言 + 更改应用中的菜单、按钮和消息。不会更改来自 GitHub 的内容。 + 跟随系统 + 重新启动以应用新语言。 + 重新启动 关于 网络 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index b385fc88..d74c8430 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -116,6 +116,13 @@ APPEARANCE + LANGUAGE + Override the app\'s UI language. + App language + Changes menus, buttons, and messages throughout the app. Does not change content coming from GitHub. + Follow system + Restart to apply the new language. + Restart NETWORK ABOUT diff --git a/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt b/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt new file mode 100644 index 00000000..32b3b03a --- /dev/null +++ b/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt @@ -0,0 +1,13 @@ +package zed.rainxch.tweaks.presentation + +/** + * No-op on Android — runtime language changes are applied via + * `Activity.recreate()` from `MainActivity`, and the triggering + * event (`OnAppLanguageChangeRequiresRestart`) is never emitted on + * this platform. We keep an actual so common code compiles; if the + * invariant ever breaks we'd rather silently skip than kill the + * process. + */ +actual fun restartAppAfterLanguageChange() { + // Intentionally empty. +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt new file mode 100644 index 00000000..885fb28f --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt @@ -0,0 +1,19 @@ +package zed.rainxch.tweaks.presentation + +/** + * Platform hook for the "restart now" Snackbar action after a + * language change on Desktop. Tries to spawn a fresh JVM with the + * same command line as the current process and then exit — so the + * user ends up in a freshly-started app with their new locale + * applied by `DesktopApp.main`. If that isn't possible (IDE/Gradle + * runs, sandbox restrictions, etc.) the implementation falls back + * to a plain exit; the user's preference is already persisted, so + * they just need to reopen the app manually. + * + * This should never be invoked on Android — `MainActivity` handles + * runtime language changes via `Activity.recreate()`, and the + * `OnAppLanguageChangeRequiresRestart` event that triggers this is + * never emitted there. The Android actual is therefore a no-op for + * safety. + */ +expect fun restartAppAfterLanguageChange() diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt index 95a9f1e1..b40e0830 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt @@ -130,4 +130,12 @@ sealed interface TweaksAction { data object OnYoudaoAppSecretVisibilityToggle : TweaksAction data object OnYoudaoCredentialsSave : TweaksAction + + /** + * User picked a UI language. `tag == null` means "follow system + * locale" — cleared persisted preference. + */ + data class OnAppLanguageSelected( + val tag: String?, + ) : TweaksAction } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt index 8c75205a..769b3d5d 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt @@ -28,4 +28,14 @@ sealed interface TweaksEvent { data object OnTranslationProviderSaved : TweaksEvent data object OnYoudaoCredentialsSaved : TweaksEvent + + /** + * Fired on platforms where changing the UI language cannot be + * applied in-place (currently Desktop — no `Activity.recreate()` + * equivalent). The UI prompts the user to restart so the new + * locale takes effect on the next cold start. On Android this + * event is never emitted; `MainActivity` handles runtime changes + * via `recreate()` directly. + */ + data object OnAppLanguageChangeRequiresRestart : TweaksEvent } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt index dab182ae..c8c7ec8c 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -122,6 +123,27 @@ fun TweaksRoot(viewModel: TweaksViewModel = koinViewModel()) { snackbarState.showSnackbar(getString(Res.string.translation_youdao_saved)) } } + + TweaksEvent.OnAppLanguageChangeRequiresRestart -> { + coroutineScope.launch { + val result = + snackbarState.showSnackbar( + message = getString(Res.string.language_restart_required), + actionLabel = getString(Res.string.language_restart_action), + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed) { + // Best-effort relaunch on Desktop; see + // `RestartApp.jvm.kt`. Falls back to plain + // exit if a clean relaunch isn't possible + // (IDE runs, sandbox restrictions) — the + // preference is already persisted so the new + // locale takes effect on the next manual + // launch either way. + restartAppAfterLanguageChange() + } + } + } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt index fe847ed1..7d01f66b 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt @@ -42,6 +42,13 @@ data class TweaksState( val youdaoAppKey: String = "", val youdaoAppSecret: String = "", val isYoudaoAppSecretVisible: Boolean = false, + /** + * User-selected UI language as a BCP 47 tag, or `null` to follow + * the system locale. Mirrors the preference observed by + * `MainViewModel` — surfaced here so the Tweaks picker can show + * which chip is selected. + */ + val selectedAppLanguage: String? = null, ) { /** Effective provider to render as "selected" in the UI — draft * overrides persisted when a pending selection is in flight. */ diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index e4d9f760..e05ae743 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString +import zed.rainxch.core.domain.getPlatform +import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.model.TranslationProvider @@ -72,6 +74,7 @@ class TweaksViewModel( loadScrollbarEnabled() loadTelemetryEnabled() loadTranslationSettings() + loadAppLanguage() observeShizukuStatus() @@ -363,6 +366,14 @@ class TweaksViewModel( } } + private fun loadAppLanguage() { + viewModelScope.launch { + tweaksRepository.getAppLanguage().collect { tag -> + _state.update { it.copy(selectedAppLanguage = tag) } + } + } + } + private fun loadIncludePreReleases() { viewModelScope.launch { tweaksRepository.getIncludePreReleases().collect { enabled -> @@ -739,6 +750,29 @@ class TweaksViewModel( _events.send(TweaksEvent.OnYoudaoCredentialsSaved) } } + + is TweaksAction.OnAppLanguageSelected -> { + // Skip the write + restart prompt when the user re-picks + // the language that's already active — tapping the + // current option shouldn't look like a change on + // Desktop (would fire a spurious "restart to apply" + // snackbar) or churn DataStore on Android. + if (action.tag == _state.value.selectedAppLanguage) return + viewModelScope.launch { + tweaksRepository.setAppLanguage(action.tag) + // Android: `MainActivity` is subscribed to the + // same preference flow and calls `recreate()` on + // change — no extra nudging needed. Desktop has + // no recreate-equivalent, so we surface a + // "restart to apply" prompt; the user's choice is + // already persisted and will take effect on the + // next launch (or they can restart now via the + // snackbar action). + if (getPlatform() != Platform.ANDROID) { + _events.send(TweaksEvent.OnAppLanguageChangeRequiresRestart) + } + } + } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt new file mode 100644 index 00000000..46ed650e --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt @@ -0,0 +1,219 @@ +package zed.rainxch.tweaks.presentation.components.sections + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.model.AppLanguages +import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksState +import zed.rainxch.tweaks.presentation.components.SectionHeader + +fun LazyListScope.languageSection( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + item { + SectionHeader(text = stringResource(Res.string.section_language)) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.language_intro), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + LanguagePickerCard( + state = state, + onAction = onAction, + ) + } +} + +@Composable +private fun LanguagePickerCard( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + shape = RoundedCornerShape(32.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(Res.string.language_picker_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.language_picker_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + + Spacer(Modifier.height(12.dp)) + + LanguageDropdown( + selectedTag = state.selectedAppLanguage, + onLanguageSelected = { tag -> + onAction(TweaksAction.OnAppLanguageSelected(tag)) + }, + ) + } + } +} + +@Composable +private fun LanguageDropdown( + selectedTag: String?, + onLanguageSelected: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + val currentLabel = + when (val match = AppLanguages.findByTag(selectedTag)) { + null -> stringResource(Res.string.language_follow_system) + else -> match.displayName + } + + Box(modifier = Modifier.fillMaxWidth()) { + // Anchor row — tappable area that shows the current value and + // toggles the menu. Uses a `surface`-tinted background so it + // reads as a pickable control against the parent card's + // `surfaceContainer`; the plain clickable row otherwise + // blends into the card. + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable { expanded = true } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = currentLabel, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.size(8.dp)) + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + shape = RoundedCornerShape(20.dp), + // Default menu container is `surfaceContainer`, the same + // tone the parent `ElevatedCard` uses — the menu would + // visually dissolve into the card. Step up to + // `surfaceContainerHigh` so it reads as a distinct popup + // layer with the correct elevation contrast. + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + // Follow-system first — it's the default and users + // escaping a wrong-language lock-in look for this first. + DropdownMenuItem( + text = { DropdownItemText(stringResource(Res.string.language_follow_system)) }, + onClick = { + onLanguageSelected(null) + expanded = false + }, + trailingIcon = { + if (selectedTag == null) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + }, + ) + + AppLanguages.ALL.forEach { language -> + DropdownMenuItem( + text = { + // Native-script label so a user stuck in the + // wrong language can still recognise their + // own and escape. + DropdownItemText(language.displayName) + }, + onClick = { + onLanguageSelected(language.tag) + expanded = false + }, + trailingIcon = { + if (selectedTag == language.tag) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + }, + ) + } + } + } +} + +@Composable +private fun DropdownItemText(label: String) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt index 7f263724..5eef2d94 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt @@ -21,6 +21,15 @@ fun LazyListScope.settings( Spacer(Modifier.height(32.dp)) } + languageSection( + state = state, + onAction = onAction, + ) + + item { + Spacer(Modifier.height(32.dp)) + } + networkSection( state = state, onAction = onAction, diff --git a/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt b/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt new file mode 100644 index 00000000..2891b528 --- /dev/null +++ b/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt @@ -0,0 +1,46 @@ +package zed.rainxch.tweaks.presentation + +import kotlin.system.exitProcess + +/** + * Best-effort "relaunch this JVM" for the Desktop language-change + * flow. In `jpackage`-built installers (DMG/MSI/DEB) the current + * process's command line is a clean invocation of the app launcher, + * and [ProcessHandle] reliably gives us the executable path plus the + * original arguments — `ProcessBuilder` can spawn a fresh instance + * from that and we just exit this one. From IDE runs / `./gradlew + * run` the command line reflects the Gradle-managed forked JVM, + * which may or may not relaunch cleanly depending on classpath and + * stdout wiring; if the spawn fails we still want to exit so the + * user can reopen manually rather than be stuck in a half-applied + * state. + * + * [inheritIO] so the relaunched process shares our stdin/stdout/ + * stderr — mostly relevant in terminal runs; packaged apps have no + * attached terminal so it's a no-op there. + */ +actual fun restartAppAfterLanguageChange() { + try { + val info = ProcessHandle.current().info() + val command = info.command().orElse(null) + if (command != null) { + val arguments = info.arguments().orElse(emptyArray()) + ProcessBuilder(listOf(command) + arguments.toList()) + .inheritIO() + .start() + } else { + System.err.println( + "restartAppAfterLanguageChange: ProcessHandle has no command; exiting without relaunch", + ) + } + } catch (t: Throwable) { + // Swallow: we'd rather exit cleanly than leave the user in a + // limbo where the app is stuck with the old locale because + // the relaunch errored out. stderr so packaging regressions + // are still noticeable in logs without adding a logging dep. + System.err.println( + "restartAppAfterLanguageChange: relaunch failed (${t.javaClass.simpleName}: ${t.message}), falling back to plain exit", + ) + } + exitProcess(0) +}