Move the sandbox first-run flow onto native AppKit

The Mac App Store build's first-run folder picker ran through AWT, which
claims NSApp before Tao installs its event loop — the app then runs on
with no window and no crash. Move the picker and its alerts into the
Objective-C helper so nothing touches AWT before nucleusApplication().

Localized dialog text now comes from SandboxStrings, which reads the
packaged Compose Resources .cvr value files directly; getString() can't
be called pre-Compose because resolving a ResourceEnvironment
initializes AWT for the density qualifier. Value files layer English
under each locale so a key Crowdin hasn't round-tripped falls back to
real text rather than its own name. SandboxStringsTest guards the format
coupling, since a Compose upgrade would otherwise only surface as raw key
names on an App Store first launch.

Also declare the clang flags as an input on compileMacosBookmarksLib so a
flags-only edit can't leave a stale dylib for signMacAppResources to sign
into the bundle, and document the sandboxed config and data paths in
SUPPORT.md.
This commit is contained in:
Wavesonics 2026-08-17 20:42:00 -07:00
parent d39819fbe6
commit 540392a7a7
10 changed files with 446 additions and 90 deletions

View file

@ -50,7 +50,12 @@ config directory).
**macOS:**
`~/Library/Application Support/hammer/0/logs/`
`~/Library/Preferences/hammer/0/logs/`
If you installed Hammer from the Mac App Store, it runs sandboxed and that
path is relocated into the app's container:
`~/Library/Containers/com.darkrockstudios.apps.hammer/Data/Library/Preferences/hammer/0/logs/`
**Linux:**

View file

