mirror of
https://github.com/Darkrock-Studios/hammer-editor.git
synced 2026-10-19 09:34:23 +00:00
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:
parent
d39819fbe6
commit
540392a7a7
10 changed files with 446 additions and 90 deletions
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue