Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(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()
Expand All @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 =
Expand Down
125 changes: 125 additions & 0 deletions composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt
Original file line number Diff line number Diff line change
@@ -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))
Comment on lines +16 to +24
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid persisting unrestricted stdout/stderr by default.

Line 21–24 writes every process log line to disk, including anything accidentally printed by dependencies or debug code. Add redaction/retention controls or make session teeing explicitly opt-in while keeping crash dumps enabled.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt`
around lines 16 - 24, The current install() sets System.setOut/Err to
TeePrintStream writing all stdout/stderr into session.log (via File(logDir,
"session.log") and rotateIfLarge), which persists arbitrary output; change
install() so teeing is opt-in and protected: add a boolean/config flag (e.g.,
enableSessionTee or CrashReporter.config.enableTee) checked before creating the
PrintStream and calling System.setOut/System.setErr so crash dumps remain active
but general teeing is disabled by default, and if enabled apply
redaction/retention controls (invoke rotateIfLarge, limit file age/size, and
filter sensitive patterns before writing using TeePrintStream or a wrapper) so
only approved/filtered output is persisted, leaving crash dump paths unchanged.

}
}.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)
}
Comment on lines +67 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle failed rotation so session.log cannot grow unbounded.

delete() and renameTo() can fail silently. If rotation fails, the next FileOutputStream(..., true) keeps appending beyond MAX_SESSION_LOG_BYTES.

Proposed fallback
     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)
+        if (rotated.exists() && !rotated.delete()) {
+            FileOutputStream(file, false).close()
+            return
+        }
+        if (!file.renameTo(rotated)) {
+            FileOutputStream(file, false).close()
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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()) {
FileOutputStream(file, false).close()
return
}
if (!file.renameTo(rotated)) {
FileOutputStream(file, false).close()
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/CrashReporter.kt`
around lines 67 - 72, The rotateIfLarge function currently calls
rotated.delete() and file.renameTo() which can fail silently; update
rotateIfLarge to check their boolean return values and handle failures: if
rotated.delete() fails, attempt to delete via Files.deleteIfExists(Path) or
create a uniquely-named temp rotated file and fall back to truncating the
original (open FileOutputStream(file, false) to truncate to zero) so session.log
cannot grow past MAX_SESSION_LOG_BYTES; if file.renameTo(rotated) fails, attempt
an atomic move via Files.move(Path, Path,
StandardCopyOption.ATOMIC_MOVE/REPLACE_EXISTING) or again truncate the original,
and log the error using your logger so failures are visible. Ensure references
to rotated ("session.1.log"), file, rotateIfLarge, and MAX_SESSION_LOG_BYTES are
used to locate where to apply these checks and fallbacks.


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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand All @@ -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<String>) {
// 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.
Expand All @@ -32,6 +45,31 @@ fun main(args: Array<String>) {

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<TweaksRepository>()
val localization = koin.get<LocalizationManager>()
val tag =
try {
withTimeoutOrNull(LANGUAGE_PREF_READ_TIMEOUT_MS) {
tweaksRepo.getAppLanguage().first()
}
} catch (_: Exception) {
null
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
localization.setActiveLanguageTag(tag)
}

val deepLinkArg = args.firstOrNull()

if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,6 +212,28 @@ class TweaksRepositoryImpl(
}
}

override fun getAppLanguage(): Flow<String?> =
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
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

companion object {
private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L

Expand All @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
}
Loading