@ -239,7 +239,26 @@ val compileMacosBookmarksLib = tasks.register("compileMacosBookmarksLib") {
val outDir = layout.buildDirectory.dir("macos-native-libs/macos").get().asFile
val outFile = outDir.resolve("libhammer_bookmarks.dylib")
// Declared as an input so a flags-only edit — another framework, a version-min
// bump — reruns the task instead of leaving a stale dylib UP-TO-DATE for
// signMacAppResources to sign into the bundle. The absolute -o and source
// paths stay out of it; inputs.file/outputs.file already cover those, and
// baking them in would make the fingerprint machine-specific.
val clangArgs = listOf(
"-dynamiclib",
"-fobjc-arc",
"-O2",
"-arch", "arm64",
"-mmacosx-version-min=12.0",
"-framework", "Foundation",
// AppKit backs the first-run folder picker and its alerts, which
// must be native — AWT would claim NSApp and wedge the Tao loop.
"-framework", "AppKit",
"-install_name", "@rpath/libhammer_bookmarks.dylib",
)
inputs.file(src).withPropertyName("source").withPathSensitivity(PathSensitivity.RELATIVE)
inputs.property("clangArgs", clangArgs)
outputs.file(outFile)
val execOps = project.providers
@ -247,17 +266,7 @@ val compileMacosBookmarksLib = tasks.register("compileMacosBookmarksLib") {
outDir.mkdirs()
execOps.exec {
executable = "clang"
args(
"-dynamiclib",
"-fobjc-arc",
"-O2",
"-arch", "arm64",
"-mmacosx-version-min=12.0",
"-framework", "Foundation",
"-install_name", "@rpath/libhammer_bookmarks.dylib",
"-o", outFile.absolutePath,
src.absolutePath
)
args(clangArgs + listOf("-o", outFile.absolutePath, src.absolutePath))
}.result.get()
logger.lifecycle("Compiled ${outFile.name}")
}

View file

@ -9,13 +9,20 @@
* security-scoped bookmark (NSURL bookmarkData with the security-scope
* option) and resolve it at startup.
*
* This dylib exposes 4 C functions JNA can call. Strings returned by
* create/resolve are heap-allocated and must be freed via
* This dylib exposes C functions JNA can call. Strings returned by
* create/resolve/pick are heap-allocated and must be freed via
* hammer_bookmark_free_string. Resolved NSURLs are retained internally
* so stop_access can find them by path without the caller round-tripping
* the NSURL pointer.
*
* It also hosts the first-run folder picker and its alerts. Those are
* deliberately native rather than AWT/Swing: they run before the Compose
* application starts, and initializing AWT first permanently wedges the
* Tao backend's event loop (see hammer_dialog_pick_directory).
*/
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
@ -115,3 +122,106 @@ void hammer_bookmark_stop(const char *path) {
void hammer_bookmark_free_string(const char *s) {
if (s != NULL) free((void *) s);
}
#pragma mark - First-run dialogs
/*
* AppKit refuses to instantiate an NSWindow anywhere but thread 0, and both
* dialogs below are NSWindows underneath. The JVM's `main` thread is not
* thread 0 — on macOS the java launcher runs main on a pthread of its own
* (JavaMain/ThreadJavaMain) and parks thread 0 in a CFRunLoop, which is what
* makes AppKit usable at all. So we hop onto the main queue and block until
* the user answers. That parked run loop is what services the queue; without
* it this dispatch_sync would deadlock rather than return.
*/
static void run_on_main_sync(dispatch_block_t block) {
if (pthread_main_np()) {
block();
} else {
dispatch_sync(dispatch_get_main_queue(), block);
}
}
/*
* NSAlert and NSOpenPanel need an NSApplication instance and a foreground
* activation policy to appear and take keyboard focus. Creating NSApp is all
* we do — the main run loop is left untouched so the Tao backend can claim it
* once startup finishes. (Letting AWT create NSApp instead is what breaks Tao:
* AWT parks the AppKit run loop on its own thread, and Tao's loop on the main
* thread then never receives events, so the app hangs with no window.)
*/
static void ensure_app(void) {
[NSApplication sharedApplication];
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
[NSApp activateIgnoringOtherApps:YES];
}
static NSString *ns_or_nil(const char *s) {
return s == NULL ? nil : [NSString stringWithUTF8String:s];
}
/*
* Modal two-button alert. Returns 1 when the user chose the primary button
* and 0 for the secondary button.
*/
int hammer_dialog_confirm(const char *title,
const char *message,
const char *primaryButton,
const char *secondaryButton) {
__block int result = 0;
run_on_main_sync(^{
@autoreleasepool {
ensure_app();
NSAlert *alert = [[NSAlert alloc] init];
alert.alertStyle = NSAlertStyleInformational;
NSString *t = ns_or_nil(title);
NSString *m = ns_or_nil(message);
if (t != nil) alert.messageText = t;
if (m != nil) alert.informativeText = m;
NSString *primary = ns_or_nil(primaryButton);
NSString *secondary = ns_or_nil(secondaryButton);
if (primary != nil) [alert addButtonWithTitle:primary];
if (secondary != nil) [alert addButtonWithTitle:secondary];
// Buttons come back in the order added, starting at NSAlertFirstButtonReturn.
result = ([alert runModal] == NSAlertFirstButtonReturn) ? 1 : 0;
}
});
return result;
}
/*
* Modal directory chooser. Returns the selected path (caller frees via
* hammer_bookmark_free_string) or NULL if the user cancelled.
*
* Under the sandbox this is the Powerbox panel, and it is the only thing that
* can widen our file access — picking here is what makes the subsequent
* hammer_bookmark_create call succeed.
*/
const char *hammer_dialog_pick_directory(const char *title,
const char *message,
const char *prompt) {
__block char *result = NULL;
run_on_main_sync(^{
@autoreleasepool {
ensure_app();
NSOpenPanel *panel = [NSOpenPanel openPanel];
panel.canChooseFiles = NO;
panel.canChooseDirectories = YES;
panel.allowsMultipleSelection = NO;
panel.canCreateDirectories = YES;
NSString *t = ns_or_nil(title);
NSString *m = ns_or_nil(message);
NSString *p = ns_or_nil(prompt);
if (t != nil) panel.title = t;
if (m != nil) panel.message = m;
if (p != nil) panel.prompt = p;
// Deliberately no directoryURL: NSHomeDirectory() is the sandbox
// container, and the panel already defaults to the real ~/Documents.
if ([panel runModal] != NSModalResponseOK) return;
NSURL *url = panel.URL;
if (url == nil) return;
result = copy_cstring([url path]);
}
});
return result;
}

View file

@ -160,11 +160,6 @@ fun main(args: Array<String>) {
val quickShortcuts = getKoin().get<QuickShortcuts>()
quickShortcuts.init()
if (initialProject == null) {
// When opening a project, the subsequent ApplicationState.openProject() will refresh.
appScope.launch { quickShortcuts.refresh() }
}
val scope = CoroutineScope(getDefaultDispatcher())
val mainDispatcher = getMainDispatcher()
@ -189,7 +184,15 @@ fun main(args: Array<String>) {
backend = NucleusBackend.Tao,
enableSingleInstance = false,
) {
LaunchedEffect(Unit) { Napier.i("Startup: first composition") }
LaunchedEffect(Unit) {
Napier.i("Startup: first composition")
// Refreshed from inside composition, never from a coroutine racing
// the launch: an implementation that touches AWT (the macOS dock
// menu does) must not get there before the Tao backend has claimed
// AppKit, or the app hangs with no window. See SandboxStartup.
// When opening a project, ApplicationState.openProject() refreshes instead.
if (initialProject == null) quickShortcuts.refresh()
}
val applicationState = remember {
ApplicationState(
appScope = appScope,

View file

@ -6,9 +6,6 @@ import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
import java.awt.Toolkit
fun getScreenWidth() = Toolkit.getDefaultToolkit().screenSize.width.dp
fun getScreenHeight() = Toolkit.getDefaultToolkit().screenSize.height.dp
fun coerceWindowSize(targetWidth: Dp, targetHeight: Dp): DpSize {
val min = 100.dp
val maxPercent = 0.9

View file

@ -21,6 +21,14 @@ internal interface HammerBookmarksLib : Library {
fun hammer_bookmark_resolve_and_start(base64Bookmark: String, outStale: IntByReference): Pointer?
fun hammer_bookmark_stop(path: String)
fun hammer_bookmark_free_string(s: Pointer)
fun hammer_dialog_confirm(
title: String?,
message: String?,
primaryButton: String?,
secondaryButton: String?,
): Int
fun hammer_dialog_pick_directory(title: String?, message: String?, prompt: String?): Pointer?
}
internal object MacOsBookmarks {
@ -67,6 +75,29 @@ internal object MacOsBookmarks {
if (activePath == path) activePath = null
}
/**
* Native two-button alert. True when the user chose the primary button.
*
* Deliberately not [javax.swing.JOptionPane]: this runs before the Compose
* application starts, and initializing AWT first leaves the Tao backend's
* event loop with no events to pump, hanging the app on a blank screen.
*/
fun confirm(title: String, message: String, primaryButton: String, secondaryButton: String): Boolean {
val lib = lib ?: return false
return lib.hammer_dialog_confirm(title, message, primaryButton, secondaryButton) == 1
}
/** Native directory chooser (the Powerbox panel when sandboxed). Null when cancelled. */
fun pickDirectory(title: String, message: String, prompt: String): String? {
val lib = lib ?: return null
val ptr = lib.hammer_dialog_pick_directory(title, message, prompt) ?: return null
return try {
ptr.getString(0)
} finally {
lib.hammer_bookmark_free_string(ptr)
}
}
private fun tryLoadLib(): HammerBookmarksLib? {
val osName = System.getProperty("os.name") ?: return null
if (!osName.lowercase().contains("mac")) return null

View file

@ -1,17 +1,9 @@
package com.darkrockstudios.apps.hammer.desktop.sandbox
import com.darkrockstudios.apps.hammer.*
import com.darkrockstudios.apps.hammer.common.IS_APP_STORE
import com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource.GlobalSettingsDatasource
import com.darkrockstudios.apps.hammer.common.util.StrRes
import io.github.aakira.napier.Napier
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.koin.java.KoinJavaComponent.getKoin
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import javax.swing.JOptionPane
import kotlin.system.exitProcess
/**
@ -22,15 +14,23 @@ import kotlin.system.exitProcess
* from the projects dir, which is inaccessible in the sandbox until we
* activate a security-scoped bookmark for it.
*
* Nothing on this path may touch AWT, directly or indirectly. All of it runs
* before the Compose application starts, and whatever creates `NSApp` first
* owns it: if that is AWT, the Tao backend's event loop — which blocks the
* main thread expecting AppKit events — never gets any, and the app runs on
* with no window and no crash. Two separate things used to do exactly that,
* and both had to go: the Swing/AWT dialogs (now native AppKit via
* [MacOsBookmarks]) and Compose Resources string lookup (now [SandboxStrings],
* because `getString()` reaches `Toolkit.getDefaultToolkit()` on desktop).
*
* The hang only ever bites on first launch — later launches resolve a stored
* bookmark and never get here — which is exactly how it reached App Review.
*
* No-op on non-sandboxed builds.
*/
object SandboxStartup {
private val strRes: StrRes by lazy { getKoin().get(StrRes::class) }
// These dialogs run on the startup thread before any Compose UI exists, so
// resolve the (suspend) localized strings synchronously.
private fun str(res: StringResource): String = runBlocking { strRes.get(res) }
private fun str(key: String): String = SandboxStrings.get(key)
fun ensureProjectsDirAccess() {
if (!IS_APP_STORE) return
@ -58,6 +58,7 @@ object SandboxStartup {
val (path, bookmark) = pickDirectoryUntilSuccessfulOrQuit()
bookmarkStore.saveProjectsDirBookmark(bookmark)
datasource.storeSettings(datasource.loadSettings().copy(projectsDirectory = path))
Napier.i("Projects directory set to picked path: $path")
}
private fun refreshIfNeeded(
@ -84,10 +85,14 @@ object SandboxStartup {
while (true) {
val picked = showNativeDirectoryPicker()
if (picked != null) {
val bookmark = MacOsBookmarks.createBookmark(picked.absolutePath)
Napier.i("User picked projects directory: $picked")
val bookmark = MacOsBookmarks.createBookmark(picked)
if (bookmark != null) {
val resolved = MacOsBookmarks.resolveAndStartAccess(bookmark)
if (resolved != null) return resolved.path to bookmark
if (resolved != null) {
Napier.i("Bookmark for picked directory resolved to: ${resolved.path}")
return resolved.path to bookmark
}
Napier.w("Could not start access on a freshly-created bookmark; retrying")
} else {
Napier.w("Could not create bookmark for picked directory; retrying")
@ -100,55 +105,27 @@ object SandboxStartup {
}
}
private fun showNativeDirectoryPicker(): File? {
// macOS-specific AWT toggle to make FileDialog pick directories rather
// than files. Restore the previous value so we don't leak this into any
// other dialog Compose Desktop might open later.
val key = "apple.awt.fileDialogForDirectories"
val prior = System.getProperty(key)
System.setProperty(key, "true")
try {
// Don't set dialog.directory — user.home is the sandbox container.
// NSOpenPanel runs out-of-process and defaults to the real ~/Documents.
val dialog = FileDialog(null as Frame?, str(Res.string.sandbox_picker_title), FileDialog.LOAD)
dialog.isVisible = true
val name = dialog.file ?: return null
val dir = dialog.directory ?: return null
return File(dir, name)
} finally {
if (prior == null) System.clearProperty(key) else System.setProperty(key, prior)
}
}
// Same string for title and message on purpose: modern macOS hides an open
// panel's title bar, so `message` is the only one the user actually reads.
private fun showNativeDirectoryPicker(): String? = MacOsBookmarks.pickDirectory(
title = str(SandboxStrings.PICKER_TITLE),
message = str(SandboxStrings.PICKER_TITLE),
prompt = str(SandboxStrings.CHOOSE_FOLDER_BUTTON),
)
// Shown once before the native picker so first-time users understand why
// macOS is about to ask them to choose a folder.
private fun confirmIntroOrQuit(): Boolean {
val options = arrayOf<Any>(str(Res.string.sandbox_choose_folder_button), str(Res.string.sandbox_quit_button))
val result = JOptionPane.showOptionDialog(
null,
str(Res.string.sandbox_intro_message),
str(Res.string.sandbox_intro_title),
JOptionPane.YES_NO_OPTION,
JOptionPane.INFORMATION_MESSAGE,
null,
options,
options[0],
)
return result == JOptionPane.YES_OPTION
}
private fun confirmIntroOrQuit(): Boolean = MacOsBookmarks.confirm(
title = str(SandboxStrings.INTRO_TITLE),
message = str(SandboxStrings.INTRO_MESSAGE),
primaryButton = str(SandboxStrings.CHOOSE_FOLDER_BUTTON),
secondaryButton = str(SandboxStrings.QUIT_BUTTON),
)
private fun confirmRetryOrQuit(): Boolean {
val options = arrayOf<Any>(str(Res.string.sandbox_choose_folder_button), str(Res.string.sandbox_quit_button))
val result = JOptionPane.showOptionDialog(
null,
str(Res.string.sandbox_retry_message),
"Hammer",
JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE,
null,
options,
options[0],
)
return result == JOptionPane.YES_OPTION
}
private fun confirmRetryOrQuit(): Boolean = MacOsBookmarks.confirm(
title = "Hammer",
message = str(SandboxStrings.RETRY_MESSAGE),
primaryButton = str(SandboxStrings.CHOOSE_FOLDER_BUTTON),
secondaryButton = str(SandboxStrings.QUIT_BUTTON),
)
}

View file

@ -0,0 +1,97 @@
package com.darkrockstudios.apps.hammer.desktop.sandbox
import io.github.aakira.napier.Napier
import java.util.Base64
import java.util.Locale
/**
* Reads the first-run dialog strings straight out of the packaged Compose
* Resources value files, without going through Compose Resources itself.
*
* That detour exists because `getString()` cannot be called before the Compose
* application starts. On desktop it resolves a [org.jetbrains.compose.resources.ResourceEnvironment]
* via `getSystemEnvironment()`, which calls `Toolkit.getDefaultToolkit()` to
* pick a density qualifier — and that initializes AWT. AWT then owns `NSApp`,
* the Tao backend's event loop gets no events, and the app runs on with no
* window and no crash (see [SandboxStartup]). Supplying our own environment
* isn't an option either: `ResourceEnvironment`'s constructor is internal.
*
* The `.cvr` files are a Compose Resources implementation detail — an ASCII
* `version:0` header followed by `string|<name>|<base64 utf-8>` lines, with
* XML escapes already resolved at build time, so a value here matches what
* `getString()` would have returned. Reading them keeps the translations
* Crowdin manages rather than forking a second copy of these strings. Since
* the format is not a public contract, every step degrades rather than throws:
* an unknown version or unreadable file falls back to English, and a key missing
* from every layer falls back to the key name, so a Compose upgrade can spoil
* the dialog text but never block startup.
*/
internal object SandboxStrings {
private const val RESOURCE_DIR = "composeResources/com.darkrockstudios.apps.hammer"
private const val FILE_NAME = "strings_desktop.commonMain.cvr"
private const val SUPPORTED_VERSION = "version:0"
private const val DEFAULT_VALUES_DIR = "values"
const val INTRO_TITLE = "sandbox_intro_title"
const val INTRO_MESSAGE = "sandbox_intro_message"
const val PICKER_TITLE = "sandbox_picker_title"
const val CHOOSE_FOLDER_BUTTON = "sandbox_choose_folder_button"
const val QUIT_BUTTON = "sandbox_quit_button"
const val RETRY_MESSAGE = "sandbox_retry_message"
/** Localized value for [key], falling back to the key itself if it can't be resolved. */
fun get(key: String): String = strings[key]?.takeIf { it.isNotBlank() } ?: key
private val strings: Map<String, String> by lazy { load(Locale.getDefault()) }
/**
* Layers each value file over the less specific ones beneath it, so English
* still backs a key a translation is missing. Compose's own fallback is
* per-key; taking a single value file wholesale would render the raw key name
* for anything Crowdin has not round-tripped yet.
*/
internal fun load(locale: Locale): Map<String, String> =
candidateDirs(locale).asReversed().fold(emptyMap<String, String>()) { merged, dir ->
merged + (readValues(dir) ?: emptyMap())
}
/** Most specific locale first, English last — mirrors Compose's own qualifier fallback. */
internal fun candidateDirs(locale: Locale): List<String> = buildList {
val language = locale.language
val country = locale.country
if (language.isNotEmpty() && country.isNotEmpty()) add("$DEFAULT_VALUES_DIR-$language-r$country")
if (language.isNotEmpty()) add("$DEFAULT_VALUES_DIR-$language")
add(DEFAULT_VALUES_DIR)
}
private fun readValues(valuesDir: String): Map<String, String>? {
val path = "$RESOURCE_DIR/$valuesDir/$FILE_NAME"
val text = runCatching {
javaClass.classLoader.getResourceAsStream(path)?.use { it.readBytes().decodeToString() }
}.onFailure { Napier.w("Could not read sandbox strings from $path", it) }.getOrNull() ?: return null
return parse(text, path)
}
internal fun parse(text: String, path: String = FILE_NAME): Map<String, String>? {
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
if (lines.firstOrNull()?.trim() != SUPPORTED_VERSION) {
Napier.w("Unexpected Compose Resources value-file version in $path — ignoring it")
return null
}
// Values are base64 on a single line each, so multi-line strings survive
// this line-based parse intact.
val parsed = lines.drop(1).mapNotNull { line ->
val parts = line.split('|', limit = 3)
if (parts.size < 3 || parts[0] != "string") return@mapNotNull null
val decoded = runCatching { Base64.getDecoder().decode(parts[2]).decodeToString() }.getOrNull()
// Blank values are dropped rather than kept, so an empty translation
// falls through to the English layer instead of shadowing it.
decoded?.trim()?.takeIf { it.isNotEmpty() }?.let { parts[1] to it }
}.toMap()
return parsed.ifEmpty { null }
}
}

View file

@ -40,16 +40,23 @@ class MacOsDockShortcuts(
runCatching {
val recent = projectsRepository.getRecentProjects(MAX_RECENT_PROJECTS, excludeCurrent)
idToProject.clear()
@Suppress("UNUSED_VARIABLE")
val items = recent.mapIndexed { idx, def ->
val id = idx + 1
idToProject[id] = def
DockMenuItem(id = id, title = def.name)
}
// Defer to the EDT so AWT has installed the NSApplicationDelegate
// that Nucleus method-swizzles in nativeSetDockMenu.
javax.swing.SwingUtilities.invokeLater {
MacOsDockMenu.setDockMenu(items)
}
// Publishing the menu is commented out along with the rest of this
// implementation being unbound (see desktopModule). It used to hop to
// the EDT so AWT would have installed the NSApplicationDelegate that
// Nucleus method-swizzles in nativeSetDockMenu — but touching AWT is
// exactly what hangs the app when it wins the race against the Tao
// backend claiming AppKit (see SandboxStartup). Re-enabling this needs
// a non-AWT way to get that delegate installed first; restoring the
// SwingUtilities call as-is would reintroduce the hang.
// javax.swing.SwingUtilities.invokeLater {
// MacOsDockMenu.setDockMenu(items)
// }
}.onFailure { Napier.w("Failed to refresh macOS dock menu", it) }
}
}

View file

@ -0,0 +1,120 @@
package com.darkrockstudios.apps.hammer.desktop.sandbox
import java.util.Locale
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* [SandboxStrings] reads a Compose Resources implementation detail (the `.cvr`
* value files). These tests are the tripwire for that coupling: if a Compose
* upgrade changes the format or the resource path, the first-run dialogs would
* silently degrade to showing raw key names, and only on a Mac App Store first
* launch. Catch it here instead.
*/
class SandboxStringsTest {
@Test
fun `every first-run dialog key resolves to real text`() {
val keys = listOf(
SandboxStrings.INTRO_TITLE,
SandboxStrings.INTRO_MESSAGE,
SandboxStrings.PICKER_TITLE,
SandboxStrings.CHOOSE_FOLDER_BUTTON,
SandboxStrings.QUIT_BUTTON,
SandboxStrings.RETRY_MESSAGE,
)
keys.forEach { key ->
val value = SandboxStrings.get(key)
// get() falls back to the key name, so equality means it failed to load.
assertNotEquals(key, value, "'$key' did not resolve from the packaged value files")
assertTrue(value.isNotBlank(), "'$key' resolved to blank text")
}
}
@Test
fun `english values load and match the known source text`() {
val strings = SandboxStrings.load(Locale.ENGLISH)
assertEquals("Welcome to Hammer", strings[SandboxStrings.INTRO_TITLE])
assertEquals("Choose Folder", strings[SandboxStrings.CHOOSE_FOLDER_BUTTON])
assertEquals("Quit", strings[SandboxStrings.QUIT_BUTTON])
}
@Test
fun `a translated locale resolves to its own text`() {
// French is checked in rather than generated, so it is a stable fixture.
val french = SandboxStrings.load(Locale.FRENCH)
val english = SandboxStrings.load(Locale.ENGLISH)
assertNotEquals(
english[SandboxStrings.INTRO_TITLE],
french[SandboxStrings.INTRO_TITLE],
"French should not fall back to the English value file",
)
}
@Test
fun `a translation missing a key still resolves it from english`() {
// Translated value files run behind the English one — the encyclopedia
// strings already differ by two keys — so every locale must layer English
// underneath rather than replace it, or an untranslated key would render
// as its own name in the first-run dialog.
val english = SandboxStrings.load(Locale.ENGLISH)
listOf(Locale.FRENCH, Locale.GERMAN, Locale.ITALIAN).forEach { locale ->
val strings = SandboxStrings.load(locale)
english.keys.forEach { key ->
assertTrue(
strings[key]?.isNotBlank() == true,
"'$key' is missing from $locale instead of falling back to English",
)
}
}
}
@Test
fun `unknown locale falls back to english`() {
val strings = SandboxStrings.load(Locale.forLanguageTag("zz"))
assertEquals("Welcome to Hammer", strings[SandboxStrings.INTRO_TITLE])
}
@Test
fun `candidate dirs go most specific first and end at the default`() {
assertEquals(
listOf("values-pt-rBR", "values-pt", "values"),
SandboxStrings.candidateDirs(Locale.forLanguageTag("pt-BR")),
)
assertEquals(listOf("values-fr", "values"), SandboxStrings.candidateDirs(Locale.FRENCH))
}
@Test
fun `multi-line values survive the line-based parse`() {
// Values are base64 on one line each, so an embedded newline must round-trip.
val encoded = java.util.Base64.getEncoder().encodeToString("first\nsecond".toByteArray())
val parsed = SandboxStrings.parse("version:0\nstring|multiline|$encoded")
assertEquals("first\nsecond", parsed?.get("multiline"))
}
@Test
fun `an unsupported file version is rejected rather than half-parsed`() {
val encoded = java.util.Base64.getEncoder().encodeToString("value".toByteArray())
assertNull(SandboxStrings.parse("version:99\nstring|key|$encoded"))
}
@Test
fun `malformed lines are skipped without failing the whole file`() {
val encoded = java.util.Base64.getEncoder().encodeToString("kept".toByteArray())
val parsed = SandboxStrings.parse(
"""
version:0
garbage-with-no-delimiters
plural|ignored_type|$encoded
string|good|$encoded
string|bad_base64|!!!not-base64!!!
""".trimIndent()
)
assertEquals("kept", parsed?.get("good"))
assertNull(parsed?.get("ignored_type"))
assertNull(parsed?.get("bad_base64"))
}
}