diff --git a/docs/configuration.md b/docs/configuration.md index 35218d9..fb09dd2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,6 +94,24 @@ The exported names are included in `rivet-schema.json`: removing one is a breaking change, while adding one is compatible. See [typed device communication](device-communication.md). +## macOS bundle localizations + +Apps whose in-app strings already follow the user language declare + +``` +(macos-localizations "zh-Hans" "en") +``` + +in `rivet.rktd` (BCP 47 identifiers). Without a declaration the bundle +builds byte-identical to before, and AppKit renders every system-provided +string — About / Hide / Quit, standard alerts, SwiftUI navigation +tooltips — in the development region's language. With the declaration, +`CFBundleLocalizations` lands in Info.plist and packaging stages marker +`Contents/Resources/.lproj/InfoPlist.strings` files before signing +(no re-sign needed), so `CFBundleCopyBundleLocalizations` resolves the +user's language and the system chrome localizes to match the in-app +strings. Only declare languages the application itself already ships. + ## Application resources and icons - `resources` — a list of project-relative files or directories copied into the application resource root while preserving their relative paths. Generated/control roots (`.git`, `.rivet`, `build`, and `dist`) and symbolic links are rejected so a release cannot accidentally capture repository metadata, stale output, or files outside the project. diff --git a/rivet-cli/package.rkt b/rivet-cli/package.rkt index 2968f2e..06c9506 100644 --- a/rivet-cli/package.rkt +++ b/rivet-cli/package.rkt @@ -60,7 +60,8 @@ minimum-version icon-name url-schemes - file-associations) + file-associations + [localizations '()]) (define (xml-escape value) (regexp-replace* #px"[&<>\"]" value @@ -95,11 +96,22 @@ (format " CFBundleIconFile~a\n" (xml-escape icon-name)) "")) + ;; Declared localizations make CFBundleCopyBundleLocalizations (and thus + ;; the system-provided chrome: About/Hide/Quit menus, standard alerts) + ;; resolve to the user's language instead of the development region. + (define localization-fragment + (if (null? localizations) + "" + (format + " CFBundleLocalizations~a\n" + (apply string-append + (for/list ([language (in-list localizations)]) + (format "~a" (xml-escape language))))))) (call-with-output-file path #:exists 'truncate/replace (lambda (out) (fprintf out - "\n\n\n\n CFBundleDevelopmentRegionen\n CFBundleExecutable~a\n CFBundleIdentifier~a\n CFBundleInfoDictionaryVersion6.0\n CFBundleName~a\n CFBundleDisplayName~a\n CFBundlePackageTypeAPPL\n CFBundleShortVersionString~a\n CFBundleVersion~a\n LSMinimumSystemVersion~a\n~a~a~a NSHighResolutionCapable\n\n\n" + "\n\n\n\n CFBundleDevelopmentRegionen\n CFBundleExecutable~a\n CFBundleIdentifier~a\n CFBundleInfoDictionaryVersion6.0\n CFBundleName~a\n CFBundleDisplayName~a\n CFBundlePackageTypeAPPL\n CFBundleShortVersionString~a\n CFBundleVersion~a\n LSMinimumSystemVersion~a\n~a~a~a~a NSHighResolutionCapable\n\n\n" (xml-escape executable) (xml-escape identifier) (xml-escape display-name) @@ -109,7 +121,8 @@ (xml-escape minimum-version) icon-fragment url-fragment - association-fragment)))) + association-fragment + localization-fragment)))) (define (write-entitlements! path) (call-with-output-file path @@ -354,6 +367,7 @@ "docs/configuration.md, \"Application resources and icons\".") (string-join swiftpm-bundles ", "))) + (define localizations (project-macos-localizations project)) (write-macos-info! (build-path contents "Info.plist") display-name executable-name @@ -363,7 +377,18 @@ (project-macos-min-version project) packaged-icon-name (project-url-schemes project) - (project-file-associations project)) + (project-file-associations project) + localizations) + ;; Marker .lproj directories plus an empty InfoPlist.strings make the + ;; bundle resolve every declared language. Injection happens before + ;; sign-macos!, so no re-sign is needed. Projects without the + ;; declaration build a byte-identical bundle to before. + (for ([language (in-list localizations)]) + (define lproj (build-path contents "Resources" (format "~a.lproj" language))) + (make-parent-directory* (build-path lproj "InfoPlist.strings")) + (call-with-output-file (build-path lproj "InfoPlist.strings") + #:exists 'truncate/replace + void)) (define entitlements (project-path project ".rivet" "macos-entitlements.plist")) diff --git a/rivet-cli/project.rkt b/rivet-cli/project.rkt index 71478d4..8b1dacf 100644 --- a/rivet-cli/project.rkt +++ b/rivet-cli/project.rkt @@ -28,6 +28,7 @@ project-device-rpcs project-windows-icon project-macos-icon + project-macos-localizations project-linux-icon project-dmg-background project-windows-license @@ -202,6 +203,18 @@ (optional 'macos-icon (lambda (v) (path-string-has-extension? v ".icns")) "project-relative .icns path") + (optional 'macos-localizations + (lambda (v) + (define valid-language? + (lambda (item) + (and (string? item) + (regexp-match? #px"^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*$" + item)))) + (and (list? v) + (not (null? v)) + (andmap valid-language? v) + (= (length v) (length (remove-duplicates v))))) + "BCP 47 language identifiers that already ship in-app, e.g. (macos-localizations \"zh-Hans\" \"en\")") (optional 'dmg-background (lambda (v) (path-string-has-extension? v ".png")) "project-relative .png path") @@ -327,6 +340,12 @@ (define (project-macos-icon project) (project-ref project 'macos-icon (lambda () #f))) +;; Languages the application itself already localizes. Declaring them makes +;; AppKit localize the system-provided chrome (About/Quit menus, standard +;; alerts) into the user's language instead of the development region's. +(define (project-macos-localizations project) + (project-ref project 'macos-localizations (lambda () '()))) + ;; The branded DMG layout switches on this key; projects without it keep the ;; plain single-folder image. (define (project-dmg-background project) diff --git a/rivet/tests/resources.rkt b/rivet/tests/resources.rkt index 64a5100..bc65b29 100644 --- a/rivet/tests/resources.rkt +++ b/rivet/tests/resources.rkt @@ -117,6 +117,42 @@ (regexp-match? #rx"CFBundleIconFileAppIcon.icns" plist-text)) (check-true (regexp-match? #rx"Demo & Test" plist-text)) + ;; Without a declaration there is no CFBundleLocalizations key — the + ;; byte-identical behavior projects without the key have always had. + (check-false (regexp-match? #rx"CFBundleLocalizations" plist-text)) + + ;; Declared localizations land in Info.plist and marker lproj + ;; directories; the declaration also rejects malformed language tags. + (define localized-plist (build-path temp-root "Localized-Info.plist")) + (write-macos-info! localized-plist + "Demo" + "Demo" + "dev.rivet.demo" + "1.2.3" + 7 + "14.0" + #f + '() + '() + '("zh-Hans" "en")) + (define localized-text (file->string localized-plist)) + (check-true + (regexp-match? + #rx"CFBundleLocalizationszh-Hansen" + localized-text)) + (check-false + (regexp-match? #rx"CFBundleLocalizationszh" + (file->string plist))) + ;; Malformed language tags fail at the declaration, not the writer. + (define bad-loc-root (build-path temp-root "bad-loc")) + (make-directory* bad-loc-root) + (call-with-output-file (build-path bad-loc-root "rivet.rktd") + (lambda (out) + (displayln + "#hasheq((name . \"Demo_App\") (backend . \"app/backend.rkt\") (module . \"backend\") (entry . \"start\") (protocol . 1) (macos-localizations . (\"zh_Hans\")))" + out))) + (check-exn #rx"BCP 47" + (lambda () (load-project bad-loc-root))) (define missing-project (rivet-project temp-root #hasheq((resources . ("missing.txt")))))