diff --git a/.github/workflows/build-ipa.yml b/.github/workflows/build-ipa.yml new file mode 100644 index 000000000..be3486887 --- /dev/null +++ b/.github/workflows/build-ipa.yml @@ -0,0 +1,286 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Builds an unsigned Husk.ipa -- the same artifact scripts/package_ipa.sh makes +# on a Mac -- and runs the translation layer's host tests alongside it. +# +# From a clean checkout that means building everything under build/ first: +# QEMU's dependencies, ANGLE (from WebKit), epoxy, virglrenderer, then QEMU with +# Husk's own sources integrated. That is most of two hours on a hosted runner, +# so the results are cached in two layers, each keyed on exactly the files that +# decide it: +# +# deps everything QEMU links against, ANGLE included +# qemu the QEMU dylib, which also depends on src/ios-jit and every patch +# +# A change to the app or to src/translation-layer reuses both and needs only +# the Xcode build. "rebuild" on a manual run ignores both caches. +# +# The IPA is unsigned on purpose, as package_ipa.sh explains: AltStore, +# SideStore and TrollStore re-sign at install. It is attached to the run as an +# artifact. +name: Build IPA + +on: + push: + branches: [main, "claude/**"] + pull_request: + workflow_dispatch: + inputs: + xcode: + description: "Xcode to build with, e.g. 26.2 (default: the newest on the runner)" + required: false + default: "" + runner: + description: "macOS runner image" + required: false + default: "macos-26" + rebuild: + description: "Ignore the caches and build QEMU and its dependencies from scratch" + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + translation-layer: + name: Translation layer tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install toolchains + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq clang lld llvm zlib1g-dev \ + gcc-aarch64-linux-gnu libc6-dev-arm64-cross qemu-user + + - name: Page planner, scanner, and device checks under qemu-user + run: sh tests/translation-layer/run.sh + + ipa: + name: Husk.ipa + runs-on: ${{ inputs.runner || 'macos-26' }} + timeout-minutes: 330 + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: "1" + DD: ${{ github.workspace }}/build/DerivedData + DEPS_FILES: >- + scripts/sources.sh scripts/build_ios.sh scripts/build_angle_ios.sh + scripts/build_gpu_ios.sh patches/pixman-0.38.0.patch + patches/libslirp-v4.9.1.patch patches/husk-epoxy-ios-egl-path.patch + steps: + - uses: actions/checkout@v4 + + # The newest Xcode unless told otherwise: Husk is developed against the + # newest SDK, and WebKit's ANGLE tree follows it closely. + - name: Select Xcode + id: xcode + env: + WANT: ${{ inputs.xcode }} + run: | + set -euo pipefail + if [ -n "${WANT:-}" ]; then + APP="/Applications/Xcode_${WANT}.app" + else + APP="$(find /Applications -maxdepth 1 -name 'Xcode_*.app' | sort -V | tail -1)" + fi + sudo xcode-select -s "$APP/Contents/Developer" + xcodebuild -version + echo "iOS SDK $(xcrun --sdk iphoneos --show-sdk-version)" + echo "build=$(xcodebuild -version | awk '/Build version/ {print $3}')" >> "$GITHUB_OUTPUT" + + # Since Xcode 26 the Metal compiler is a separate download, and both the + # app's Shaders.metal and ANGLE's built-in shaders need it. + - name: Metal toolchain + run: | + xcodebuild -downloadComponent MetalToolchain || echo "no separate Metal toolchain on this Xcode" + xcrun --sdk iphoneos -f metal + + # PyYAML for the python3 the cross files name (build_ios.sh writes + # `command -v python3` into them): virglrenderer's gallium code generator + # imports yaml, and a fresh runner does not have it. + - name: Host tools + run: | + brew install meson ninja + # qemu-img, for project.yml's pre-build step: it creates the firmware + # and userdata seeds the bundle ships, and without it the Xcode build + # stops with "qemu-img is required to generate the firmware and + # userdata seeds". + command -v qemu-img >/dev/null || brew install qemu + command -v pkg-config >/dev/null || brew install pkgconf + python3 -m pip install --quiet --break-system-packages pyyaml + python3 -c 'import yaml; print("PyYAML", yaml.__version__, "for", __import__("sys").executable)' + meson --version; ninja --version; pkg-config --version; qemu-img --version | head -1 + + - name: Cache keys + id: keys + run: | + set -euo pipefail + # shasum over the files themselves, in a fixed order, so the key + # changes exactly when one of them does. + read -ra files <<< "$DEPS_FILES" + deps=$(cat "${files[@]}" | shasum -a 256 | cut -c1-16) + # MoltenVK and libhusk_rppairing stage into build/ios-arm64/lib, which + # is the layer the qemu key covers, so their scripts belong in this + # hash rather than in DEPS_FILES -- a new MoltenVK version, or a new + # Cargo.lock, has to rebuild that layer. + qemu=$( (cat "${files[@]}" scripts/integrate_husk.sh scripts/build_moltenvk_ios.sh \ + scripts/build_rppairing_ios.sh src/rppairing-ios/Cargo.lock; \ + find patches src/ios-jit -type f | LC_ALL=C sort | xargs cat) \ + | shasum -a 256 | cut -c1-16) + echo "deps=deps-v1-${{ steps.xcode.outputs.build }}-$deps" >> "$GITHUB_OUTPUT" + # v2: a v1 lib/ was saved before the MoltenVK step existed, so it has + # no MoltenVK.xcframework in it and must not be restored. + echo "qemu=qemu-v2-${{ steps.xcode.outputs.build }}-$qemu" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies + id: deps + if: ${{ !inputs.rebuild }} + uses: actions/cache/restore@v4 + with: + key: ${{ steps.keys.outputs.deps }} + path: | + build/ios-arm64/sysroot + build/stamps + + - name: Restore QEMU + id: qemu + if: ${{ !inputs.rebuild }} + uses: actions/cache/restore@v4 + with: + key: ${{ steps.keys.outputs.qemu }} + path: | + build/ios-arm64/lib + third_party/build/qemu-10.0.12-utm/pc-bios + + # fetch_sources.sh also fetches libiconv and gettext, which no stage + # builds, so its exit status is not the test: the trees the build needs are. + - name: Fetch sources + if: steps.deps.outputs.cache-hit != 'true' || steps.qemu.outputs.cache-hit != 'true' + run: | + scripts/fetch_sources.sh || true + for d in libffi-3.5.0 glib-2.83.0 pixman-0.38.0 libslirp-v4.9.1 \ + qemu-10.0.12-utm libucontext; do + [ -d "third_party/build/$d" ] || { echo "::error::missing source tree $d"; exit 1; } + done + + - name: Build dependencies (libffi, glib, pixman, libucontext, libslirp) + if: steps.deps.outputs.cache-hit != 'true' + run: scripts/build_ios.sh libffi glib pixman libucontext libslirp + + - name: Build ANGLE + if: steps.deps.outputs.cache-hit != 'true' + run: scripts/build_angle_ios.sh + + # build_gpu_ios.sh reads cross-ios-darwin.meson, which nothing in the + # repository generates. build_ios.sh writes the same thing -- host system + # darwin, subsystem ios -- as cross-darwin.meson. + - name: Build epoxy and virglrenderer + if: steps.deps.outputs.cache-hit != 'true' + run: | + [ -f build/ios-arm64/cross-ios-darwin.meson ] \ + || cp build/ios-arm64/cross-darwin.meson build/ios-arm64/cross-ios-darwin.meson + scripts/build_gpu_ios.sh + + - name: Save dependencies + if: steps.deps.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + key: ${{ steps.keys.outputs.deps }} + path: | + build/ios-arm64/sysroot + build/stamps + + # UTM's patch first, then Husk's integration: the order a developer's + # tree gets them in (build_ios.sh patches on its first run), and the + # order integrate_husk.sh's in-place edits were written against. + - name: Build QEMU + if: steps.qemu.outputs.cache-hit != 'true' + run: | + set -euo pipefail + Q=third_party/build/qemu-10.0.12-utm + if [ ! -f "$Q/.husk-patched" ]; then + (cd "$Q" && patch -p1 -N -r - < "$GITHUB_WORKSPACE/patches/qemu-10.0.12-utm.patch") \ + || echo "::warning::qemu-10.0.12-utm.patch did not apply cleanly" + touch "$Q/.husk-patched" + fi + scripts/integrate_husk.sh + rm -f build/stamps/qemu + scripts/build_ios.sh qemu + ls -lh build/ios-arm64/lib/libqemu-aarch64-softmmu.dylib + + # project.yml embeds build/ios-arm64/lib/MoltenVK.xcframework: the Vulkan + # renderer Unreal Engine games draw through (Minecraft Dungeons) is + # MoltenVK on Apple hardware. Nothing else in this workflow builds it, and + # without it the app target does not get as far as compiling -- + # "There is no XCFramework found at .../MoltenVK.xcframework". It stages + # into build/ios-arm64/lib, the path the cache below covers, so it is built + # before that cache is saved. + - name: Build MoltenVK + if: steps.qemu.outputs.cache-hit != 'true' + run: scripts/build_moltenvk_ios.sh + + # The app target also links -lhusk_rppairing (Built-in StikJIT's on-device + # pairing, src/rppairing-ios), which stages into the same + # build/ios-arm64/lib, so it is built here and cached with the two above. + # The runner has rustup already; only the iOS target has to be added. + - name: Build libhusk_rppairing + if: steps.qemu.outputs.cache-hit != 'true' + run: | + export PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:$PATH" + command -v rustup >/dev/null || brew install rustup + export PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:$PATH" + rustup target add aarch64-apple-ios + scripts/build_rppairing_ios.sh + + - name: Save QEMU + if: steps.qemu.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + key: ${{ steps.keys.outputs.qemu }} + path: | + build/ios-arm64/lib + third_party/build/qemu-10.0.12-utm/pc-bios + + - name: Guest kernel + uses: actions/cache@v4 + with: + key: guest-phase0-${{ hashFiles('scripts/fetch_phase0_guest.sh') }} + path: guests/phase0 + + - name: Fetch guest kernel + run: scripts/fetch_phase0_guest.sh + + # The checked-in project, not a regenerated one: it lists every source + # (xcodegen is deliberately not installed, so package_ipa.sh uses it + # as-is), and it is what the commit being built actually says. + - name: Build and package + run: | + mkdir -p "$DD" + scripts/package_ipa.sh "$RUNNER_TEMP/Husk.ipa" + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: Husk-${{ github.run_number }}-${{ github.sha }} + path: ${{ runner.temp }}/Husk.ipa + if-no-files-found: error + retention-days: 30 + + - name: Upload build logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-logs-${{ github.run_number }} + if-no-files-found: ignore + path: | + build/logs + build/DerivedData/build.log + third_party/build/qemu-10.0.12-utm/_husk_build/meson-logs + third_party/gpu/*/_build/meson-logs diff --git a/README.md b/README.md index 3daab721a..9166605fe 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,36 @@ Husk's built-in StikJIT helper (iOS 26+), which on iOS 27 can pair with your iPhone from Settings with no computer. The app walks you through it; see [docs/06-built-in-jit.md](docs/06-built-in-jit.md). +## Languages + +Husk is drawn in English or Simplified Chinese, following the phone unless you +pin one in **Settings › Language**. + +The strings live in two String Catalogs, and there is no per-language code: + +| file | what is in it | +| --- | --- | +| `src/app/Husk/Localizable.xcstrings` | every string in the app | +| `src/app/Husk/InfoPlist.xcstrings` | the one line Info.plist puts in front of the user | + +English is the source language, so its strings are the keys and a key with no +translation falls back to itself. Adding a language is a matter of adding it to +those two catalogs and to `CFBundleLocalizations` in `Info.plist`. + +Two things are worth knowing before editing a string: + +- `Text("literal")` is localized on its own. Anything the compiler sees as a + plain `String` — a ternary, a concatenation, a value out of a computed + property, a parameter typed `String` — is not, and needs `localizedKey(...)` + (for a view) or `localizedString(...)` (for a value that has to stay a + `String`) from [`Localization.swift`](src/app/Husk/Localization.swift). A + sentence with a value in the middle must be one literal with an interpolation + — `Text("Version \(v)")`, key `Version %@` — and never a `+` between literals, + which would make it a `String` and put the value in the key. +- Log lines, shell commands, paths and the names of things Android reports (app + labels, ABIs, engine names) are deliberately not localized: they are evidence, + not sentences. + ## Builds Every push builds an unsigned `Husk.ipa` in GitHub Actions diff --git a/src/app/Husk.xcodeproj/project.pbxproj b/src/app/Husk.xcodeproj/project.pbxproj index 3b12ec5da..bf16063ef 100644 --- a/src/app/Husk.xcodeproj/project.pbxproj +++ b/src/app/Husk.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ 60DF5AC57C0225BEE8C9FB3A /* icon-dark.png in Resources */ = {isa = PBXBuildFile; fileRef = EA7240C2BD49C9F0CF7CB3DF /* icon-dark.png */; }; 61B170D2F117D85F651CD6E6 /* icon-cleardark.png in Resources */ = {isa = PBXBuildFile; fileRef = E3A701AA76E485A6CE15F106 /* icon-cleardark.png */; }; 622DA3EB6E956A9ED06CF55C /* husk-tl-probe.c in Sources */ = {isa = PBXBuildFile; fileRef = E4D1C81CB49D39A7D865A0DA /* husk-tl-probe.c */; }; + 66067DD03C99969CE47038F2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 7D060CA77FBF8A9F33B77A37 /* Localizable.xcstrings */; }; 6A8A2290222C62C81EBF2E3E /* husk-tl-bionic-pthread.c in Sources */ = {isa = PBXBuildFile; fileRef = C2150DD286E82127CB5B7C8B /* husk-tl-bionic-pthread.c */; }; 70AAC964808B173B7E8E395B /* husk-tl-va.c in Sources */ = {isa = PBXBuildFile; fileRef = 23EF05EC9B3D8A0E6E8FC720 /* husk-tl-va.c */; }; 70AF7E999105691EB51B54FD /* JITSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83275C7FEF2DEB950028D914 /* JITSetup.swift */; }; @@ -77,9 +78,11 @@ 9883B80CE2F3C84E5D6E0A8F /* husk-tl-elf.c in Sources */ = {isa = PBXBuildFile; fileRef = D08FCD7F480D6E2F65F26636 /* husk-tl-elf.c */; }; 9E3948EF967C2A0773C521DD /* husk-tl-jni-assetstream.c in Sources */ = {isa = PBXBuildFile; fileRef = FD6B5A72980D1EC374BFC326 /* husk-tl-jni-assetstream.c */; }; 9FA806201E1AF87FE7581742 /* husk-tl-jni-security.c in Sources */ = {isa = PBXBuildFile; fileRef = 85D8F83C8952092997CE071C /* husk-tl-jni-security.c */; }; + A9E109EC6075A61B2B7E74DE /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = F32D9806ADF6A69A331563B6 /* InfoPlist.xcstrings */; }; AAF3739690794B605F5D7CAE /* husk-tl-dexindex.c in Sources */ = {isa = PBXBuildFile; fileRef = AA12F6AB59959C0162BB8928 /* husk-tl-dexindex.c */; }; AC5C07974C7A8658358A55C9 /* husk-tl-a64.c in Sources */ = {isa = PBXBuildFile; fileRef = 3ADCEA1477DA816D867C1EBB /* husk-tl-a64.c */; }; B4CE75407A317521C77A2207 /* husk-tl-jni-loop.c in Sources */ = {isa = PBXBuildFile; fileRef = FE8922E7AB75C8C61B37986A /* husk-tl-jni-loop.c */; }; + B56BEF1EEE20E2F7957DD29E /* Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EA5A31D4A26E847447C926 /* Localization.swift */; }; B5C5BB7A0CFE28C032188D75 /* husk-tl-jni-ue4.c in Sources */ = {isa = PBXBuildFile; fileRef = ED3F7E0267BE8F2AC09FF772 /* husk-tl-jni-ue4.c */; }; B7335D9F68CE6EAF0E3D9825 /* icon-tintedlight.png in Resources */ = {isa = PBXBuildFile; fileRef = 336AB2FDC58A5C08D55FF8F9 /* icon-tintedlight.png */; }; B9D7168A505DD009DE222599 /* husk-tl-gamepad.c in Sources */ = {isa = PBXBuildFile; fileRef = 46287061DCF461334B1A8D2E /* husk-tl-gamepad.c */; }; @@ -168,6 +171,7 @@ 0C8178C469873F2DA90BB728 /* husk-tl-gameactivity.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "husk-tl-gameactivity.h"; sourceTree = ""; }; 0F570EB6EAB321272B32955C /* VirtualPad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualPad.swift; sourceTree = ""; }; 100DF87CB49D35A4DA089C94 /* husk-tl-framework.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "husk-tl-framework.h"; sourceTree = ""; }; + 11EA5A31D4A26E847447C926 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; 14E50E2B9CCFF7B1FEF73608 /* HuskAudio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuskAudio.swift; sourceTree = ""; }; 15D08F1730303C74C1B7DE23 /* AppSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSource.swift; sourceTree = ""; }; 198FA3419B26F852A2B05ED9 /* HuskJITMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuskJITMessages.swift; sourceTree = ""; }; @@ -241,6 +245,7 @@ 79E4865C5FE44ADD210A4917 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 7B5E2D3E9F12A2A3BB5BBDC7 /* husk-tl-gtasa.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "husk-tl-gtasa.h"; sourceTree = ""; }; 7CC582FAC1F17C1C659C9D47 /* husk-tl-bionic-io.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = "husk-tl-bionic-io.c"; sourceTree = ""; }; + 7D060CA77FBF8A9F33B77A37 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 7F8AFBCC1E9A402444C2D1E6 /* husk-tl-bionic-net.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = "husk-tl-bionic-net.c"; sourceTree = ""; }; 7FD8DC66414547110F0E0B0E /* husk-tl-res.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = "husk-tl-res.c"; sourceTree = ""; }; 7FE5D3DD79B5BEA14506FF60 /* husk-tl-dex.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = "husk-tl-dex.c"; sourceTree = ""; }; @@ -297,6 +302,7 @@ EE533073FECAD5C54B677935 /* icon-tinteddark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon-tinteddark.png"; sourceTree = ""; }; EF6C1917FEC08090833D9CF0 /* JITPairing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JITPairing.swift; sourceTree = ""; }; F13D91C39BBC9C52E70E1520 /* husk-tl-dexindex.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "husk-tl-dexindex.h"; sourceTree = ""; }; + F32D9806ADF6A69A331563B6 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; F337AAF2A6860FCB03810AA6 /* HuskGLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuskGLView.swift; sourceTree = ""; }; F54A47159D39CB8039E9E44E /* JITCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JITCard.swift; sourceTree = ""; }; F7FE1E9438A6B743456806DF /* husk-tl-xmem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "husk-tl-xmem.h"; sourceTree = ""; }; @@ -361,6 +367,7 @@ D91C1E7D6301EBCE06FEE0FE /* HuskMetalView.swift */, 7FFF8E257AE872A53A4E3066 /* HuskRPPairing.h */, FD559A37CF59C3290F29FA43 /* Info.plist */, + F32D9806ADF6A69A331563B6 /* InfoPlist.xcstrings */, 5664FE015975D16E7500F3C7 /* JITBootstrap.swift */, 43C233A62FB57030C0818489 /* JITBuiltIn.swift */, F54A47159D39CB8039E9E44E /* JITCard.swift */, @@ -369,6 +376,8 @@ 5894E74BA308222C01AEE311 /* JITSetupView.swift */, 34EE1ED95268ED65B2AA9A5A /* LibraryTab.swift */, 56DAE8E0DADC5483D33F6B7B /* LibraryView.swift */, + 7D060CA77FBF8A9F33B77A37 /* Localizable.xcstrings */, + 11EA5A31D4A26E847447C926 /* Localization.swift */, 79E4865C5FE44ADD210A4917 /* OnboardingView.swift */, 766F6F7816E672E83CFFE47D /* PipeShim.c */, 2E91A601F16886103BC482AB /* QemuRunner.swift */, @@ -621,6 +630,7 @@ knownRegions = ( Base, en, + "zh-Hans", ); mainGroup = 1E59FB82FCE9A2CABD19395C; minimizedProjectReferenceProxies = 1; @@ -641,9 +651,11 @@ buildActionMask = 2147483647; files = ( 38A9F59D462DE5A28D3F579D /* Assets.xcassets in Resources */, + A9E109EC6075A61B2B7E74DE /* InfoPlist.xcstrings in Resources */, FA8097D8E118A8CE8DBCF763 /* LICENSE-StikJIT-MPL-2.0.txt in Resources */, DD78A64F53CD2C615EB004B3 /* LICENSE-idevice-MIT.txt in Resources */, 1D576C7E088182B888F811BB /* LICENSES-rppairing-crates.txt in Resources */, + 66067DD03C99969CE47038F2 /* Localizable.xcstrings in Resources */, 50A3A90912B5C9D90B87D9BF /* cacert.pem in Resources */, E2C01F517E73992A29E22EC2 /* husk-jit.js in Resources */, 7A7A40B3B76C8BA2D7262907 /* husk-tl-egl-es31.inc in Resources */, @@ -748,6 +760,7 @@ D61EF536158A8495F2B0CE79 /* JITSetupView.swift in Sources */, D5795B7B2815D857E136AC85 /* LibraryTab.swift in Sources */, CE5D4B4D666CC7B351345EB3 /* LibraryView.swift in Sources */, + B56BEF1EEE20E2F7957DD29E /* Localization.swift in Sources */, D1DB4D94478AB7DD95E1A24E /* OnboardingView.swift in Sources */, F1E2ED25F28A24BA7197C5DB /* PipeShim.c in Sources */, 5E4B0292717DAE8B0CAA99E4 /* QemuRunner.swift in Sources */, diff --git a/src/app/Husk/AppDetailView.swift b/src/app/Husk/AppDetailView.swift index 7400ddd74..f2f34f98a 100644 --- a/src/app/Husk/AppDetailView.swift +++ b/src/app/Husk/AppDetailView.swift @@ -46,7 +46,7 @@ struct AppDetailView: View { } label: { HStack { Spacer() - Label(canOpen ? "Launch" : "Starting Android…", + Label(localizedKey(canOpen ? "Launch" : "Starting Android…"), systemImage: canOpen ? "play.fill" : "hourglass") .font(.headline) Spacer() @@ -63,7 +63,9 @@ struct AppDetailView: View { Section { LabeledContent("Version", value: live.version ?? "—") LabeledContent("Size", value: live.sizeBytes.map(Self.bytes) ?? "—") - LabeledContent("Last Used", value: live.lastUsed.map(Self.when) ?? "Never from Husk") + LabeledContent("Last Used", + value: live.lastUsed.map(Self.when) + ?? localizedString("Never from Husk")) } Section { @@ -92,8 +94,8 @@ struct AppDetailView: View { } Button("Cancel", role: .cancel) { } } message: { - Text("Its data goes with it. Save Android afterwards or the change is " - + "lost on the next launch.") + Text(localizedKey("Its data goes with it. Save Android afterwards or the change is " + + "lost on the next launch.")) } } @@ -130,8 +132,8 @@ struct AppDetailView: View { .foregroundStyle(.secondary) .lineLimit(1).truncationMode(.middle) HStack(spacing: 6) { - if let c = live.category { Tag(text: c) } - if let b = live.bitness { Tag(text: b) } + if let c = live.category { Tag(text: localizedKey(c)) } + if let b = live.bitness { Tag(text: localizedKey(b)) } } .padding(.top, 2) } @@ -158,16 +160,20 @@ struct AppDetailView: View { ByteCountFormatter.string(fromByteCount: n, countStyle: .file) } + /// "Today, 14:03" for the last two days and a plain date before that. + /// + /// The time itself is formatted, not spelled out, so it follows the phone's + /// own 12- or 24-hour setting; only the word in front of it is a key. A date + /// format of `'Today,' h:mm a` would have frozen both the wording and the + /// clock into English. static func when(_ date: Date) -> String { - let f = DateFormatter() + let time = date.formatted(date: .omitted, time: .shortened) if Calendar.current.isDateInToday(date) { - f.dateFormat = "'Today,' h:mm a" - } else if Calendar.current.isDateInYesterday(date) { - f.dateFormat = "'Yesterday,' h:mm a" - } else { - f.dateStyle = .medium - f.timeStyle = .none + return String(localized: "Today, \(time)") } - return f.string(from: date) + if Calendar.current.isDateInYesterday(date) { + return String(localized: "Yesterday, \(time)") + } + return date.formatted(date: .abbreviated, time: .omitted) } } diff --git a/src/app/Husk/AppSource.swift b/src/app/Husk/AppSource.swift index afbb02705..60a0b065c 100644 --- a/src/app/Husk/AppSource.swift +++ b/src/app/Husk/AppSource.swift @@ -101,7 +101,7 @@ final class SourceManager: ObservableObject { // Fetch a single source by URL func fetchSource(urlString: String) async { guard let url = URL(string: urlString) else { - fetchErrors[urlString] = "Invalid URL" + fetchErrors[urlString] = localizedString("Invalid URL") return } @@ -148,7 +148,7 @@ final class SourceManager: ObservableObject { let source = AppSource(name: simple.name, identifier: urlString, apps: apps) upsert(source: source) } else { - fetchErrors[urlString] = "Unrecognized source format" + fetchErrors[urlString] = localizedString("Unrecognized source format") HuskLog.log("sources", "Unrecognized format at \(urlString)") } } catch { diff --git a/src/app/Husk/BootScreen.swift b/src/app/Husk/BootScreen.swift index 8a0bd2e99..478a13e50 100644 --- a/src/app/Husk/BootScreen.swift +++ b/src/app/Husk/BootScreen.swift @@ -28,7 +28,7 @@ struct BootScreen: View { /// Said in order, not at random: the first two land while someone is still /// looking at the screen, and the jokes should not repeat before the /// information does. - private static let phrases = [ + private static let phrases: [LocalizedStringKey] = [ "Prepare for awesomeness", "Waking Android up", "Teaching an iPhone to speak Android", @@ -123,9 +123,19 @@ struct BootScreen: View { .frame(height: 5) HStack { - Text(shown > 0 ? "\(shown)%" : "starting") - .font(.technical(12, weight: .medium)) - .foregroundStyle(Theme.accent) + // Two literals rather than one ternary: the helper takes a + // `String`, so a ternary would hand it "42%" already + // interpolated, which is a key of its own rather than one the + // catalog can format. + Group { + if shown > 0 { + Text("\(shown)%") + } else { + Text("starting") + } + } + .font(.technical(12, weight: .medium)) + .foregroundStyle(Theme.accent) Spacer() if let left = remaining { Text(left) @@ -164,8 +174,8 @@ struct BootScreen: View { // for ten minutes, so show elapsed time instead. if done >= 58, !QemuRunner.didRestore { let mins = Int(now.timeIntervalSince(QemuRunner.bootStarted) / 60) - return mins < 1 ? "first boot takes 5–15 min" - : "\(mins) min · first boot takes 5–15 min" + return mins < 1 ? localizedString("first boot takes 5–15 min") + : String(localized: "\(mins) min · first boot takes 5–15 min") } guard done >= 8, done <= 92 else { return nil } let elapsed = now.timeIntervalSince(began) @@ -175,8 +185,8 @@ struct BootScreen: View { guard left > 2, left < 15 * 60 else { return nil } if left < 90 { let rounded = Int((left / 5).rounded()) * 5 - return "about \(max(rounded, 5)) seconds remaining" + return String(localized: "about \(max(rounded, 5)) seconds remaining") } - return "about \(Int((left / 60).rounded())) minutes remaining" + return String(localized: "about \(Int((left / 60).rounded())) minutes remaining") } } diff --git a/src/app/Husk/BundleUnpacker.swift b/src/app/Husk/BundleUnpacker.swift index be60423c0..cd67092d0 100644 --- a/src/app/Husk/BundleUnpacker.swift +++ b/src/app/Husk/BundleUnpacker.swift @@ -12,7 +12,7 @@ enum BundleUnpacker { case notAZip, unsupported(String) var errorDescription: String? { switch self { - case .notAZip: return "That file is not a bundle Husk can read." + case .notAZip: return localizedString("That file is not a bundle Husk can read.") case .unsupported(let why): return why } } @@ -54,7 +54,7 @@ enum BundleUnpacker { guard e >= 0 else { throw Failure.notAZip } let count = Int(le16(bytes, e + 10)) let dirSize = UInt64(le32(bytes, e + 12)), dirOffset = UInt64(le32(bytes, e + 16)) - if dirOffset == 0xffff_ffff || count == 0xffff { throw Failure.unsupported("That bundle uses a zip format (Zip64) Husk does not read yet.") } + if dirOffset == 0xffff_ffff || count == 0xffff { throw Failure.unsupported(localizedString("That bundle uses a zip format (Zip64) Husk does not read yet.")) } try file.seek(toOffset: dirOffset) guard let directory = try file.read(upToCount: Int(dirSize)), directory.count == Int(dirSize) else { throw Failure.notAZip } @@ -74,7 +74,7 @@ enum BundleUnpacker { let base = (name as NSString).lastPathComponent guard base.lowercased().hasSuffix(".apk"), !name.hasSuffix("/") else { continue } - guard method == 0 else { throw Failure.unsupported("\(base) is compressed inside its bundle, which Husk cannot unpack yet.") } + guard method == 0 else { throw Failure.unsupported(String(localized: "\(base) is compressed inside its bundle, which Husk cannot unpack yet.")) } // The local header has its own name and extra lengths; the data follows them. try file.seek(toOffset: localOffset) @@ -96,7 +96,7 @@ enum BundleUnpacker { } names.append(base) } - if names.isEmpty { throw Failure.unsupported("There are no APKs in that bundle.") } + if names.isEmpty { throw Failure.unsupported(localizedString("There are no APKs in that bundle.")) } return names } diff --git a/src/app/Husk/ContentView.swift b/src/app/Husk/ContentView.swift index 6e4138e6f..2414e0b7b 100644 --- a/src/app/Husk/ContentView.swift +++ b/src/app/Husk/ContentView.swift @@ -315,7 +315,7 @@ struct GuestScreenView: View { ProgressView(value: Double(runner.bootProgress), total: 100) .progressViewStyle(.linear) .frame(width: 200) - Text(runner.setupMessage ?? "Starting Android…") + Text(localizedKey(runner.setupMessage ?? "Starting Android…")) .font(.caption2).foregroundStyle(.secondary) .multilineTextAlignment(.center) } @@ -383,14 +383,14 @@ struct GuestScreenView: View { HuskGLView.rotated.toggle() rotated = HuskGLView.rotated } label: { - Label(rotated ? "Unrotate picture" : "Rotate picture", + Label(localizedKey(rotated ? "Unrotate picture" : "Rotate picture"), systemImage: "rotate.right") } Divider() Button { QemuRunner.shared.saveState(reason: "asked from full screen") } label: { - Label(runner.isSavingState ? "Saving…" : "Save Android", + Label(localizedKey(runner.isSavingState ? "Saving…" : "Save Android"), systemImage: "externaldrive.badge.checkmark") } .disabled(runner.isSavingState) @@ -499,7 +499,10 @@ struct SetupView: View { } else { ProgressView() } - Text(runner.setupMessage.map { "Android: \($0)" } ?? "Starting Android…") + // Composed rather than concatenated: the runner's own sentence is + // already translated, and "Android:" is the key around it. + Text(runner.setupMessage.map { String(localized: "Android: \($0)") } + ?? localizedString("Starting Android…")) .font(.callout).foregroundStyle(.secondary) .multilineTextAlignment(.center).padding(.horizontal, 36) Text("First run downloads Android and can take several minutes.") @@ -510,9 +513,9 @@ struct SetupView: View { switch guest.state { case .downloading(let p, let received, let total): VStack(spacing: 10) { - Text(guest.hasShippedSnapshot || GuestImage.shared.isFetchingSnapshot + Text(localizedKey(guest.hasShippedSnapshot || GuestImage.shared.isFetchingSnapshot ? "Downloading pre-booted Android" - : "Downloading Android runtime").font(.headline) + : "Downloading Android runtime")).font(.headline) ProgressView(value: p).padding(.horizontal, 50) Text("\(fmt(received)) of \(total > 0 ? fmt(total) : "…")") .font(.caption.monospacedDigit()).foregroundStyle(.secondary) @@ -730,8 +733,8 @@ struct ControlsSheet: View { row("computermouse", "Mouse", on: false, available: false) } - Text("Touch always works. A gamepad and a pointer are not wired " - + "through to Android yet.") + Text(localizedKey("Touch always works. A gamepad and a pointer are not wired " + + "through to Android yet.")) .font(.system(size: 12)) .foregroundStyle(Theme.textDim) .multilineTextAlignment(.center) @@ -743,7 +746,7 @@ struct ControlsSheet: View { } } - private func row(_ icon: String, _ title: String, + private func row(_ icon: String, _ title: LocalizedStringKey, on: Bool, available: Bool) -> some View { HStack(spacing: 14) { Image(systemName: icon) diff --git a/src/app/Husk/FilesTab.swift b/src/app/Husk/FilesTab.swift index c54ddb7fa..1a01411b0 100644 --- a/src/app/Husk/FilesTab.swift +++ b/src/app/Husk/FilesTab.swift @@ -20,7 +20,7 @@ struct FilesTab: View { DirectoryView(path: Self.root, title: "Files") .navigationDestination(for: String.self) { path in DirectoryView(path: path, - title: (path as NSString).lastPathComponent) + title: localizedKey((path as NSString).lastPathComponent)) } } } @@ -29,7 +29,7 @@ struct FilesTab: View { /// One directory. struct DirectoryView: View { let path: String - let title: String + let title: LocalizedStringKey @ObservedObject private var host = AndroidHost.shared @State private var entries: [AndroidHost.GuestEntry] = [] @@ -60,7 +60,8 @@ struct DirectoryView: View { if loading && entries.isEmpty { ProgressView() } else if let failure { - EmptyState(title: "Cannot Read This Folder", message: failure, systemImage: "lock") + EmptyState(title: "Cannot Read This Folder", message: localizedKey(failure), + systemImage: "lock") } else if entries.isEmpty { EmptyState(title: "Empty", message: "Nothing is in this folder yet.", systemImage: "folder", actionTitle: "Import Files", @@ -101,15 +102,16 @@ struct DirectoryView: View { @ViewBuilder private func row(_ e: AndroidHost.GuestEntry) -> some View { if e.isDirectory { NavigationLink(value: e.path) { - fileLabel(icon: "folder.fill", tint: .accentColor, title: e.name, - subtitle: e.modified.map(Self.when)) + fileLabel(icon: "folder.fill", tint: .accentColor, title: localizedKey(e.name), + subtitle: e.modified.map { localizedKey(Self.when($0)) }) } } else { Button { if e.name.lowercased().hasSuffix(".apk") { installing = e } } label: { HStack { - fileLabel(icon: icon(for: e.name), tint: .secondary, title: e.name, subtitle: subtitle(e)) + fileLabel(icon: icon(for: e.name), tint: .secondary, title: localizedKey(e.name), + subtitle: subtitle(e)) if e.name.lowercased().hasSuffix(".apk") { Spacer() Image(systemName: "arrow.down.circle").foregroundStyle(Color.accentColor) @@ -120,7 +122,8 @@ struct DirectoryView: View { } } - private func fileLabel(icon: String, tint: Color, title: String, subtitle: String?) -> some View { + private func fileLabel(icon: String, tint: Color, title: LocalizedStringKey, + subtitle: LocalizedStringKey?) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.title3) @@ -154,9 +157,9 @@ struct DirectoryView: View { return min(max(CGFloat(s.total - s.free) / CGFloat(s.total), 0.02), 1) } - private func subtitle(_ e: AndroidHost.GuestEntry) -> String { + private func subtitle(_ e: AndroidHost.GuestEntry) -> LocalizedStringKey { let size = AppDetailView.bytes(e.size) - guard let m = e.modified else { return size } + guard let m = e.modified else { return localizedKey(size) } return "\(size) · \(Self.when(m))" } @@ -213,12 +216,12 @@ struct ImportSheet: View { NavigationStack { VStack(spacing: 18) { VStack(spacing: 10) { + let folder = (destination as NSString).lastPathComponent Image(systemName: "doc.badge.plus") .font(.system(size: 34, weight: .light)) .foregroundStyle(Color.accentColor) Text("Import Files").font(.headline) - Text("They go to \((destination as NSString).lastPathComponent). " - + "Unmodified APKs install from here too.") + Text("They go to \(folder). Unmodified APKs install from here too.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/src/app/Husk/GuestImage.swift b/src/app/Husk/GuestImage.swift index 0ca7aa5e1..63186a12e 100644 --- a/src/app/Husk/GuestImage.swift +++ b/src/app/Husk/GuestImage.swift @@ -340,7 +340,7 @@ final class GuestImage: ObservableObject { if let why = check.problem { HuskLog.log("guest", "existing guest disk is INVALID (\(why)); removing it") try? FileManager.default.removeItem(atPath: diskPath) - state = .failed("The runtime on disk is not a valid image (\(why)). Tap to retry.") + state = .failed(String(localized: "The runtime on disk is not a valid image (\(why)). Tap to retry.")) } else { HuskLog.log("guest", "guest disk present and valid: \(check.size ?? 0) bytes") state = .ready @@ -531,9 +531,11 @@ final class GuestImage: ObservableObject { if let want = self.manifest?.snapshot.sha256, want != joined.digest { try? FileManager.default.removeItem(at: joined.url) self.isFetchingSnapshot = false - self.failed("The snapshot did not match the release " - + "(sha256 \(joined.digest.prefix(12)), expected " - + "\(want.prefix(12))).") + self.failed(String(localized: """ + The snapshot did not match the release \ + (sha256 \(joined.digest.prefix(12)), expected \ + \(want.prefix(12))). + """)) return } self.finishedSnapshot(tempURL: joined.url, digest: joined.digest) @@ -578,7 +580,7 @@ final class GuestImage: ObservableObject { try? FileManager.default.removeItem(at: dest) try? FileManager.default.removeItem(atPath: self.snapshotStampPath) DispatchQueue.main.async { - self.state = .failed("Could not unpack the snapshot: \(error.localizedDescription)") + self.state = .failed(String(localized: "Could not unpack the snapshot: \(error.localizedDescription)")) } } } @@ -751,7 +753,7 @@ final class GuestImage: ObservableObject { try? FileManager.default.removeItem(at: tempURL) HuskLog.log("guest", "image rejected: sha256 \(got.prefix(12)) but the " + "release says \(want.prefix(12))") - state = .failed("The downloaded image did not match the release. Tap to retry.") + state = .failed(localizedString("The downloaded image did not match the release. Tap to retry.")) return } // Validate BEFORE installing, so a bad download never becomes the thing @@ -760,7 +762,7 @@ final class GuestImage: ObservableObject { if let why = check.problem { try? FileManager.default.removeItem(at: tempURL) HuskLog.log("guest", "downloaded file rejected: \(why)") - state = .failed("Download did not produce a disk image: \(why)") + state = .failed(String(localized: "Download did not produce a disk image: \(why)")) return } let size = check.size ?? 0 diff --git a/src/app/Husk/GuestManifest.swift b/src/app/Husk/GuestManifest.swift index 86b80397a..b7c1f721a 100644 --- a/src/app/Husk/GuestManifest.swift +++ b/src/app/Husk/GuestManifest.swift @@ -115,8 +115,8 @@ enum GuestUpdate: Equatable { var title: String { switch self { case .none: return "" - case .image: return "A new Android image is available" - case .snapshot: return "A new pre-booted snapshot is available" + case .image: return localizedString("A new Android image is available") + case .snapshot: return localizedString("A new pre-booted snapshot is available") } } @@ -131,14 +131,21 @@ enum GuestUpdate: Equatable { let size = String(format: "%.1f GB", gb) switch self { case .image: - return "Downloading it replaces the Android system and its pre-booted " - + "snapshot (\(size)), and with them everything inside Android — " - + "installed apps included. A snapshot only restores against the " - + "image it was saved on, so the two cannot be updated separately." + // Multi-line rather than concatenated: the size is an interpolation + // of the key, and a `+` between literals makes the whole thing a + // plain String, which is not a key at all. + return String(localized: """ + Downloading it replaces the Android system and its pre-booted \ + snapshot (\(size)), and with them everything inside Android — \ + installed apps included. A snapshot only restores against the \ + image it was saved on, so the two cannot be updated separately. + """) case .snapshot: - return "Without it Android boots from cold, which takes several " - + "minutes. The download is \(size), and it replaces what is " - + "inside Android now, including installed apps." + return String(localized: """ + Without it Android boots from cold, which takes several \ + minutes. The download is \(size), and it replaces what is \ + inside Android now, including installed apps. + """) case .none: return "" } diff --git a/src/app/Husk/HuskAppIcon.swift b/src/app/Husk/HuskAppIcon.swift index 7c496880b..781bfb4dd 100644 --- a/src/app/Husk/HuskAppIcon.swift +++ b/src/app/Husk/HuskAppIcon.swift @@ -23,20 +23,22 @@ enum HuskAppIcon: String, CaseIterable, Identifiable { var id: String { rawValue } + /// A localized `String` rather than a key: this is drawn as a caption and + /// also named in the log when the icon is applied. var title: String { switch self { - case .automatic: return "Automatic" - case .clearLight: return "Clear Light" - case .clearDark: return "Clear Dark" - case .tintedLight: return "Tinted Light" - case .tintedDark: return "Tinted Dark" + case .automatic: return localizedString("Automatic") + case .clearLight: return localizedString("Clear Light") + case .clearDark: return localizedString("Clear Dark") + case .tintedLight: return localizedString("Tinted Light") + case .tintedDark: return localizedString("Tinted Dark") } } var detail: String { self == .automatic - ? "Follows the system: light, dark and tinted." - : "Always this look." + ? localizedString("Follows the system: light, dark and tinted.") + : localizedString("Always this look.") } /// What UIKit wants: nil for the primary, the asset name otherwise. diff --git a/src/app/Husk/HuskBridgeFS.swift b/src/app/Husk/HuskBridgeFS.swift index 348b4e9fd..e778bb2fa 100644 --- a/src/app/Husk/HuskBridgeFS.swift +++ b/src/app/Husk/HuskBridgeFS.swift @@ -157,7 +157,7 @@ final class HuskBridgeFS: ObservableObject { + "(\((try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? NSNumber)??.intValue ?? 0) bytes)") } catch { HuskLog.log("bridge", "FAILED to queue \(name): \(error.localizedDescription)") - lastAgentMessage = "Could not add \(name): \(error.localizedDescription)" + lastAgentMessage = String(localized: "Could not add \(name): \(error.localizedDescription)") } } @@ -884,7 +884,7 @@ final class AndroidHost: ObservableObject { } @Published private(set) var isReady = false - @Published private(set) var status = "Starting Android…" + @Published private(set) var status = localizedString("Starting Android…") @Published private(set) var packages: [Package] = AndroidHost.loadCatalogue() @Published private(set) var busy: String? /// What just finished. Progress lives in `busy`; this is the sentence after. @@ -908,7 +908,7 @@ final class AndroidHost: ObservableObject { func waitForReady() { guard !polling, !isReady else { return } polling = true - status = "Starting Android…" + status = localizedString("Starting Android…") Task.detached { [weak self] in var attempt = 0 while true { @@ -929,7 +929,7 @@ final class AndroidHost: ObservableObject { if booted == "1" { await MainActor.run { self?.isReady = true - self?.status = "Android is ready" + self?.status = localizedString("Android is ready") self?.polling = false } HuskLog.log("bridge", "guest is ready after \(attempt) attempts") @@ -943,12 +943,13 @@ final class AndroidHost: ObservableObject { await MainActor.run { self?.dumpDiagnostics() } return } - await MainActor.run { self?.status = "Android is booting…" } + await MainActor.run { self?.status = localizedString("Android is booting…") } } catch { GuestBridge.shared.disconnect() await MainActor.run { - self?.status = attempt < 4 ? "Starting Android…" - : "Waiting for Android (\(attempt * 3)s)…" + self?.status = attempt < 4 + ? localizedString("Starting Android…") + : String(localized: "Waiting for Android (\(attempt * 3)s)…") } } try? await Task.sleep(nanoseconds: 3_000_000_000) @@ -1435,7 +1436,7 @@ final class AndroidHost: ObservableObject { guard GuestBridge.shared.isAlive() else { let why = GuestBridge.shared.diagnose() HuskLog.log("bridge", "the shell is not answering -- \(why)") - return .unreachable("the Android shell is not answering") + return .unreachable(localizedString("the Android shell is not answering")) } // mWakefulness is Awake / Asleep / Dozing / Dreaming. if let power = try? GuestBridge.shared.shell( @@ -1462,7 +1463,10 @@ final class AndroidHost: ObservableObject { /// route in at all. Same transfer as an install, minus pm. func sendFiles(_ files: [URL], to directory: String = "/sdcard/Download") { guard !files.isEmpty else { return } - busy = "Sending \(files.count == 1 ? files[0].lastPathComponent : "\(files.count) files")…" + let what = files.count == 1 + ? files[0].lastPathComponent + : String(localized: "\(files.count) files") + busy = String(localized: "Sending \(what)…") Task.detached { [weak self] in var sent = 0 for file in files { @@ -1470,7 +1474,7 @@ final class AndroidHost: ObservableObject { do { switch AndroidHost.guestState() { case .locked: - throw BridgeError.io("Android is locked. Unlock it and try again.") + throw BridgeError.io(localizedString("Android is locked. Unlock it and try again.")) case .unreachable(let why): throw BridgeError.io(why) case .asleep: @@ -1489,7 +1493,7 @@ final class AndroidHost: ObservableObject { "mkdir -p \(Self.quote(directory))") try GuestBridge.shared.push(file, to: "'\(remote)'") { p in Task { @MainActor in - self?.busy = "Sending \(name) — \(Int(p * 100))%" + self?.busy = String(localized: "Sending \(name) — \(Int(p * 100))%") } } // Without this the file exists and no app can see it: the @@ -1501,7 +1505,7 @@ final class AndroidHost: ObservableObject { sent += 1 } catch { await MainActor.run { - self?.busy = "Could not send \(name): \(error.localizedDescription)" + self?.busy = String(localized: "Could not send \(name): \(error.localizedDescription)") } try? await Task.sleep(nanoseconds: 4_000_000_000) await MainActor.run { self?.busy = nil } @@ -1512,8 +1516,9 @@ final class AndroidHost: ObservableObject { let where_ = (directory as NSString).lastPathComponent await MainActor.run { self?.busy = nil - self?.say(n == 1 ? "File sent" : "\(n) files sent", - "In Android's \(where_) folder.") + self?.say(n == 1 ? localizedString("File sent") + : String(localized: "\(n) files sent"), + String(localized: "In Android's \(where_) folder.")) } } } @@ -1529,7 +1534,7 @@ final class AndroidHost: ObservableObject { /// it from there. This is the path for an APK that arrived through the /// Files tab, or that a browser in the guest downloaded itself. func installFromGuest(_ path: String, name: String) { - busy = "Installing \(name)…" + busy = String(localized: "Installing \(name)…") Task.detached { [weak self] in let out = (try? GuestBridge.shared.shell( "pm install -r \(AndroidHost.quote(path))", timeout: 900)) ?? "" @@ -1539,8 +1544,8 @@ final class AndroidHost: ObservableObject { await self?.refreshPackages() await MainActor.run { self?.busy = nil - self?.say(ok ? "APK installed" : "Install failed", - ok ? "\(name) is ready to launch." + self?.say(localizedString(ok ? "APK installed" : "Install failed"), + ok ? String(localized: "\(name) is ready to launch.") : String(out.prefix(120)) .trimmingCharacters(in: .whitespacesAndNewlines), good: ok) @@ -1549,7 +1554,7 @@ final class AndroidHost: ObservableObject { } func uninstall(_ package: String) { - busy = "Removing \(package)…" + busy = String(localized: "Removing \(package)…") Task.detached { [weak self] in var out = (try? GuestBridge.shared.shell("pm uninstall \(package)", timeout: 300)) ?? "" @@ -1572,8 +1577,8 @@ final class AndroidHost: ObservableObject { await self?.refreshPackages() await MainActor.run { self?.busy = nil - self?.say(ok ? "Uninstalled" : "Could not uninstall", - ok ? "\(label) is gone from Android." : label, + self?.say(localizedString(ok ? "Uninstalled" : "Could not uninstall"), + ok ? String(localized: "\(label) is gone from Android.") : label, good: ok) } } @@ -1595,7 +1600,7 @@ final class AndroidHost: ObservableObject { guard let first = apks.first else { return } let name = apks.count == 1 ? first.lastPathComponent : "\(apks.count) APKs (\(first.lastPathComponent))" - busy = "Installing \(name)…" + busy = String(localized: "Installing \(name)…") Task.detached { [weak self] in // /data/local/tmp is the one directory the shell user owns outright, // and the one pm will read an APK from. @@ -1620,15 +1625,17 @@ final class AndroidHost: ObservableObject { _ = try? GuestBridge.shared.shell("input keyevent KEYCODE_WAKEUP") Thread.sleep(forTimeInterval: 1.5) if case .locked = AndroidHost.guestState() { - throw BridgeError.io("Android is locked. Open the Android " - + "screen, unlock it, and try again.") + throw BridgeError.io(localizedString("Android is locked. Open the Android " + + "screen, unlock it, and try again.")) } case .locked: - throw BridgeError.io("Android is locked. Open the Android " - + "screen, unlock it, and try again.") + throw BridgeError.io(localizedString("Android is locked. Open the Android " + + "screen, unlock it, and try again.")) case .unreachable(let why): - throw BridgeError.io("\(why). Open the Android screen and " - + "check it is running, then try again.") + throw BridgeError.io(String(localized: """ + \(why). Open the Android screen and \ + check it is running, then try again. + """)) case .ready: break } @@ -1641,7 +1648,7 @@ final class AndroidHost: ObservableObject { : "\(url.lastPathComponent) (\(i + 1)/\(apks.count))" try GuestBridge.shared.push(url, to: remotes[i]) { p in Task { @MainActor in - self?.busy = "Copying \(label) — \(Int(p * 100))%" + self?.busy = String(localized: "Copying \(label) — \(Int(p * 100))%") } } // Confirm the whole file landed before asking pm to parse it. @@ -1658,7 +1665,7 @@ final class AndroidHost: ObservableObject { expected += size } - await MainActor.run { self?.busy = "Installing \(name)…" } + await MainActor.run { self?.busy = String(localized: "Installing \(name)…") } // Installing is dex2oat's work and it is emulated, so minutes // rather than seconds for anything large. -t allows test-signed // APKs, which most sideloaded builds are. @@ -1704,8 +1711,8 @@ final class AndroidHost: ObservableObject { HuskLog.log("bridge", "\(name) installed; not saving, " + "automatic saves are off") self?.busy = nil - self?.say("APK installed", - "Save Android to keep it past this session.") + self?.say(localizedString("APK installed"), + localizedString("Save Android to keep it past this session.")) return } @@ -1715,16 +1722,17 @@ final class AndroidHost: ObservableObject { // install was already lost, which is worse than saying // nothing -- it is a true-sounding statement that is no // longer true. - self?.busy = "Saving Android — the screen will freeze briefly" + self?.busy = localizedString("Saving Android — the screen will freeze briefly") QemuRunner.shared.saveState(reason: "installed \(name)") { saved in self?.busy = nil if saved { - self?.say("APK installed", "Ready to launch.") + self?.say(localizedString("APK installed"), + localizedString("Ready to launch.")) } else { HuskLog.log("bridge", "\(name) is installed but the machine " + "was not saved; it will be gone next launch") - self?.say("Installed, but not saved", - "It will be gone on the next launch.", + self?.say(localizedString("Installed, but not saved"), + localizedString("It will be gone on the next launch."), good: false) } } @@ -1732,7 +1740,7 @@ final class AndroidHost: ObservableObject { } else { await MainActor.run { self?.busy = nil - self?.say("Install failed", + self?.say(localizedString("Install failed"), String(out.prefix(120)).trimmingCharacters( in: .whitespacesAndNewlines), good: false) @@ -1740,7 +1748,7 @@ final class AndroidHost: ObservableObject { } } catch { HuskLog.log("bridge", "install failed: \(error.localizedDescription)") - await MainActor.run { self?.busy = "Install failed: \(error.localizedDescription)" } + await MainActor.run { self?.busy = String(localized: "Install failed: \(error.localizedDescription)") } Task { try? await Task.sleep(nanoseconds: 5_000_000_000) await MainActor.run { self?.busy = nil } } } @@ -1848,7 +1856,7 @@ final class AndroidHost: ObservableObject { /// Density moves with it, or every app lays out for a screen that is no /// longer there and the UI ends up cropped. func setRenderScale(_ scale: Double, then: @escaping () -> Void = {}) { - busy = scale >= 1 ? "Restoring full resolution…" : "Reducing render size…" + busy = localizedString(scale >= 1 ? "Restoring full resolution…" : "Reducing render size…") Task.detached { [weak self] in let base = GuestImage.shared.snapshotPins defer { Task { @MainActor in self?.busy = nil; then() } } @@ -1901,7 +1909,7 @@ final class AndroidHost: ObservableObject { func launch(_ pkg: String, then: @escaping () -> Void) { markLaunched(pkg) - busy = "Opening…" + busy = localizedString("Opening…") Task.detached { [weak self] in let out = (try? GuestBridge.shared.shell( "monkey -p \(pkg) -c android.intent.category.LAUNCHER 1", timeout: 60)) ?? "" diff --git a/src/app/Husk/HuskFilePicker.swift b/src/app/Husk/HuskFilePicker.swift index 5b9efcb26..a1d06b261 100644 --- a/src/app/Husk/HuskFilePicker.swift +++ b/src/app/Husk/HuskFilePicker.swift @@ -23,7 +23,7 @@ enum HuskFilePicker { live = coordinator guard let top = topController() else { live = nil - onFail?("There is no screen to show the file picker on.") + onFail?(localizedString("There is no screen to show the file picker on.")) return } HuskLog.log("ui", "file picker: presenting from \(type(of: top))") diff --git a/src/app/Husk/Info.plist b/src/app/Husk/Info.plist index 6aef4d7fa..e630f2893 100644 --- a/src/app/Husk/Info.plist +++ b/src/app/Husk/Info.plist @@ -16,6 +16,15 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + + CFBundleLocalizations + + en + zh-Hans + CFBundleName $(PRODUCT_NAME) CFBundlePackageType diff --git a/src/app/Husk/InfoPlist.xcstrings b/src/app/Husk/InfoPlist.xcstrings new file mode 100644 index 000000000..5fab48729 --- /dev/null +++ b/src/app/Husk/InfoPlist.xcstrings @@ -0,0 +1,24 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "NSLocalNetworkUsageDescription" : { + "comment" : "Shown by iOS when Husk first asks for local network access, for on-device pairing. The key is an Info.plist key rather than a sentence, so the English text has to be spelled out here as well -- without it the compiled en.lproj table maps the key to itself and the prompt reads \"NSLocalNetworkUsageDescription\".", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Husk uses the local network to pair with this iPhone for JIT, without a computer." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Husk 通过本地网络与此 iPhone 配对以开启 JIT,无需电脑。" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/src/app/Husk/JITBootstrap.swift b/src/app/Husk/JITBootstrap.swift index 075bc77a3..a56c76d93 100644 --- a/src/app/Husk/JITBootstrap.swift +++ b/src/app/Husk/JITBootstrap.swift @@ -90,11 +90,11 @@ enum JITBootstrap { HuskLog.log("jit", "no trap servicer, but MAP_JIT executes here -- " + "QEMU will map its own buffer") } else { - lastFailure = "The debugger is attached but is not answering trap " + lastFailure = localizedString("The debugger is attached but is not answering trap " + "requests, and this device will not execute a MAP_JIT " + "mapping either, so no executable memory could be " + "claimed. This is what happens when Husk runs inside " - + "another container app rather than sideloaded on its own." + + "another container app rather than sideloaded on its own.") } HuskLog.log("jit", ok ? "JIT region secured; it will be handed to QEMU later" : "JIT prewarm FAILED -- StikDebug is not servicing traps") diff --git a/src/app/Husk/JITBuiltIn.swift b/src/app/Husk/JITBuiltIn.swift index 49410b7d4..aa72c3bbc 100644 --- a/src/app/Husk/JITBuiltIn.swift +++ b/src/app/Husk/JITBuiltIn.swift @@ -43,13 +43,13 @@ enum HuskBuiltInJIT { static var unavailableReason: String? { #if targetEnvironment(simulator) - return "Built-in JIT is available only on a physical device." + return localizedString("Built-in JIT is available only on a physical device.") #else guard #available(iOS 26.0, *) else { - return "Built-in JIT needs iOS 26 or later. Use StikDebug instead." + return localizedString("Built-in JIT needs iOS 26 or later. Use StikDebug instead.") } if getenv("LC_HOME_PATH") != nil { - return "Built-in JIT is unavailable inside LiveContainer. Use StikDebug instead." + return localizedString("Built-in JIT is unavailable inside LiveContainer. Use StikDebug instead.") } return nil #endif @@ -71,31 +71,30 @@ enum HuskBuiltInJIT { completion(.failure(HelperRequest.error(code, message))) } guard unavailableReason == nil else { - fail(1, unavailableReason ?? "Built-in JIT is unavailable.") + fail(1, unavailableReason ?? localizedString("Built-in JIT is unavailable.")) return } guard let identifier = helperIdentifier else { - fail(2, "Husk's JIT helper is missing from this installation. Reinstall Husk, and keep " - + "its app extensions if your sideloader asks, or use StikDebug.") + fail(2, localizedString("Husk's JIT helper is missing from this installation. Reinstall Husk, and keep " + + "its app extensions if your sideloader asks, or use StikDebug.")) return } if NSClassFromString("NSExtension") == nil { dlopen("/System/Library/Frameworks/ExtensionFoundation.framework/ExtensionFoundation", RTLD_NOW) } guard let extensionClass = NSClassFromString("NSExtension") else { - fail(3, "iOS did not provide the extension API Husk's JIT helper needs.") + fail(3, localizedString("iOS did not provide the extension API Husk's JIT helper needs.")) return } let factory = unsafeBitCast(extensionClass as AnyObject, to: NSExtensionClassShim.self) let found: AnyObject do { found = try factory.extension(withIdentifier: identifier) } catch { - fail(2, "Husk's JIT helper (\(identifier)) could not be found: \(error.localizedDescription) " - + "Reinstall Husk.") + fail(2, String(localized: "Husk's JIT helper (\(identifier)) could not be found: \(error.localizedDescription) Reinstall Husk.")) return } guard let data = try? JSONEncoder().encode(request) else { - fail(4, "The request for Husk's JIT helper could not be encoded.") + fail(4, localizedString("The request for Husk's JIT helper could not be encoded.")) return } let helper = HelperRequest(extension: found, operation: request.operation.rawValue, @@ -133,14 +132,14 @@ private final class HelperRequest { DispatchQueue.main.async { MainActor.assumeIsolated { self?.returned(payload) } } } shim.setRequestCancellationBlock { [weak self] _, error in - let message = error?.localizedDescription ?? "the request was cancelled" + let message = error?.localizedDescription ?? localizedString("the request was cancelled") DispatchQueue.main.async { MainActor.assumeIsolated { - self?.end(.failure(Self.error(5, "Husk's JIT helper stopped: \(message)"))) + self?.end(.failure(Self.error(5, String(localized: "Husk's JIT helper stopped: \(message)")))) } } } shim.setRequestInterruptionBlock { [weak self] _ in DispatchQueue.main.async { MainActor.assumeIsolated { - self?.end(.failure(Self.error(6, "Husk's JIT helper stopped unexpectedly. Try again."))) + self?.end(.failure(Self.error(6, localizedString("Husk's JIT helper stopped unexpectedly. Try again.")))) } } } let item = NSExtensionItem() @@ -149,8 +148,8 @@ private final class HelperRequest { DispatchQueue.main.async { MainActor.assumeIsolated { guard let self else { return } guard let uuid else { - self.end(.failure(Self.error(7, "Husk's JIT helper did not start. Reinstall Husk, and " - + "keep its app extensions if your sideloader asks."))) + self.end(.failure(Self.error(7, localizedString("Husk's JIT helper did not start. Reinstall Husk, and " + + "keep its app extensions if your sideloader asks.")))) return } HuskLog.log("jit-helper", "\(self.operation): started \(identifier) as pid " @@ -163,7 +162,7 @@ private final class HelperRequest { private func returned(_ payload: Data?) { guard let payload, let response = try? JSONDecoder().decode(HuskJITRequest.Response.self, from: payload) else { - end(.failure(Self.error(8, "Husk's JIT helper finished without an answer."))) + end(.failure(Self.error(8, localizedString("Husk's JIT helper finished without an answer.")))) return } HuskLog.log("jit-helper", "\(operation): finished success=\(response.success ? 1 : 0)") diff --git a/src/app/Husk/JITCard.swift b/src/app/Husk/JITCard.swift index 8489fe86d..35377cf19 100644 --- a/src/app/Husk/JITCard.swift +++ b/src/app/Husk/JITCard.swift @@ -27,22 +27,20 @@ struct JITCard: View { .padding(.vertical, 4) } - private var explanation: String { + private var explanation: LocalizedStringKey { switch offered { case .trollStore: - return "TrollStore can turn JIT on for Husk: it opens Husk and enables it, with no computer. " - + "In TrollStore's Settings, URL Scheme must be on." + return "TrollStore can turn JIT on for Husk: it opens Husk and enables it, with no computer. In TrollStore's Settings, URL Scheme must be on." case .jailbreak: return "On a jailbroken device JIT is a setting. In Dopamine, turn on Allow JIT in Apps, then open Husk again." case .stikDebug: return "StikDebug is installed, so Husk will open it to turn JIT on." default: - return "StikJIT is built into Husk. You can turn JIT on right here — no computer and no other app. " - + "Games and Android both need it." + return "StikJIT is built into Husk. You can turn JIT on right here — no computer and no other app. Games and Android both need it." } } - private var buttonTitle: String { + private var buttonTitle: LocalizedStringKey { switch offered { case .trollStore: return "Turn On JIT with TrollStore" case .jailbreak: return "How to Allow JIT" @@ -74,7 +72,7 @@ struct JITCard: View { Image(systemName: "bolt.fill").font(.title3).foregroundStyle(Color.accentColor) } VStack(alignment: .leading, spacing: 4) { - Text(offered == .builtIn ? "StikJIT" : offered.title).font(.title3.weight(.semibold)) + Text(localizedKey(offered == .builtIn ? "StikJIT" : offered.title)).font(.title3.weight(.semibold)) Text("Turn on JIT").font(.subheadline).foregroundStyle(.secondary) } Spacer(minLength: 0) @@ -91,7 +89,7 @@ struct JITCard: View { if jit.busy { HStack(spacing: 10) { ProgressView() - Text(jit.status ?? "Turning on JIT…").font(.subheadline).foregroundStyle(.secondary) + Text(localizedKey(jit.status ?? "Turning on JIT…")).font(.subheadline).foregroundStyle(.secondary) } } else { Button { diff --git a/src/app/Husk/JITPairing.swift b/src/app/Husk/JITPairing.swift index cd3217e4c..6e3681d98 100644 --- a/src/app/Husk/JITPairing.swift +++ b/src/app/Husk/JITPairing.swift @@ -79,7 +79,7 @@ import UserNotifications guard Self.isSupported, !active, session == nil else { return } var error: UnsafeMutablePointer? guard let session = husk_rppairing_new(Self.hostName, &error) else { - fail(Self.take(error) ?? "Husk could not open its pairing port.") + fail(Self.take(error) ?? localizedString("Husk could not open its pairing port.")) return } self.session = session @@ -95,7 +95,7 @@ import UserNotifications beginBackground() deadline = Timer.scheduledTimer(withTimeInterval: Self.timeout, repeats: false) { _ in MainActor.assumeIsolated { - OnDevicePairing.shared.cancel(reason: "Pairing timed out. Try again when you are ready to pair from Settings.") + OnDevicePairing.shared.cancel(reason: localizedString("Pairing timed out. Try again when you are ready to pair from Settings.")) } } @@ -139,11 +139,13 @@ import UserNotifications phase = .pin(pin) log("code shown") if #available(iOS 26.0, *), let task = continued as? BGContinuedProcessingTask { - task.updateTitle("Pairing code \(pin)", subtitle: "Enter it on this \(Self.deviceKind) to pair with Husk") + task.updateTitle(String(localized: "Pairing code \(pin)"), + subtitle: String(localized: "Enter it on this \(Self.deviceKind) to pair with Husk")) task.progress.completedUnitCount = 1 } if UIApplication.shared.applicationState != .active { - notify("Husk pairing code: \(pin)", body: "Enter this code on your \(Self.deviceKind) to finish pairing.") + notify(String(localized: "Husk pairing code: \(pin)"), + body: String(localized: "Enter this code on your \(Self.deviceKind) to finish pairing.")) } } @@ -158,19 +160,19 @@ import UserNotifications do { guard let data else { throw NSError(domain: "HuskJIT", code: 20) } try JITCoordinator.shared.storeOnDevicePairing(data) - phase = .paired(device: device ?? "this \(Self.deviceKind)") + phase = .paired(device: device ?? String(localized: "this \(Self.deviceKind)")) log("paired") if UIApplication.shared.applicationState != .active { - notify("Paired with Husk", body: "Return to Husk to finish setting up JIT.") + notify(localizedString("Paired with Husk"), body: localizedString("Return to Husk to finish setting up JIT.")) } endBackground(success: true) } catch { - fail("Pairing finished, but Husk could not save the pairing file.") + fail(localizedString("Pairing finished, but Husk could not save the pairing file.")) } case 2: endBackground(success: false) default: - fail(message.map { "Pairing failed: \($0)" } ?? "Pairing failed. Try again.") + fail(message.map { String(localized: "Pairing failed: \($0)") } ?? localizedString("Pairing failed. Try again.")) } } @@ -221,9 +223,9 @@ import UserNotifications private func advertisingFailed(_ error: DNSServiceErrorType) { guard active else { return } log("advertising failed error=\(error)") - fail(error == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) + fail(localizedString(error == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) ? "Husk needs Local Network access to pair. Allow it in Settings › Apps › Husk, then try again." - : "Husk could not announce itself on the local network. Check that Wi-Fi is on, then try again.") + : "Husk could not announce itself on the local network. Check that Wi-Fi is on, then try again.")) } private func stopAdvertising() { @@ -245,8 +247,8 @@ import UserNotifications MainActor.assumeIsolated { let pairing = OnDevicePairing.shared if pairing.continued == nil && pairing.active { - pairing.cancel(reason: "iOS stopped Husk in the background before pairing finished. " - + "Try again, and pair from Settings straight away.") + pairing.cancel(reason: localizedString("iOS stopped Husk in the background before pairing finished. " + + "Try again, and pair from Settings straight away.")) } pairing.endGrace() } @@ -265,8 +267,8 @@ import UserNotifications guard ok else { log("register refused"); backgroundLimited = true; return } registered = identifier } - let request = BGContinuedProcessingTaskRequest(identifier: identifier, title: "Pairing with Husk", - subtitle: "Settings › Privacy & Security › Developer Mode") + let request = BGContinuedProcessingTaskRequest(identifier: identifier, title: localizedString("Pairing with Husk"), + subtitle: localizedString("Settings › Privacy & Security › Developer Mode")) request.strategy = .fail do { try BGTaskScheduler.shared.submit(request) @@ -283,13 +285,14 @@ import UserNotifications continued = task task.progress.totalUnitCount = 2 if case .pin(let pin) = phase { - task.updateTitle("Pairing code \(pin)", subtitle: "Enter it on this \(Self.deviceKind) to pair with Husk") + task.updateTitle(String(localized: "Pairing code \(pin)"), + subtitle: String(localized: "Enter it on this \(Self.deviceKind) to pair with Husk")) task.progress.completedUnitCount = 1 } task.expirationHandler = { DispatchQueue.main.async { OnDevicePairing.shared.continued = nil - OnDevicePairing.shared.cancel(reason: "iOS stopped the pairing in the background. Try again.") + OnDevicePairing.shared.cancel(reason: localizedString("iOS stopped the pairing in the background. Try again.")) } } log("continued-processing running") diff --git a/src/app/Husk/JITSetup.swift b/src/app/Husk/JITSetup.swift index 868178c14..2f8e26a26 100644 --- a/src/app/Husk/JITSetup.swift +++ b/src/app/Husk/JITSetup.swift @@ -13,13 +13,15 @@ enum JITMethod: String, CaseIterable, Identifiable { case builtIn var id: String { rawValue } + /// A localized `String` rather than a key: the picker draws it, the button + /// that enables JIT interpolates it into a sentence, and the log names it. var title: String { switch self { - case .automatic: return "Automatic" - case .stikDebug: return "StikDebug" - case .trollStore: return "TrollStore" - case .jailbreak: return "Jailbreak (Dopamine)" - case .builtIn: return "Built-in StikJIT" + case .automatic: return localizedString("Automatic") + case .stikDebug: return localizedString("StikDebug") + case .trollStore: return localizedString("TrollStore") + case .jailbreak: return localizedString("Jailbreak (Dopamine)") + case .builtIn: return localizedString("Built-in StikJIT") } } } @@ -67,7 +69,7 @@ enum JITPairingFileStore { let identifier = dictionary["identifier"] as? String, !identifier.isEmpty else { throw NSError(domain: "HuskJIT", code: 10, userInfo: [NSLocalizedDescriptionKey: - "That is not a remote pairing file. Make one with the StikDebug pairing-file guide and try again."]) + localizedString("That is not a remote pairing file. Make one with the StikDebug pairing-file guide and try again.")]) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try data.write(to: url, options: .atomic) @@ -104,10 +106,10 @@ final class JITCoordinator: ObservableObject { var message: String { switch self { case .vpn: - return "Husk couldn't reach this device. Connect LocalDevVPN, then try again." + return localizedString("Husk couldn't reach this device. Connect LocalDevVPN, then try again.") case .pairing: - return "This device closed the connection, which usually means it no longer accepts " - + "Husk's pairing. Pair again, and check that LocalDevVPN is connected." + return localizedString("This device closed the connection, which usually means it no longer accepts " + + "Husk's pairing. Pair again, and check that LocalDevVPN is connected.") } } } @@ -145,10 +147,10 @@ final class JITCoordinator: ObservableObject { var automaticDescription: String { switch resolvedMethod { - case .stikDebug: return "StikDebug is installed, so Husk will open it." - case .trollStore: return "TrollStore is installed, so Husk will ask it to enable JIT." - case .jailbreak: return "This device is jailbroken, so JIT comes from the jailbreak's Allow JIT in Apps setting." - default: return "StikDebug, TrollStore and a jailbreak were not found, so Husk will use its built-in helper." + case .stikDebug: return localizedString("StikDebug is installed, so Husk will open it.") + case .trollStore: return localizedString("TrollStore is installed, so Husk will ask it to enable JIT.") + case .jailbreak: return localizedString("This device is jailbroken, so JIT comes from the jailbreak's Allow JIT in Apps setting.") + default: return localizedString("StikDebug, TrollStore and a jailbreak were not found, so Husk will use its built-in helper.") } } @@ -169,19 +171,19 @@ final class JITCoordinator: ObservableObject { assertionFailure("Automatic must resolve to a concrete JIT method") case .stikDebug: if !JITBootstrap.requestAttach(), !JITBootstrap.requestTrollStoreAttach() { - error = "StikDebug is not installed. Install it, or set up Built-in StikJIT." + error = localizedString("StikDebug is not installed. Install it, or set up Built-in StikJIT.") showSetup = true } case .trollStore: if JITBootstrap.requestTrollStoreAttach() { awaitTrollStore() } else { - error = "TrollStore could not be opened. Install Husk through TrollStore, or choose another method." + error = localizedString("TrollStore could not be opened. Install Husk through TrollStore, or choose another method.") showSetup = true } case .jailbreak: // Nothing to ask: a jailbreak marks an app as debugged as it opens, if it has been told to. Say what to turn on. - error = "Turn on Allow JIT in Apps in Dopamine's settings, then open Husk again." + error = localizedString("Turn on Allow JIT in Apps in Dopamine's settings, then open Husk again.") showSetup = true case .builtIn: guard HuskBuiltInJIT.isAvailable, hasPairing else { @@ -198,20 +200,20 @@ final class JITCoordinator: ObservableObject { /// it never comes -- TrollStore only answers enable-jit once its URL Scheme setting is on. private func awaitTrollStore() { busy = true - status = "Waiting for TrollStore to enable JIT…" + status = localizedString("Waiting for TrollStore to enable JIT…") log("waiting for TrollStore's attach") _ = Self.waitForDebugger(timeout: 60) { [weak self] attached in guard let self else { return } busy = false if attached { - status = "JIT is on." + status = localizedString("JIT is on.") error = nil attachGeneration += 1 log("TrollStore enabled JIT") } else { status = nil - error = "TrollStore did not enable JIT. In TrollStore's Settings turn on URL Scheme, and make sure Husk was " - + "installed through TrollStore (or is signed with get-task-allow)." + error = localizedString("TrollStore did not enable JIT. In TrollStore's Settings turn on URL Scheme, and make sure Husk was " + + "installed through TrollStore (or is signed with get-task-allow).") log("TrollStore did not enable JIT within a minute") showSetup = true } @@ -225,7 +227,7 @@ final class JITCoordinator: ObservableObject { refreshPairingStatus() method = .builtIn prepared = false - status = "Pairing file imported." + status = localizedString("Pairing file imported.") error = nil log("pairing file imported") } catch { @@ -239,7 +241,7 @@ final class JITCoordinator: ObservableObject { refreshPairingStatus() method = .builtIn prepared = false - status = "Paired on this device." + status = localizedString("Paired on this device.") error = nil } @@ -249,7 +251,7 @@ final class JITCoordinator: ObservableObject { busy = true error = nil connectionProblem = nil - status = "Checking LocalDevVPN and the Developer Disk Image…" + status = localizedString("Checking LocalDevVPN and the Developer Disk Image…") HuskBuiltInJIT.send(.prepare(pairingData: pairing)) { [weak self] result in guard let self else { return } busy = false @@ -271,14 +273,14 @@ final class JITCoordinator: ObservableObject { func enableBuiltIn() { guard let pairing = builtInPairing() else { return } guard let script = JITBootstrap.scriptBase64 else { - error = "Husk's JIT script is missing from this installation. Reinstall Husk." + error = localizedString("Husk's JIT script is missing from this installation. Reinstall Husk.") return } busy = true error = nil connectionProblem = nil - status = "Starting Husk's JIT helper…" + status = localizedString("Starting Husk's JIT helper…") log("enabling JIT with the built-in helper") var readiness: Timer? var finished = false @@ -293,7 +295,7 @@ final class JITCoordinator: ObservableObject { log("built-in JIT failed: \(failure)") showSetup = true } else { - status = "JIT is on." + status = localizedString("JIT is on.") error = nil attachGeneration += 1 log("built-in helper attached") @@ -303,10 +305,10 @@ final class JITCoordinator: ObservableObject { HuskBuiltInJIT.send( .enable(targetPID: getpid(), pairingData: pairing, scriptBase64: script), started: { [weak self] in - self?.status = "Waiting for the helper to attach…" + self?.status = localizedString("Waiting for the helper to attach…") readiness = Self.waitForDebugger { attached in - finish(attached ? nil : "The helper did not attach within 90 seconds. " - + "Check that LocalDevVPN is connected, then try again.") + finish(attached ? nil : localizedString("The helper did not attach within 90 seconds. " + + "Check that LocalDevVPN is connected, then try again.")) } }, completion: { [weak self] result in @@ -327,7 +329,7 @@ final class JITCoordinator: ObservableObject { func resetDDI() { busy = true error = nil - status = "Resetting the Developer Disk Image cache…" + status = localizedString("Resetting the Developer Disk Image cache…") HuskBuiltInJIT.send(.resetDDI) { [weak self] result in guard let self else { return } busy = false @@ -349,7 +351,7 @@ final class JITCoordinator: ObservableObject { return nil } guard let pairing = try? JITPairingFileStore.data() else { - error = "Pair this device or import its pairing file first." + error = localizedString("Pair this device or import its pairing file first.") return nil } return pairing @@ -388,7 +390,11 @@ enum LocalDevVPN { static let connect = URL(string: "localdevvpn://enable?scheme=husk")! static var isInstalled: Bool { UIApplication.shared.canOpenURL(URL(string: "localdevvpn://")!) } - static var actionTitle: String { isInstalled ? "Connect LocalDevVPN" : "Get LocalDevVPN" } + /// A `String` rather than a key: it is also the name of a button in the + /// walkthrough, where `Button` localizes it just the same. + static var actionTitle: String { + localizedString(isInstalled ? "Connect LocalDevVPN" : "Get LocalDevVPN") + } @MainActor static func open() { let installed = isInstalled diff --git a/src/app/Husk/JITSetupView.swift b/src/app/Husk/JITSetupView.swift index 053c8af65..4acb8c8a8 100644 --- a/src/app/Husk/JITSetupView.swift +++ b/src/app/Husk/JITSetupView.swift @@ -65,13 +65,12 @@ struct JITSetupFlow: View { private var choose: some View { page(symbol: "bolt.fill", title: "Turn on JIT", - subtitle: "Android needs memory it can write and then run, which on iOS only an attached " - + "debugger can grant. Choose how your \(device) gets one.") { + subtitle: "Android needs memory it can write and then run, which on iOS only an attached debugger can grant. Choose how your \(device) gets one.") { VStack(spacing: 10) { let builtIn = HuskBuiltInJIT.unavailableReason way("Pair on this \(device)", symbol: "iphone.radiowaves.left.and.right", detail: builtIn ?? (!OnDevicePairing.isSupported ? "Needs iOS 27 or later." - : jit.pairingSource == .onDevice ? "Paired on this \(device)." + : jit.pairingSource == .onDevice ? String(localized: "Paired on this \(device).") : "No computer needed. Pairs from Settings in a minute."), done: jit.pairingSource == .onDevice, enabled: builtIn == nil && OnDevicePairing.isSupported) { path.append(.pairOnDevice) } @@ -108,7 +107,7 @@ struct JITSetupFlow: View { } } - private func way(_ title: String, symbol: String, detail: String, done: Bool, + private func way(_ title: LocalizedStringKey, symbol: String, detail: String, done: Bool, enabled: Bool = true, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 14) { @@ -119,7 +118,7 @@ struct JITSetupFlow: View { .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) VStack(alignment: .leading, spacing: 3) { Text(title).font(.system(size: 16, weight: .semibold)).foregroundStyle(Theme.text) - Text(detail).font(.system(size: 13)).foregroundStyle(Theme.textDim) + Text(localizedKey(detail)).font(.system(size: 13)).foregroundStyle(Theme.textDim) .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.leading) } @@ -146,16 +145,13 @@ struct JITSetupFlow: View { private var pairOnDevice: some View { page(symbol: "iphone.radiowaves.left.and.right", title: "Pair on this \(device)", - subtitle: "Husk pretends to be a computer on your Wi-Fi, and your \(device) pairs with it " - + "the way it would with a Mac.") { + subtitle: "Husk pretends to be a computer on your Wi-Fi, and your \(device) pairs with it the way it would with a Mac.") { VStack(alignment: .leading, spacing: 14) { point(1, "Turn on Wi-Fi, tap **Start pairing** below and allow Local Network access.", done: pairing.phase != .idle) - point(2, "Open **Settings › Privacy & Security › Developer Mode**, scroll down and tap " - + "**Pair with \(OnDevicePairing.hostName)**.", + point(2, "Open **Settings › Privacy & Security › Developer Mode**, scroll down and tap **Pair with \(OnDevicePairing.hostName)**.", done: pairing.isShowingPin || pairedOnDevice) - point(3, "Enter the code Husk shows. It also appears in a banner and a notification, " - + "so you don't have to switch back.", + point(3, "Enter the code Husk shows. It also appears in a banner and a notification, so you don't have to switch back.", done: pairedOnDevice) point(4, "Come back to Husk.", done: pairedOnDevice) } @@ -195,8 +191,8 @@ struct JITSetupFlow: View { } .font(.system(size: 14, weight: .semibold)) if pairing.backgroundLimited { - Text("This installation can only wait about 30 seconds in the background, " - + "so go to Settings straight away.") + Text(localizedKey("This installation can only wait about 30 seconds in the background, " + + "so go to Settings straight away.")) .font(.system(size: 13)).foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) } @@ -217,7 +213,7 @@ struct JITSetupFlow: View { case .paired(let name): outcome("Paired with \(name)", ok: true) case .failed(let message): - outcome(message, ok: false) + outcome(localizedKey(message), ok: false) } } @@ -227,8 +223,7 @@ struct JITSetupFlow: View { page(symbol: "doc.badge.plus", title: "Use a pairing file", subtitle: "A pairing file made on a computer lets Husk talk to this \(device) the same way.") { VStack(alignment: .leading, spacing: 14) { - point(1, "On a computer, make this \(device)'s pairing file with the " - + "[StikDebug pairing-file guide](https://github.com/StikDebug/StikDebug-Guide/blob/main/pairing_file.md).", + point(1, "On a computer, make this \(device)'s pairing file with the [StikDebug pairing-file guide](https://github.com/StikDebug/StikDebug-Guide/blob/main/pairing_file.md).", done: jit.pairingSource == .imported) point(2, "Save it to Files, or AirDrop it to this \(device).", done: jit.pairingSource == .imported) @@ -238,7 +233,7 @@ struct JITSetupFlow: View { Label("The pairing file stays in Husk's Documents folder and is only sent to Husk's own helper.", systemImage: "lock.fill") .font(.system(size: 13)).foregroundStyle(Theme.textDim) - if let error = jit.error { outcome(error, ok: false) } + if let error = jit.error { outcome(localizedKey(error), ok: false) } } actions: { if jit.pairingSource == .imported { Button("Continue") { path.append(.connect) }.buttonStyle(PrimaryButtonStyle()) @@ -255,8 +250,7 @@ struct JITSetupFlow: View { private var connect: some View { page(symbol: "network.badge.shield.half.filled", title: "Connect LocalDevVPN", - subtitle: "Husk's helper reaches this \(device)'s debugging service through a local VPN. " - + "Nothing leaves your \(device).") { + subtitle: "Husk's helper reaches this \(device)'s debugging service through a local VPN. Nothing leaves your \(device).") { VStack(alignment: .leading, spacing: 14) { point(1, "Install [LocalDevVPN](\(LocalDevVPN.appStore.absoluteString)) from the App Store.", done: LocalDevVPN.isInstalled) @@ -278,8 +272,7 @@ struct JITSetupFlow: View { private var enable: some View { page(symbol: "bolt.badge.checkmark", title: "Turn on JIT", - subtitle: "The first check downloads and mounts Apple's Developer Disk Image, " - + "which can take a minute.") { + subtitle: "The first check downloads and mounts Apple's Developer Disk Image, which can take a minute.") { VStack(alignment: .leading, spacing: 14) { point(1, "Tap **Check setup**. Husk checks LocalDevVPN and prepares the Developer Disk Image.", done: jit.prepared || attached) @@ -290,7 +283,7 @@ struct JITSetupFlow: View { if jit.busy { HStack(spacing: 11) { ProgressView().tint(Theme.accent) - Text(jit.status ?? "Working…").font(.system(size: 14)).foregroundStyle(Theme.text) + Text(localizedKey(jit.status ?? "Working…")).font(.system(size: 14)).foregroundStyle(Theme.text) Spacer(minLength: 0) } .padding(16).huskCard(high: true) @@ -298,9 +291,9 @@ struct JITSetupFlow: View { outcome("JIT is on. Android can start.", ok: true) } else if let error = jit.error { VStack(alignment: .leading, spacing: 10) { - outcome(error, ok: false) + outcome(localizedKey(error), ok: false) if jit.connectionProblem == .pairing { - Button(OnDevicePairing.isSupported ? "Pair again" : "Import a new pairing file") { + Button(localizedKey(OnDevicePairing.isSupported ? "Pair again" : "Import a new pairing file")) { path = [OnDevicePairing.isSupported ? .pairOnDevice : .importFile] } .font(.system(size: 14, weight: .semibold)) @@ -311,7 +304,7 @@ struct JITSetupFlow: View { } } } else if let status = jit.status { - outcome(status, ok: true) + outcome(localizedKey(status), ok: true) } if !attached { @@ -339,8 +332,7 @@ struct JITSetupFlow: View { private var stikDebug: some View { page(symbol: "ant", title: "Use StikDebug", - subtitle: "StikDebug is a separate app that attaches to Husk. Husk sends it the JIT script " - + "itself, so nothing needs configuring for Husk inside StikDebug.") { + subtitle: "StikDebug is a separate app that attaches to Husk. Husk sends it the JIT script itself, so nothing needs configuring for Husk inside StikDebug.") { VStack(alignment: .leading, spacing: 14) { point(1, "Install [StikDebug](https://github.com/StikDebug/StikDebug/releases/latest).", done: JITBootstrap.isStikDebugInstalled) @@ -363,21 +355,17 @@ struct JITSetupFlow: View { private var trollStore: some View { page(symbol: "sparkles", title: "Use TrollStore", - subtitle: "TrollStore can enable JIT for apps it installed, with no pairing file, " - + "VPN or computer.") { + subtitle: "TrollStore can enable JIT for apps it installed, with no pairing file, VPN or computer.") { VStack(alignment: .leading, spacing: 14) { - point(1, "Install Husk.ipa through TrollStore, on an iOS version TrollStore supports. It carries the " - + "entitlements TrollStore keeps, so the same file works there and in a sideloader.", + point(1, "Install Husk.ipa through TrollStore, on an iOS version TrollStore supports. It carries the entitlements TrollStore keeps, so the same file works there and in a sideloader.", done: JITBootstrap.isInstalledWithTrollStore) point(2, "In TrollStore's Settings, turn on URL Scheme. TrollStore ignores enable-jit requests without it.", done: JITBootstrap.isTrollStoreInstalled) - point(3, "Whenever Android or a game starts, Husk asks TrollStore to enable JIT. TrollStore opens Husk, " - + "attaches to it for a moment and lets go, which leaves it allowed to run code it wrote.") + point(3, "Whenever Android or a game starts, Husk asks TrollStore to enable JIT. TrollStore opens Husk, attaches to it for a moment and lets go, which leaves it allowed to run code it wrote.") } .padding(16).huskCard() if !JITBootstrap.isTrollStoreInstalled { - Label("TrollStore was not found on this \(device). Husk can only use it once it is installed " - + "and Husk was installed through it.", systemImage: "info.circle") + Label("TrollStore was not found on this \(device). Husk can only use it once it is installed and Husk was installed through it.", systemImage: "info.circle") .font(.system(size: 13)).foregroundStyle(Theme.textDim) .fixedSize(horizontal: false, vertical: true) } @@ -395,8 +383,7 @@ struct JITSetupFlow: View { private var jailbreak: some View { page(symbol: "lock.open", title: "Use a jailbreak", - subtitle: "On a device jailbroken with Dopamine, JIT is a setting: the jailbreak marks every app as " - + "debugged when it opens, and a debugged app may run code it wrote. Husk needs nothing else.") { + subtitle: "On a device jailbroken with Dopamine, JIT is a setting: the jailbreak marks every app as debugged when it opens, and a debugged app may run code it wrote. Husk needs nothing else.") { VStack(alignment: .leading, spacing: 14) { point(1, "Open Dopamine and go to its Settings.", done: JITBootstrap.isJailbroken) point(2, "Turn on Allow JIT in Apps.") @@ -424,7 +411,7 @@ struct JITSetupFlow: View { // MARK: Pieces private func page( - symbol: String, title: String, subtitle: String, + symbol: String, title: LocalizedStringKey, subtitle: LocalizedStringKey, @ViewBuilder content: () -> Content, @ViewBuilder actions: () -> Actions) -> some View { ZStack { @@ -457,7 +444,7 @@ struct JITSetupFlow: View { } } - private func point(_ number: Int, _ text: String, done: Bool = false) -> some View { + private func point(_ number: Int, _ text: LocalizedStringKey, done: Bool = false) -> some View { HStack(alignment: .top, spacing: 12) { ZStack { Circle().fill(done ? Color.green.opacity(0.18) : Theme.accentSoft) @@ -468,7 +455,7 @@ struct JITSetupFlow: View { } } .frame(width: 26, height: 26) - Text(.init(text)) + Text(text) .font(.system(size: 15)) .foregroundStyle(Theme.text) .fixedSize(horizontal: false, vertical: true) @@ -476,7 +463,7 @@ struct JITSetupFlow: View { } } - private func outcome(_ text: String, ok: Bool) -> some View { + private func outcome(_ text: LocalizedStringKey, ok: Bool) -> some View { Label(text, systemImage: ok ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") .font(.system(size: 14, weight: .medium)) .foregroundStyle(ok ? .green : .red) diff --git a/src/app/Husk/LibraryTab.swift b/src/app/Husk/LibraryTab.swift index f68e479b0..4b8265b69 100644 --- a/src/app/Husk/LibraryTab.swift +++ b/src/app/Husk/LibraryTab.swift @@ -57,8 +57,8 @@ struct LibraryTab: View { systemImage: "magnifyingglass") } else if host.packages.isEmpty && host.isReady { EmptyState(title: "No Apps Yet", - message: "Install an APK and it appears here. Split sets " - + "work too — pick every piece at once.", + message: localizedKey("Install an APK and it appears here. Split sets " + + "work too — pick every piece at once."), systemImage: "square.grid.2x2", actionTitle: "Install APK(s)", action: { importing = true }) @@ -113,22 +113,25 @@ struct LibraryTab: View { } } VStack(alignment: .leading, spacing: 4) { - Text(started ? "Starting Android" : "Android Is Not Running") + Text(localizedKey(started ? "Starting Android" : "Android Is Not Running")) .font(.subheadline.weight(.semibold)) if started, runner.bootProgress > 0 { ProgressView(value: Double(runner.bootProgress), total: 100) } else { - Text(started ? host.status + Text(localizedKey(started ? host.status : JITBootstrap.isDebuggerAttached ? "Your apps are here; start it to open them." - : "Needs JIT. StikJIT is built in — the recommended way.") + : "Needs JIT. StikJIT is built in — the recommended way.")) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } Spacer(minLength: 6) - Button(started ? "Show" : JITBootstrap.isDebuggerAttached ? "Start" : "Enable JIT") { + Button(localizedKey(started ? "Show" + : JITBootstrap.isDebuggerAttached + ? "Start" + : "Enable JIT")) { if started { onOpenGuest() } else { onStartAndroid() } } .buttonStyle(.borderedProminent) @@ -142,7 +145,7 @@ struct LibraryTab: View { private func busyStrip(_ text: String) -> some View { HStack(spacing: 12) { ProgressView() - Text(text).font(.subheadline).lineLimit(2) + Text(localizedKey(text)).font(.subheadline).lineLimit(2) Spacer(minLength: 0) } .padding(14) @@ -167,7 +170,7 @@ struct LibraryTab: View { return ["Game", "App", "Tool"].filter { set.contains($0) } } - private func plural(_ c: String) -> String { + private func plural(_ c: String) -> LocalizedStringKey { c == "Game" ? "Games" : c == "App" ? "Apps" : "Tools" } @@ -197,7 +200,7 @@ struct AppCard: View { .font(.footnote.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) - Text(app.category ?? app.bitness ?? " ") + Text(localizedKey(app.category ?? app.bitness ?? " ")) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/src/app/Husk/LibraryView.swift b/src/app/Husk/LibraryView.swift index fae45550e..49e1e509b 100644 --- a/src/app/Husk/LibraryView.swift +++ b/src/app/Husk/LibraryView.swift @@ -122,7 +122,7 @@ struct LibraryView: View { } .buttonStyle(.borderedProminent) if let msg = bridge.lastAgentMessage { - Text(msg) + Text(localizedKey(msg)) .font(.caption2).foregroundStyle(.orange) .multilineTextAlignment(.center).padding(.horizontal, 30) } diff --git a/src/app/Husk/Localizable.xcstrings b/src/app/Husk/Localizable.xcstrings new file mode 100644 index 000000000..f069edecb --- /dev/null +++ b/src/app/Husk/Localizable.xcstrings @@ -0,0 +1,5627 @@ +{ + "sourceLanguage": "en", + "strings": { + "%@ Built-in StikJIT needs iOS 26, LocalDevVPN, and a pairing file, which Husk can make itself on iOS 27.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@内置 StikJIT 需要 iOS 26、LocalDevVPN 和配对文件;在 iOS 27 上 Husk 可以自己生成配对文件。" + } + } + } + }, + "%@ Orientation and resolution apply the next time the game starts, and a game already running in this session needs Husk closed and opened again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@方向和分辨率会在游戏下次启动时生效;如果游戏已在本次会话中运行,需要先完全关闭 Husk 再打开。" + } + } + } + }, + "%@ is compressed inside its bundle, which Husk cannot unpack yet.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 在它的捆绑包内是压缩的,Husk 目前无法解压。" + } + } + } + }, + "%@ is gone from Android.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已从 Android 中移除。" + } + } + } + }, + "%@ is ready to launch.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已可以启动。" + } + } + } + }, + "%@ of %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ / %@" + } + } + } + }, + "%@ was started in this session, and a game cannot be unloaded once it has started. Close Husk completely and open it again to run %@.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已在本次会话中启动过,而游戏一旦启动就无法卸载。请完全关闭 Husk 再打开,即可运行 %@。" + } + } + } + }, + "%@, %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@,%@" + } + } + } + }, + "%@. Open the Android screen and check it is running, then try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@。请打开 Android 屏幕确认它仍在运行,然后重试。" + } + } + } + }, + "%lld": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld" + } + } + } + }, + "%lld apps": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 个应用" + } + } + } + }, + "%lld controllers": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 个控制器" + } + } + } + }, + "%lld files": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 个文件" + } + } + } + }, + "%lld files sent": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已发送 %lld 个文件" + } + } + } + }, + "%lld min · first boot takes 5–15 min": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 分钟 · 首次启动需要 5–15 分钟" + } + } + } + }, + "%lld of them use tricks the older loader could not handle; the native runtime handles those too, except for optional anti-tamper code, which it leaves out.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其中 %lld 个使用了旧加载器无法处理的技巧;原生运行时也能处理,只是会略过可选的反篡改代码。" + } + } + } + }, + "%lld%%": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld%%" + } + } + } + }, + "%lld%% · %@ · %@ elapsed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld%% · %@ · 已用 %@" + } + } + } + }, + "%llu frame(s) drawn · native runtime": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已绘制 %llu 帧 · 原生运行时" + } + } + } + }, + "1 file": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 个文件" + } + } + } + }, + "1 pixel per point. Fastest.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每点 1 像素。最快。" + } + } + } + }, + "1.5 pixels per point.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每点 1.5 像素。" + } + } + } + }, + "1280": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1280" + } + } + } + }, + "2 pixels per point. The default.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每点 2 像素。默认值。" + } + } + } + }, + "720": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "720" + } + } + } + }, + "A %@ game. It runs through Husk's native runtime, which loads its %lld arm64 libraries itself.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这是一个 %@ 游戏。它通过 Husk 的原生运行时运行,由运行时自行加载它的 %lld 个 arm64 库。" + } + } + } + }, + "A change takes effect the next time Husk opens.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更改将在下次打开 Husk 时生效。" + } + } + } + }, + "A custom resolution sets the shape itself, so this does nothing while it is on. Type a wide size for landscape.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义分辨率本身就已决定屏幕形状,因此开启它时这一项不起作用。横屏请填写较宽的尺寸。" + } + } + } + }, + "A new Android image is available": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有新的 Android 系统镜像可用" + } + } + } + }, + "A new pre-booted snapshot is available": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有新的预启动快照可用" + } + } + } + }, + "APK installed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "APK 已安装" + } + } + } + }, + "APK or bundle": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "APK 或捆绑包" + } + } + } + }, + "APKs install from the Library's + button or from the Files tab. Split sets — a base APK plus its config pieces — must be picked together; installing the base alone fails on missing native libraries.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "APK 可以从应用库的 + 按钮或「文件」标签页安装。分包 APK——基础 APK 加上它的配置分片——必须一起选择;只安装基础 APK 会因为缺少原生库而失败。" + } + } + } + }, + "ATTEMPT LOG": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尝试日志" + } + } + } + }, + "About": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关于" + } + } + } + }, + "Accent Colour": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "强调色" + } + } + } + }, + "Active Repositories": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已启用的仓库" + } + } + } + }, + "Add": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + } + } + }, + "Add APK": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加 APK" + } + } + } + }, + "Add App": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加应用" + } + } + } + }, + "Add Source": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加源" + } + } + } + }, + "Add an APK and it will be installed into the Android runtime, then appear here with its own icon.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加一个 APK,它会被安装到 Android 运行环境中,然后带着自己的图标出现在这里。" + } + } + } + }, + "Adding %@…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在添加 %@…" + } + } + } + }, + "Adding %lld APKs…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在添加 %lld 个 APK…" + } + } + } + }, + "Adds a sound device. Android cannot be saved while this is on, so every launch boots from cold.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加一个声音设备。开启后无法保存 Android,因此每次启动都是冷启动。" + } + } + } + }, + "Adds a sound device. While it is attached Android cannot be saved — QEMU refuses to snapshot a machine with one — so every launch boots from cold. Turning it on or off costs a cold boot either way.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加一个声音设备。只要它处于附加状态,Android 就无法保存——QEMU 拒绝对带有声音设备的机器做快照——所以每次启动都是冷启动。无论开启还是关闭,代价都是一次冷启动。" + } + } + } + }, + "Adds about 2 GB to the first download. It was captured on the software renderer, so it is not used on GPU — which cold-boots once and then saves its own.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "首次下载会多出约 2 GB。该快照是在软件渲染器上采集的,因此在 GPU 模式下不会使用——GPU 模式会先冷启动一次,然后保存自己的快照。" + } + } + } + }, + "All": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + } + } + }, + "Almost worth the wait": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "差不多值得等" + } + } + } + }, + "Always": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终" + } + } + } + }, + "Always this look.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终保持这一外观。" + } + } + } + }, + "Android Is Not Running": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 未在运行" + } + } + } + }, + "Android and Translation Layer games need JIT, which on iOS only an attached debugger can grant. StikJIT is built into Husk and is the recommended way: it turns JIT on from inside the app, with no computer and no other app. StikDebug and TrollStore work too.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 和翻译层游戏都需要 JIT,而在 iOS 上只有附加的调试器才能授予它。StikJIT 已内置于 Husk,也是推荐方式:它无需电脑、无需其他应用,直接在应用内开启 JIT。StikDebug 和 TrollStore 同样可行。" + } + } + } + }, + "Android and Translation Layer games need JIT. StikJIT, built into Husk, turns it on without a computer.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 和翻译层游戏需要 JIT。内置于 Husk 的 StikJIT 无需电脑即可开启。" + } + } + } + }, + "Android apps, on your iPhone": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 iPhone 上运行 Android 应用" + } + } + } + }, + "Android apps, on your iPhone.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 iPhone 上运行 Android 应用。" + } + } + } + }, + "Android cannot reshape a screen once it is running, so a landscape game on a portrait screen gets letterboxed into a band and looks tiny. Creating it landscape is the only way it can fill it — portrait apps are letterboxed instead. Costs one cold boot.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 一旦运行就无法改变屏幕形状,因此横屏游戏在竖屏上会被压成一条加黑边的窄带,看起来很小。只有开局就建成横屏才能填满屏幕——反过来竖屏应用会被加黑边。代价是一次冷启动。" + } + } + } + }, + "Android draws on the real GPU through Metal — about four times the frame rate. This is the default.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 通过 Metal 在真实 GPU 上绘制——帧率约为四倍。这是默认设置。" + } + } + } + }, + "Android installs it from where it already is — nothing is copied.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 会就地安装它——不会复制任何内容。" + } + } + } + }, + "Android is booting…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 正在启动…" + } + } + } + }, + "Android is locked. Open the Android screen, unlock it, and try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 已锁定。请打开 Android 屏幕解锁后重试。" + } + } + } + }, + "Android is locked. Unlock it and try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 已锁定。请解锁后重试。" + } + } + } + }, + "Android is ready": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 已就绪" + } + } + } + }, + "Android libraries it needs": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "它需要的 Android 库" + } + } + } + }, + "Android reaches the internet through a virtual ethernet card on QEMU's own network. Nothing on your phone's network can see the guest, and the guest cannot see it.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 通过 QEMU 自有网络上的虚拟网卡访问互联网。你手机所在的网络看不到客户机,客户机也看不到它。" + } + } + } + }, + "Android will boot from cold once, then save a new one.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 会先冷启动一次,然后保存一个新的快照。" + } + } + } + }, + "Android's native code expects things of the processor and of memory that only the phone can answer -- above all, whether a library's code can run with its data writable right beside it. Enable JIT first, or the memory checks are skipped. The results go to the console too.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 的原生代码对处理器和内存有特定要求,只有这台手机能给出答案——其中最要紧的是,一个库的代码能否在紧邻其可写数据的情况下运行。请先启用 JIT,否则会跳过内存检查。结果也会写入控制台。" + } + } + } + }, + "Android: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android:%@" + } + } + } + }, + "Another game is already loaded": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已加载另一个游戏" + } + } + } + }, + "App Icon": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用图标" + } + } + } + }, + "App Info": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用信息" + } + } + } + }, + "App by Levi": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "作者 Levi" + } + } + } + }, + "Appearance": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外观" + } + } + } + }, + "Apps": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用" + } + } + } + }, + "Attach the sound device": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "附加声音设备" + } + } + } + }, + "Automatic": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + } + } + }, + "Automatic follows the system appearance — light, dark and tinted. The others pin one look. iOS shows its own confirmation after a change; that alert cannot be turned off.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "「自动」跟随系统外观——浅色、深色和色调化。其余选项会固定一种外观。更改后 iOS 会显示自己的确认提示;该提示无法关闭。" + } + } + } + }, + "Automatic offers the controller for games that cannot be played without one (Unreal Engine games), when no real controller is connected. Always offers it for any game that understands one. A paired controller is always used.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "「自动」会在没有连接实体控制器时,为必须用控制器的游戏(虚幻引擎游戏)提供屏幕控制器;对任何识别控制器的游戏则始终提供。已配对的控制器始终优先生效。" + } + } + } + }, + "Back to Husk": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "返回 Husk" + } + } + } + }, + "Boots the guest as soon as Husk opens, once JIT is available. Off means you start it yourself.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "JIT 可用时,Husk 一打开就启动客户机。关闭则需你手动启动。" + } + } + } + }, + "Boots the guest as soon as Husk opens, when JIT is available.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "JIT 可用时,Husk 一打开就启动客户机。" + } + } + } + }, + "Both sides must be between 240 and 2560.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "两边都必须介于 240 和 2560 之间。" + } + } + } + }, + "Browse Files": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览文件" + } + } + } + }, + "Build": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "构建" + } + } + } + }, + "Built-in JIT is available only on a physical device.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置 JIT 仅在真机上可用。" + } + } + } + }, + "Built-in JIT is unavailable inside LiveContainer. Use StikDebug instead.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 LiveContainer 内无法使用内置 JIT,请改用 StikDebug。" + } + } + } + }, + "Built-in JIT is unavailable.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置 JIT 不可用。" + } + } + } + }, + "Built-in JIT needs iOS 26 or later. Use StikDebug instead.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置 JIT 需要 iOS 26 或更高版本,请改用 StikDebug。" + } + } + } + }, + "Built-in StikJIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置 StikJIT" + } + } + } + }, + "Built-in StikJIT needs LocalDevVPN and a pairing file, which Husk can make itself on iOS 27.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置 StikJIT 需要 LocalDevVPN 和配对文件;在 iOS 27 上 Husk 可以自己生成配对文件。" + } + } + } + }, + "CPU": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + } + } + }, + "Cancel": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "Cancel pairing": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消配对" + } + } + } + }, + "Cannot load": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载" + } + } + } + }, + "Change JIT Setup": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更改 JIT 设置" + } + } + } + }, + "Check setup": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查设置" + } + } + } + }, + "Checking LocalDevVPN and the Developer Disk Image…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查 LocalDevVPN 和开发者磁盘映像…" + } + } + } + }, + "Checking…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查…" + } + } + } + }, + "Choose another file": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择其他文件" + } + } + } + }, + "Choose pairing file": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择配对文件" + } + } + } + }, + "Clear Dark": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "透明深色" + } + } + } + }, + "Clear Light": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "透明浅色" + } + } + } + }, + "Close": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + } + } + }, + "Connect LocalDevVPN": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接 LocalDevVPN" + } + } + } + }, + "Console": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制台" + } + } + } + }, + "Continue": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续" + } + } + } + }, + "Continue with the current pairing": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "沿用当前配对继续" + } + } + } + }, + "Controller": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制器" + } + } + } + }, + "Controls": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制" + } + } + } + }, + "Copy": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拷贝" + } + } + } + }, + "Copy Package Name": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拷贝包名" + } + } + } + }, + "Copying %@ — %lld%%": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在拷贝 %@ — %lld%%" + } + } + } + }, + "Could Not Load": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载" + } + } + } + }, + "Could not add %@: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法添加 %@:%@" + } + } + } + }, + "Could not add the app": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法添加该应用" + } + } + } + }, + "Could not send %@: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法发送 %@:%@" + } + } + } + }, + "Could not start — see the log": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法启动——请查看日志" + } + } + } + }, + "Could not uninstall": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法卸载" + } + } + } + }, + "Could not unpack the snapshot: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法解包快照:%@" + } + } + } + }, + "Currently saved: %@.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前已保存:%@。" + } + } + } + }, + "Custom": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义" + } + } + } + }, + "Custom Colour": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义颜色" + } + } + } + }, + "Custom resolution": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义分辨率" + } + } + } + }, + "Dark": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + } + } + }, + "Data": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "数据" + } + } + } + }, + "Delete saved machine": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除已保存的机器" + } + } + } + }, + "Deleted the %@ machine. The next launch boots from cold.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已删除 %@ 机器。下次启动将冷启动。" + } + } + } + }, + "Deletes Husk's copy of the APKs. Anything installed in Android is untouched.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除 Husk 持有的 APK 副本。已安装进 Android 的内容不受影响。" + } + } + } + }, + "Deletes what the game saved and downloaded here: saves, settings and caches. The game itself stays.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除游戏在此保存和下载的所有内容:存档、设置和缓存。游戏本身会保留。" + } + } + } + }, + "Details": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详情" + } + } + } + }, + "Developer Info": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开发者信息" + } + } + } + }, + "Discover": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发现" + } + } + } + }, + "Display": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示" + } + } + } + }, + "Done": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + } + } + }, + "Download": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + } + } + }, + "Download Android runtime": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载 Android 运行环境" + } + } + } + }, + "Download did not produce a disk image: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载没有得到磁盘映像:%@" + } + } + } + }, + "Download pre-booted snapshot": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载预启动快照" + } + } + } + }, + "Downloading Android runtime": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载 Android 运行环境" + } + } + } + }, + "Downloading it replaces the Android system and its pre-booted snapshot (%@), and with them everything inside Android — installed apps included. A snapshot only restores against the image it was saved on, so the two cannot be updated separately.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载它会替换 Android 系统及其预启动快照(%@),同时替换 Android 内的全部内容——包括已安装的应用。快照只能在与保存它时相同的镜像上恢复,因此两者无法分开更新。" + } + } + } + }, + "Downloading pre-booted Android": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载预启动的 Android" + } + } + } + }, + "Emulator": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模拟器" + } + } + } + }, + "Enable JIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用 JIT" + } + } + } + }, + "Enable JIT with %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用 %@ 启用 JIT" + } + } + } + }, + "Enable JIT with TrollStore": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用 TrollStore 启用 JIT" + } + } + } + }, + "English": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "English" + } + } + } + }, + "Enter it on this %@ to pair with Husk": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在这台 %@ 上输入它与 Husk 配对" + } + } + } + }, + "Enter this code on your %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在你的 %@ 上输入此代码" + } + } + } + }, + "Enter this code on your %@ to finish pairing.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在你的 %@ 上输入此代码以完成配对。" + } + } + } + }, + "Every pixel is drawn by the emulated CPU. Much slower, and only worth choosing if the GPU misbehaves.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个像素都由模拟的 CPU 绘制。慢得多,只有在 GPU 出问题时才值得选择。" + } + } + } + }, + "Every pixel the screen has. Sharpest, and heaviest.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕的全部像素。最清晰,也最吃性能。" + } + } + } + }, + "Fetching Repositories…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在获取仓库…" + } + } + } + }, + "File sent": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件已发送" + } + } + } + }, + "Files": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件" + } + } + } + }, + "First run downloads Android and can take several minutes.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "首次运行需要下载 Android,可能要几分钟。" + } + } + } + }, + "First time? See the setup, or use StikDebug or TrollStore instead": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "第一次使用?查看设置向导,或改用 StikDebug、TrollStore" + } + } + } + }, + "Follows the system: light, dark and tinted.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跟随系统:浅色、深色和色调化。" + } + } + } + }, + "Full HD — 1080 × 1920": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全高清 — 1080 × 1920" + } + } + } + }, + "GPU": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GPU" + } + } + } + }, + "GPU machine": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GPU 机器" + } + } + } + }, + "Games": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏" + } + } + } + }, + "Games and Android can run.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏和 Android 都可以运行了。" + } + } + } + }, + "General": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通用" + } + } + } + }, + "Get": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "获取" + } + } + } + }, + "Get LocalDevVPN": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "获取 LocalDevVPN" + } + } + } + }, + "Gives Android a landscape screen, which games fill properly. Portrait apps get letterboxed instead.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "给 Android 一块横屏,游戏便能完整填满。竖屏应用则会被加黑边。" + } + } + } + }, + "Guest image": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "客户机镜像" + } + } + } + }, + "HD — 720 × 1280": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高清 — 720 × 1280" + } + } + } + }, + "HUSK": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "HUSK" + } + } + } + }, + "Height": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高度" + } + } + } + }, + "Hide": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏" + } + } + } + }, + "Hide log": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏日志" + } + } + } + }, + "Hide pad": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏手柄" + } + } + } + }, + "Hide the interface": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏界面" + } + } + } + }, + "High": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高" + } + } + } + }, + "Home": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主屏幕" + } + } + } + }, + "How it connects": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接方式" + } + } + } + }, + "How should Husk behave?": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你希望 Husk 如何工作?" + } + } + } + }, + "How to Allow JIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如何允许 JIT" + } + } + } + }, + "Husk": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk" + } + } + } + }, + "Husk could not announce itself on the local network. Check that Wi-Fi is on, then try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 无法在本地网络上广播自己。请确认 Wi-Fi 已开启,然后重试。" + } + } + } + }, + "Husk could not copy it: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 无法复制它:%@" + } + } + } + }, + "Husk could not open its pairing port.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 无法打开配对端口。" + } + } + } + }, + "Husk couldn't reach this device. Connect LocalDevVPN, then try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 联系不上这台设备。请连接 LocalDevVPN 后重试。" + } + } + } + }, + "Husk needs Local Network access to pair. Allow it in Settings › Apps › Husk, then try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 需要本地网络权限才能配对。请在「设置 › 应用 › Husk」中允许,然后重试。" + } + } + } + }, + "Husk needs its Android runtime — about 760 MB. Android itself is downloaded afterwards by the runtime.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 需要它的 Android 运行环境——约 760 MB。Android 本身随后由运行环境下载。" + } + } + } + }, + "Husk needs memory it can write and then execute, which on iOS takes an attached debugger. There are two ways to get it: a debugger that services trap requests, or a MAP_JIT mapping, which the kernel allows any debugged process. Either one is enough — which is available depends on the device and the iOS version, so Husk tests both rather than assuming.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 需要既能写入又能执行的内存,而在 iOS 上这需要附加调试器。有两条路可走:由调试器响应陷阱请求,或使用 MAP_JIT 映射——内核对任何被调试的进程都允许它。两者有其一即可;哪一条可用取决于设备和 iOS 版本,因此 Husk 会实际测试而不是想当然。" + } + } + } + }, + "Husk pairing code: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 配对码:%@" + } + } + } + }, + "Husk restores a saved machine instead of booting it, which takes seconds rather than minutes. The picture freezes while it writes. With this off, nothing saves by itself — including after an install.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 会恢复已保存的机器而不是重新启动,只需几秒而不是几分钟。写入期间画面会冻结。关闭此项后不会有任何自动保存——安装之后也不会。" + } + } + } + }, + "Husk runs a real Android system and opens APKs inside it. A few questions first — all of them can be changed later in Settings.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 运行一个真正的 Android 系统,并在其中打开 APK。先问几个问题——全都可以稍后在「设置」中更改。" + } + } + } + }, + "Husk runs unmodified Android APKs in a real Android system on your iPhone. The console shows Husk's live log, the guest's serial output and QEMU's own output — the three files any problem here is diagnosed from.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 在你的 iPhone 上运行真正的 Android 系统,并直接运行未经修改的 APK。控制台显示 Husk 的实时日志、客户机的串口输出和 QEMU 自身的输出——这里出现的任何问题都要靠这三个文件来诊断。" + } + } + } + }, + "Husk turns the screen the way the game asks for.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 按游戏的要求旋转屏幕。" + } + } + } + }, + "Husk was marked as debugged when it opened, so JIT is on.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 启动时已被标记为已调试,因此 JIT 已开启。" + } + } + } + }, + "Husk will use StikDebug.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 将使用 StikDebug。" + } + } + } + }, + "Husk will use TrollStore.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 将使用 TrollStore。" + } + } + } + }, + "Husk's JIT helper (%@) could not be found: %@ Reinstall Husk.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找不到 Husk 的 JIT 辅助程序(%@):%@ 请重新安装 Husk。" + } + } + } + }, + "Husk's JIT helper did not start. Reinstall Husk, and keep its app extensions if your sideloader asks.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 的 JIT 辅助程序没有启动。请重新安装 Husk;如果你的侧载工具询问,请保留它的应用扩展。" + } + } + } + }, + "Husk's JIT helper finished without an answer.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 的 JIT 辅助程序结束时没有返回结果。" + } + } + } + }, + "Husk's JIT helper is missing from this installation. Reinstall Husk, and keep its app extensions if your sideloader asks, or use StikDebug.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这次安装中缺少 Husk 的 JIT 辅助程序。请重新安装 Husk;如果你的侧载工具询问,请保留它的应用扩展,或改用 StikDebug。" + } + } + } + }, + "Husk's JIT helper stopped unexpectedly. Try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 的 JIT 辅助程序意外停止。请重试。" + } + } + } + }, + "Husk's JIT helper stopped: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 的 JIT 辅助程序已停止:%@" + } + } + } + }, + "Husk's JIT script is missing from this installation. Reinstall Husk.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这次安装中缺少 Husk 的 JIT 脚本。请重新安装 Husk。" + } + } + } + }, + "If JIT is not on when Android starts, Husk turns it on with the method you chose, or walks you through setting one up. You can change it any time in Settings › JIT & sideload.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果 Android 启动时 JIT 尚未开启,Husk 会用你选择的方式开启它,或带你完成设置。你可以随时在「设置 › JIT 与侧载」中更改。" + } + } + } + }, + "Import": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入" + } + } + } + }, + "Import Files": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入文件" + } + } + } + }, + "Import a new pairing file": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入新的配对文件" + } + } + } + }, + "In Android's %@ folder.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已放入 Android 的 %@ 文件夹。" + } + } + } + }, + "Input": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入" + } + } + } + }, + "Install": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装" + } + } + } + }, + "Install %@?": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装 %@?" + } + } + } + }, + "Install APK": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装 APK" + } + } + } + }, + "Install an APK and it appears here. Split sets work too — pick every piece at once.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装一个 APK,它就会出现在这里。分包 APK 也可以——请一次选中所有分片。" + } + } + } + }, + "Install failed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装失败" + } + } + } + }, + "Install failed: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装失败:%@" + } + } + } + }, + "Installed, but not saved": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装,但未保存" + } + } + } + }, + "Installing %@…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在安装 %@…" + } + } + } + }, + "Installing…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在安装…" + } + } + } + }, + "Invalid URL": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效的 URL" + } + } + } + }, + "It is a landscape game: Husk turns the screen for it.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这是横屏游戏:Husk 会为它旋转屏幕。" + } + } + } + }, + "It opens as soon as Android answers.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android 一有响应它就会打开。" + } + } + } + }, + "It will be gone on the next launch.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下次启动时它就会消失。" + } + } + } + }, + "It's connected": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已连接" + } + } + } + }, + "Its data goes with it. Save Android afterwards or the change is lost on the next launch.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "它的数据会一并删除。之后请保存 Android,否则这次更改会在下次启动时丢失。" + } + } + } + }, + "Its own libraries link against these, and it does not carry them. Each is something the translation layer has to provide.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "它自己的库依赖这些库,而它并不自带。每一个都需要翻译层来提供。" + } + } + } + }, + "JIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "JIT" + } + } + } + }, + "JIT & sideload": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "JIT 与侧载" + } + } + } + }, + "JIT allowed for apps": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已允许应用使用 JIT" + } + } + } + }, + "JIT is on": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "JIT 已开启" + } + } + } + }, + "JIT is on.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "JIT 已开启。" + } + } + } + }, + "Jailbreak (Dopamine)": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "越狱(Dopamine)" + } + } + } + }, + "Keep debugger attached": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保持调试器附加" + } + } + } + }, + "Keep the network across saves": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存时保留网络" + } + } + } + }, + "Keep the screen on": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保持屏幕常亮" + } + } + } + }, + "Landscape": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "横屏" + } + } + } + }, + "Landscape HD — 1280 × 720": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "横屏高清 — 1280 × 720" + } + } + } + }, + "Language": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言" + } + } + } + }, + "Last Used": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上次使用" + } + } + } + }, + "Launch": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动" + } + } + } + }, + "Library": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用库" + } + } + } + }, + "Light": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色" + } + } + } + }, + "Loaded: Native libraries loaded (no draw)": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已加载:原生库加载成功(没有绘制画面)" + } + } + } + }, + "Loading libraries and starting the engine": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载库并启动引擎" + } + } + } + }, + "Loading the engine…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载引擎…" + } + } + } + }, + "Loads arm64 native code into JIT memory on Apple Silicon and drives a NativeActivity lifecycle. Apps with Java/Dex require ART (milestone 2).": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Apple 芯片上把 arm64 原生代码加载进 JIT 内存,并驱动 NativeActivity 生命周期。含 Java/Dex 的应用需要 ART(第二阶段)。" + } + } + } + }, + "Log": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日志" + } + } + } + }, + "Logs": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日志" + } + } + } + }, + "Low": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低" + } + } + } + }, + "Manage Repositories": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理仓库" + } + } + } + }, + "Maps as it is": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可原样映射" + } + } + } + }, + "Medium": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中" + } + } + } + }, + "Method": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "方式" + } + } + } + }, + "Name": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "名称" + } + } + } + }, + "Names and icons come from Android's own launcher, which keeps the version it draws. Re-fetching throws away Husk's copies and asks again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "名称和图标来自 Android 自己的桌面,而桌面会保留它绘制过的那一版。重新获取会丢弃 Husk 的副本并再问一次。" + } + } + } + }, + "Native": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原生" + } + } + } + }, + "Native runtime": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原生运行时" + } + } + } + }, + "Nearly there": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "就快好了" + } + } + } + }, + "Needs JIT. StikJIT is built in — the recommended way.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要 JIT。StikJIT 已内置——这是推荐方式。" + } + } + } + }, + "Needs loader work": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要加载器处理" + } + } + } + }, + "Negotiating with the JIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在与 JIT 交涉" + } + } + } + }, + "Network": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络" + } + } + } + }, + "Never": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从不" + } + } + } + }, + "Never from Husk": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从未通过 Husk" + } + } + } + }, + "No %@ machine is saved, so nothing was deleted.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有保存 %@ 机器,因此未删除任何内容。" + } + } + } + }, + "No apps yet": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还没有应用" + } + } + } + }, + "No jailbreak was found on this %@.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在这台 %@ 上未发现越狱。" + } + } + } + }, + "No, it has not frozen": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有卡死,真的" + } + } + } + }, + "Not now": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以后再说" + } + } + } + }, + "Not started": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未启动" + } + } + } + }, + "Not yet": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂不支持" + } + } + } + }, + "Nothing is saved, so Android boots from cold.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有已保存的内容,Android 将冷启动。" + } + } + } + }, + "Now": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前" + } + } + } + }, + "OK": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "好" + } + } + } + }, + "Off by default. Husk detaches StikDebug as soon as the JIT region is held, because a debugger that iOS has suspended stops the whole app the next time it is needed. Turn this on only to collect StikDebug's own logs.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认关闭。一旦 JIT 区域到手,Husk 就会分离 StikDebug,因为被 iOS 挂起的调试器会在下次需要它时拖住整个应用。只有需要收集 StikDebug 自己的日志时才开启。" + } + } + } + }, + "On a jailbroken device JIT is a setting. In Dopamine, turn on Allow JIT in Apps, then open Husk again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在已越狱的设备上,JIT 只是一项设置。请在 Dopamine 中打开「允许应用使用 JIT」,然后重新打开 Husk。" + } + } + } + }, + "On-screen controller": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕控制器" + } + } + } + }, + "Opacity": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不透明度" + } + } + } + }, + "Open": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开" + } + } + } + }, + "Open Console": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开控制台" + } + } + } + }, + "Open Settings": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开设置" + } + } + } + }, + "Open in Files": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在「文件」中打开" + } + } + } + }, + "Opening…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在打开…" + } + } + } + }, + "Orientation": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "方向" + } + } + } + }, + "Other Ways to Turn On JIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其他开启 JIT 的方式" + } + } + } + }, + "Pad": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手柄" + } + } + } + }, + "Pair again": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新配对" + } + } + } + }, + "Pair this device or import its pairing file first.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先与这台设备配对,或导入它的配对文件。" + } + } + } + }, + "Paired on this %@.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已在这台 %@ 上配对。" + } + } + } + }, + "Paired on this device.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已在本机配对。" + } + } + } + }, + "Paired with Husk": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已与 Husk 配对" + } + } + } + }, + "Pairing code %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对码 %@" + } + } + } + }, + "Pairing failed. Try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对失败。请重试。" + } + } + } + }, + "Pairing failed: %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对失败:%@" + } + } + } + }, + "Pairing file imported.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对文件已导入。" + } + } + } + }, + "Pairing finished, but Husk could not save the pairing file.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对已完成,但 Husk 无法保存配对文件。" + } + } + } + }, + "Pairing timed out. Try again when you are ready to pair from Settings.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对超时。等你可以从「设置」配对时再试一次。" + } + } + } + }, + "Pairing with Husk": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在与 Husk 配对" + } + } + } + }, + "Paste any F-Droid-compatible repository index URL.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "粘贴任意兼容 F-Droid 的仓库索引 URL。" + } + } + } + }, + "Performance": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "性能" + } + } + } + }, + "Performance readout": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "性能读数" + } + } + } + }, + "Play": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始游戏" + } + } + } + }, + "Portrait": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "竖屏" + } + } + } + }, + "Prepare for awesomeness": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "准备好大吃一惊" + } + } + } + }, + "Preset": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预设" + } + } + } + }, + "Presets": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预设" + } + } + } + }, + "Re-fetch icons": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新获取图标" + } + } + } + }, + "Ready": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "就绪" + } + } + } + }, + "Ready to launch.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可以启动了。" + } + } + } + }, + "Reducing render size…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在降低渲染尺寸…" + } + } + } + }, + "Refresh": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + } + } + }, + "Refresh from Android": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从 Android 刷新" + } + } + } + }, + "Refreshing…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在刷新…" + } + } + } + }, + "Refused: Could not execute": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "被拒绝:无法执行" + } + } + } + }, + "Remove": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "Remove %@?": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除 %@?" + } + } + } + }, + "Removing %@…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在移除 %@…" + } + } + } + }, + "Renderer": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "渲染器" + } + } + } + }, + "Repositories": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仓库" + } + } + } + }, + "Rerun": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新运行" + } + } + } + }, + "Reset Developer Disk Image": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置开发者磁盘映像" + } + } + } + }, + "Reset Game Data": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置游戏数据" + } + } + } + }, + "Reset the data of %@?": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置 %@ 的数据?" + } + } + } + }, + "Resetting the Developer Disk Image cache…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在重置开发者磁盘映像缓存…" + } + } + } + }, + "Resolution": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分辨率" + } + } + } + }, + "Restart Husk to see the change.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新打开 Husk 即可看到更改。" + } + } + } + }, + "Restoring full resolution…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在恢复完整分辨率…" + } + } + } + }, + "Return to Husk to finish setting up JIT.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "返回 Husk 以完成 JIT 设置。" + } + } + } + }, + "Rotate picture": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "旋转画面" + } + } + } + }, + "Run Translation Layer Attempt": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行翻译层尝试" + } + } + } + }, + "Run again": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "再运行一次" + } + } + } + }, + "Run checks": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行检查" + } + } + } + }, + "Running": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行中" + } + } + } + }, + "Running Attempt...": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在运行尝试…" + } + } + } + }, + "Runs Android games straight on your iPhone, without starting Android. Add an APK, or a bundle (.xapk, .apkm, .apks) — or pick a base APK and its split pieces together. Husk keeps its own copy, apart from Android's.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无需启动 Android,直接在 iPhone 上运行 Android 游戏。添加一个 APK,或一个捆绑包(.xapk、.apkm、.apks)——也可以一次选中基础 APK 及其分片。Husk 会保留自己的副本,与 Android 的分开。" + } + } + } + }, + "Save Android": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存 Android" + } + } + } + }, + "Save Android to keep it past this session.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存 Android,它才能留存到本次会话之后。" + } + } + } + }, + "Save automatically": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动保存" + } + } + } + }, + "Save now": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即保存" + } + } + } + }, + "Saved machine": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保存的机器" + } + } + } + }, + "Saves the machine once Android settles, so later launches restore in seconds instead of booting.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等 Android 稳定后保存机器,之后启动只需几秒恢复,而无需重新引导。" + } + } + } + }, + "Saves, settings and caches the game made here are deleted.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏在此产生的存档、设置和缓存都会被删除。" + } + } + } + }, + "Saving Android — the screen will freeze briefly": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在保存 Android — 画面会短暂冻结" + } + } + } + }, + "Saving closes apps but leaves Android's framework running, so the network still works after a restore.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存会关闭应用,但让 Android 框架继续运行,因此恢复后网络仍然可用。" + } + } + } + }, + "Saving stops the framework too. Clears every GPU resource, which is steadier — but the network may not come back until a cold boot.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存时连框架也一并停止。这会清空所有 GPU 资源,因此更稳定——但网络可能要等到冷启动才会恢复。" + } + } + } + }, + "Saving…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在保存…" + } + } + } + }, + "Screen": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕" + } + } + } + }, + "Screenshots": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "截图" + } + } + } + }, + "Search %lld apps": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索 %lld 个应用" + } + } + } + }, + "Search apps": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索应用" + } + } + } + }, + "Search to see %lld more apps.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索可查看另外 %lld 个应用。" + } + } + } + }, + "Sending %@ — %lld%%": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在发送 %@ — %lld%%" + } + } + } + }, + "Sending %@…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在发送 %@…" + } + } + } + }, + "Set Up StikJIT Now": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即设置 StikJIT" + } + } + } + }, + "Set up JIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置 JIT" + } + } + } + }, + "Settings": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置" + } + } + } + }, + "Settings › Privacy & Security › Developer Mode": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置 › 隐私与安全性 › 开发者模式" + } + } + } + }, + "Show": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示" + } + } + } + }, + "Show Android": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示 Android" + } + } + } + }, + "Show in Android Settings": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Android 设置中显示" + } + } + } + }, + "Sideloading": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "侧载" + } + } + } + }, + "Size": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "大小" + } + } + } + }, + "Small — 360 × 800": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "小 — 360 × 800" + } + } + } + }, + "Software machine": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "软件机器" + } + } + } + }, + "Something went wrong": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "出了点问题" + } + } + } + }, + "Sound": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "声音" + } + } + } + }, + "Source URL": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "源 URL" + } + } + } + }, + "Star the repo if you like this sort of thing": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "喜欢的话就给仓库点个星" + } + } + } + }, + "Start": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动" + } + } + } + }, + "Start Android on launch": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动时开启 Android" + } + } + } + }, + "Start pairing": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始配对" + } + } + } + }, + "Start using Husk": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始使用 Husk" + } + } + } + }, + "Starting": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动" + } + } + } + }, + "Starting Android": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动 Android" + } + } + } + }, + "Starting Android…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动 Android…" + } + } + } + }, + "Starting Husk's JIT helper…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动 Husk 的 JIT 辅助程序…" + } + } + } + }, + "Starting attempt...": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在开始尝试…" + } + } + } + }, + "Starting…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动…" + } + } + } + }, + "StikDebug": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "StikDebug" + } + } + } + }, + "StikDebug is installed, so Husk will open it to turn JIT on.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "StikDebug 已安装,Husk 会打开它来开启 JIT。" + } + } + } + }, + "StikDebug is installed, so Husk will open it.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "StikDebug 已安装,Husk 会打开它。" + } + } + } + }, + "StikDebug is not installed. Install it, or set up Built-in StikJIT.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未安装 StikDebug。请安装它,或设置内置 StikJIT。" + } + } + } + }, + "StikDebug, TrollStore and a jailbreak were not found, so Husk will use its built-in helper.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到 StikDebug、TrollStore 和越狱,因此 Husk 将使用内置辅助程序。" + } + } + } + }, + "StikJIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "StikJIT" + } + } + } + }, + "StikJIT is built into Husk. You can turn JIT on right here — no computer and no other app. Games and Android both need it.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "StikJIT 已内置于 Husk。你可以在这里直接开启 JIT——无需电脑,也无需其他应用。游戏和 Android 都需要它。" + } + } + } + }, + "Stop": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } + } + }, + "Storage": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "存储" + } + } + } + }, + "Success: The guest drew frames!": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "成功:客户机绘制出了画面!" + } + } + } + }, + "System": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跟随系统" + } + } + } + }, + "System follows the phone: light by day, dark by night. The guest's own screen stays dark either way — it is a picture of another phone.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "「跟随系统」随手机变化:白天浅色,夜晚深色。无论哪种,客户机自己的屏幕都保持深色——那只是另一台手机的画面。" + } + } + } + }, + "Tablet — 1280 × 800": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "平板 — 1280 × 800" + } + } + } + }, + "Tap with three fingers to show the interface": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "三指轻点可显示界面" + } + } + } + }, + "Teaching an iPhone to speak Android": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "教 iPhone 说 Android 语" + } + } + } + }, + "Technical detail in the Android Translation Layer screens: library reports, device checks and run logs.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "翻译层各界面中的技术细节:库报告、设备检查和运行日志。" + } + } + } + }, + "That bundle uses a zip format (Zip64) Husk does not read yet.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该捆绑包使用了 Husk 尚不支持的 zip 格式(Zip64)。" + } + } + } + }, + "That file is not a bundle Husk can read.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该文件不是 Husk 能读取的捆绑包。" + } + } + } + }, + "That is not a remote pairing file. Make one with the StikDebug pairing-file guide and try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这不是远程配对文件。请按 StikDebug 的配对文件指南生成一个,然后重试。" + } + } + } + }, + "The debugger is attached but is not answering trap requests, and this device will not execute a MAP_JIT mapping either, so no executable memory could be claimed. This is what happens when Husk runs inside another container app rather than sideloaded on its own.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "调试器已附加,但不响应陷阱请求,而这台设备也不执行 MAP_JIT 映射,因此无法取得可执行内存。当 Husk 运行在另一个容器应用内、而不是独立侧载时,就会出现这种情况。" + } + } + } + }, + "The downloaded image did not match the release. Tap to retry.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载的镜像与发布版本不符。轻点重试。" + } + } + } + }, + "The game exited": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏已退出" + } + } + } + }, + "The game is loaded in this session. Close Husk completely and open it again to reset its data.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏已在本次会话中加载。请完全关闭 Husk 再打开,才能重置它的数据。" + } + } + } + }, + "The game starts with nothing over it: no bar, no controller, no readout, the picture to every edge. Tap with three fingers at once to bring the bar back, and again to hide it. The Hide button in the bar does the same while playing, but leaves the strip the bar sat in.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏启动时画面上什么都没有:没有工具条,没有控制器,没有读数,画面铺满每一个边缘。三指同时轻点可唤回工具条,再点一次收起。游戏过程中工具条上的「隐藏」按钮效果相同,但会留下工具条所占的那一条区域。" + } + } + } + }, + "The helper did not attach within 90 seconds. Check that LocalDevVPN is connected, then try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "辅助程序在 90 秒内没有附加。请确认 LocalDevVPN 已连接,然后重试。" + } + } + } + }, + "The language Husk is drawn in. Android and the apps inside it keep their own, chosen in Android's settings.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Husk 界面所用的语言。Android 和其中的应用有各自的语言,在 Android 的设置里选择。" + } + } + } + }, + "The list is written to disk, so it is on screen before Android has finished starting.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这份列表已写入磁盘,因此在 Android 启动完成之前就能显示出来。" + } + } + } + }, + "The pairing file stays in Husk's Documents folder and is only sent to Husk's own helper.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对文件保存在 Husk 的「文档」文件夹中,只会发送给 Husk 自己的辅助程序。" + } + } + } + }, + "The panel is built when the machine starts, so a change costs one cold boot, and the next save replaces the machine saved at the old size — changing back costs another. Sizes are rounded to a multiple of eight. Bigger is slower: every pixel is drawn by an emulated phone. Android's density does not change with the panel, so a larger one shows more rather than bigger.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕在机器启动时构建,因此每次更改都要付出一次冷启动的代价,而下一次保存会替换掉以旧尺寸保存的机器——改回去又要再一次。尺寸会取整到 8 的倍数。越大越慢:每个像素都由模拟的手机绘制。Android 的密度不随屏幕变化,所以更大的屏幕显示的是更多内容,而不是更大的画面。" + } + } + } + }, + "The plan, in order, is docs/04-translation-layer.md in Husk's source.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按顺序推进的计划写在 Husk 源码的 docs/04-translation-layer.md 中。" + } + } + } + }, + "The request for Husk's JIT helper could not be encoded.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发给 Husk JIT 辅助程序的请求无法编码。" + } + } + } + }, + "The runtime on disk is not a valid image (%@). Tap to retry.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "磁盘上的运行环境不是有效镜像(%@)。轻点重试。" + } + } + } + }, + "The snapshot did not match the release (sha256 %@, expected %@).": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快照与发布版本不符(sha256 %@,应为 %@)。" + } + } + } + }, + "Theme": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主题" + } + } + } + }, + "There are no APKs in that bundle.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该捆绑包里没有 APK。" + } + } + } + }, + "There is no screen to show the file picker on.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可用于显示文件选择器的屏幕。" + } + } + } + }, + "They go to %@. Unmodified APKs install from here too.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "它们会放入 %@。未经修改的 APK 也可以从这里安装。" + } + } + } + }, + "This device closed the connection, which usually means it no longer accepts Husk's pairing. Pair again, and check that LocalDevVPN is connected.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台设备断开了连接,通常意味着它不再接受 Husk 的配对。请重新配对,并确认 LocalDevVPN 已连接。" + } + } + } + }, + "This device is jailbroken, so JIT comes from the jailbreak's Allow JIT in Apps setting.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台设备已越狱,因此 JIT 来自越狱设置中的「允许应用使用 JIT」。" + } + } + } + }, + "This iPhone": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此 iPhone" + } + } + } + }, + "This installation can only wait about 30 seconds in the background, so go to Settings straight away.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这次安装在后台只能等待约 30 秒,请立刻前往「设置」。" + } + } + } + }, + "This is a whole operating system — be patient": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这是一整套操作系统——请耐心等待" + } + } + } + }, + "Tinted Dark": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "色调化深色" + } + } + } + }, + "Tinted Light": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "色调化浅色" + } + } + } + }, + "Tints buttons, switches and selected states throughout the app.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为整个应用中的按钮、开关和选中状态着色。" + } + } + } + }, + "Today, %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天 %@" + } + } + } + }, + "Tools": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工具" + } + } + } + }, + "Touch always works. A gamepad and a pointer are not wired through to Android yet.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "触摸始终可用。手柄和指针目前还没有接入 Android。" + } + } + } + }, + "Touch is always on. The keyboard and the rotate control are in the pill at the bottom of the guest's screen; a gamepad and a pointer are not wired through yet.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "触摸始终开启。键盘和旋转控制在客户机屏幕底部的胶囊条里;手柄和指针目前还没有接入。" + } + } + } + }, + "Translating arm64, one block at a time": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "逐块翻译 arm64" + } + } + } + }, + "Translation Layer": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "翻译层" + } + } + } + }, + "TrollStore": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "TrollStore" + } + } + } + }, + "TrollStore can turn JIT on for Husk: it opens Husk and enables it, with no computer. In TrollStore's Settings, URL Scheme must be on.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "TrollStore 可以为 Husk 开启 JIT:它会打开 Husk 并启用 JIT,无需电脑。TrollStore 的设置中必须打开「URL Scheme」。" + } + } + } + }, + "TrollStore could not be opened. Install Husk through TrollStore, or choose another method.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开 TrollStore。请通过 TrollStore 安装 Husk,或选择其他方式。" + } + } + } + }, + "TrollStore did not enable JIT. In TrollStore's Settings turn on URL Scheme, and make sure Husk was installed through TrollStore (or is signed with get-task-allow).": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "TrollStore 没有启用 JIT。请在 TrollStore 的设置中打开「URL Scheme」,并确认 Husk 是通过 TrollStore 安装的(或以 get-task-allow 签名)。" + } + } + } + }, + "TrollStore is installed, so Husk will ask it to enable JIT.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "TrollStore 已安装,Husk 会请它启用 JIT。" + } + } + } + }, + "TrollStore was not found on this %@. Husk can only use it once it is installed and Husk was installed through it.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在这台 %@ 上未找到 TrollStore。只有安装了它、并且 Husk 是通过它安装的,Husk 才能使用它。" + } + } + } + }, + "Try again": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + } + } + }, + "Turn On JIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开启 JIT" + } + } + } + }, + "Turn On JIT with StikDebug": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用 StikDebug 开启 JIT" + } + } + } + }, + "Turn On JIT with StikJIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用 StikJIT 开启 JIT" + } + } + } + }, + "Turn On JIT with TrollStore": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用 TrollStore 开启 JIT" + } + } + } + }, + "Turn on Allow JIT in Apps in Dopamine's settings, then open Husk again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请在 Dopamine 的设置中打开「允许应用使用 JIT」,然后重新打开 Husk。" + } + } + } + }, + "Turn on JIT": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开启 JIT" + } + } + } + }, + "Turn on JIT first. Close the game with Close at the top.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先开启 JIT。用顶部的「关闭」结束游戏。" + } + } + } + }, + "Turning on JIT…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在开启 JIT…" + } + } + } + }, + "Uninstall": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载" + } + } + } + }, + "Uninstall %@?": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载 %@?" + } + } + } + }, + "Uninstalled": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已卸载" + } + } + } + }, + "Unity, cocos2d-x, Minecraft and SDL3 games": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Unity、cocos2d-x、Minecraft 和 SDL3 游戏" + } + } + } + }, + "Unpacking the guest": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在解包客户机" + } + } + } + }, + "Unrecognized source format": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法识别的源格式" + } + } + } + }, + "Unrotate picture": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消旋转画面" + } + } + } + }, + "Use Husk while it starts": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动期间先使用 Husk" + } + } + } + }, + "Use StikDebug": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 StikDebug" + } + } + } + }, + "Use TrollStore": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 TrollStore" + } + } + } + }, + "Use the jailbreak": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用越狱" + } + } + } + }, + "Version": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本" + } + } + } + }, + "Version %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本 %@" + } + } + } + }, + "Version %@ · %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本 %@ · %@" + } + } + } + }, + "Vibrate on a press": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按下时振动" + } + } + } + }, + "View logs": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看日志" + } + } + } + }, + "Waiting for Android (%llds)…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待 Android(%lld 秒)…" + } + } + } + }, + "Waiting for TrollStore to enable JIT…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待 TrollStore 启用 JIT…" + } + } + } + }, + "Waiting for the helper to attach…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待辅助程序附加…" + } + } + } + }, + "Waiting for your %@…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待你的 %@…" + } + } + } + }, + "Waking Android up": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在唤醒 Android" + } + } + } + }, + "What it is": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这是什么" + } + } + } + }, + "What the game is called in Husk. The game itself is not changed.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏在 Husk 里显示的名称。游戏本身不会被改动。" + } + } + } + }, + "Where it stands": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目前进展" + } + } + } + }, + "Which saved machine?": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哪一台已保存的机器?" + } + } + } + }, + "Width": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "宽度" + } + } + } + }, + "Without it Android boots from cold, which takes several minutes. The download is %@, and it replaces what is inside Android now, including installed apps.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有它,Android 就要冷启动,需要好几分钟。下载量为 %@,并会替换 Android 内当前的全部内容,包括已安装的应用。" + } + } + } + }, + "Working…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在处理…" + } + } + } + }, + "Yesterday, %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "昨天 %@" + } + } + } + }, + "Your apps are here; start it to open them.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的应用都在这里;启动后即可打开。" + } + } + } + }, + "about %lld minutes remaining": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "约剩 %lld 分钟" + } + } + } + }, + "about %lld seconds remaining": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "约剩 %lld 秒" + } + } + } + }, + "answering": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已响应" + } + } + } + }, + "attached": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已附加" + } + } + } + }, + "ctrl": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "ctrl" + } + } + } + }, + "detached": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已分离" + } + } + } + }, + "executes": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可执行" + } + } + } + }, + "file imported": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已导入文件" + } + } + } + }, + "first boot takes 5–15 min": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "首次启动需要 5–15 分钟" + } + } + } + }, + "found; Allow JIT in Apps is off": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已找到;「允许应用使用 JIT」已关闭" + } + } + } + }, + "granted": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已获得" + } + } + } + }, + "https://f-droid.org/repo/index-v1.json": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://f-droid.org/repo/index-v1.json" + } + } + } + }, + "iOS did not provide the extension API Husk's JIT helper needs.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iOS 没有提供 Husk 的 JIT 辅助程序所需的扩展 API。" + } + } + } + }, + "iOS stopped Husk in the background before pairing finished. Try again, and pair from Settings straight away.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对完成前 iOS 已在后台停止了 Husk。请重试,并立即从「设置」进行配对。" + } + } + } + }, + "iOS stopped the pairing in the background. Try again.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iOS 已在后台停止了配对。请重试。" + } + } + } + }, + "installed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装" + } + } + } + }, + "no": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "否" + } + } + } + }, + "none": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无" + } + } + } + }, + "not answering": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未响应" + } + } + } + }, + "not attached": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未附加" + } + } + } + }, + "not claimed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未获取" + } + } + } + }, + "not found": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到" + } + } + } + }, + "not set up": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未设置" + } + } + } + }, + "not started": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未启动" + } + } + } + }, + "not tested": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未测试" + } + } + } + }, + "not used (TXM)": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未使用(TXM)" + } + } + } + }, + "nothing": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无" + } + } + } + }, + "paired on this device": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已在本机配对" + } + } + } + }, + "refused": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "被拒绝" + } + } + } + }, + "software": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "软件" + } + } + } + }, + "starting": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动" + } + } + } + }, + "tap to show": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "轻点显示" + } + } + } + }, + "the Android shell is not answering": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Android shell 没有响应" + } + } + } + }, + "the request was cancelled": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请求已取消" + } + } + } + }, + "this %@": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这台 %@" + } + } + } + }, + "working": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可用" + } + } + } + }, + "yes": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "是" + } + } + } + }, + "…": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "…" + } + } + } + }, + "简体中文": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "简体中文" + } + } + } + } + }, + "version": "1.0" +} diff --git a/src/app/Husk/Localization.swift b/src/app/Husk/Localization.swift new file mode 100644 index 000000000..23d631108 --- /dev/null +++ b/src/app/Husk/Localization.swift @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import SwiftUI + +/// Husk's translations. +/// +/// The strings themselves live in `Localizable.xcstrings`, with `en` as the +/// source language and every other language beside it; `InfoPlist.xcstrings` +/// holds the one line Info.plist shows the user. Adding a language means adding +/// it to those two catalogs and nothing else -- there is no per-language code. +/// +/// SwiftUI localizes a `Text("literal")` on its own: the literal becomes a +/// `LocalizedStringKey` and is looked up in the catalog. Text the compiler sees +/// as a plain `String` is *not* a key and skips the catalog entirely, which is +/// what the two helpers below are for. That covers a ternary, a concatenation, +/// a value out of a computed property, and a parameter typed `String`. +/// +/// A key with no catalog entry resolves to itself, so data -- an app's own name, +/// an ABI, a path -- passes through these unchanged and needs no entry. + +/// A `LocalizedStringKey` for text the compiler sees as `String`. +/// +/// `Text(working ? "Refreshing…" : "Refresh")` is a `String`, so it is drawn +/// verbatim; `Text(localizedKey(working ? "Refreshing…" : "Refresh"))` is looked +/// up like any other literal. +func localizedKey(_ key: String) -> LocalizedStringKey { LocalizedStringKey(key) } + +/// A localized `String`, for values that have to stay `String`. +/// +/// A row's value is assembled at runtime -- `installed` or `not found`, a count, +/// a size -- and `DetailRow` takes it as a `String`. `String(localized:)` wants a +/// `LocalizationValue`, which only a literal can become, so a runtime `String` +/// comes through here instead. +func localizedString(_ key: String) -> String { + String(localized: String.LocalizationValue(key)) +} + +/// The language Husk is drawn in. +/// +/// The phone's own choice unless the user pins one here. A pinned language is +/// written to `AppleLanguages` in Husk's own defaults, which is the key iOS reads +/// when it picks a `.lproj` for the process. That happens once, at launch, so a +/// change shows up the next time Husk opens rather than immediately -- which is +/// what the note in `LanguageSettings` is for. +enum AppLanguage: String, CaseIterable, Identifiable { + /// Whatever the phone is set to. + case system + case english = "en" + case chineseSimplified = "zh-Hans" + + static let key = "husk.language" + + var id: String { rawValue } + + /// What the picker shows. The two languages are named in themselves, as a + /// language picker should be; only "System" is a translated word. + var title: LocalizedStringKey { + switch self { + case .system: return "System" + case .english: return "English" + case .chineseSimplified: return "简体中文" + } + } + + /// The identifier to pin, or nil to leave the choice to the system. + var code: String? { self == .system ? nil : rawValue } + + static var current: AppLanguage { + AppLanguage(rawValue: UserDefaults.standard.string(forKey: key) ?? "") ?? .system + } + + /// Writes the choice where iOS reads it, and remembers it so the picker can + /// show it again. An absent `AppleLanguages` is what "follow the system" + /// means: the bundle's own localizations decide, in the phone's order. + static func apply(_ language: AppLanguage) { + let defaults = UserDefaults.standard + if let code = language.code { + defaults.set([code], forKey: "AppleLanguages") + defaults.set(language.rawValue, forKey: key) + } else { + defaults.removeObject(forKey: "AppleLanguages") + defaults.removeObject(forKey: key) + } + defaults.set(!isShowing(language), forKey: pendingKey) + } + + /// The language this process is drawing. `AppleLanguages` is read once, at + /// launch, so this is what is on screen right now. + static let launchedWith: String = Bundle.main.preferredLocalizations.first ?? "en" + + /// Whether what is on screen is already the language asked for. + private static func isShowing(_ language: AppLanguage) -> Bool { + guard let code = language.code else { return false } + // A pinned language and the phone's own can be the same language under + // different spellings -- `zh-Hans` against `zh-Hans-CN` -- so compare + // the language part rather than the whole identifier. + return launchedWith.hasPrefix(code) + } + + private static let pendingKey = "husk.languagePendingRestart" + + /// Whether the choice now on disk needs a relaunch to be seen. Recorded when + /// the choice is written rather than worked out later: going back to "follow + /// the system" removes the pinned identifier there is nothing left to + /// compare against, and the answer has to survive the screen being reopened. + static var isPending: Bool { UserDefaults.standard.bool(forKey: pendingKey) } +} diff --git a/src/app/Husk/OnboardingView.swift b/src/app/Husk/OnboardingView.swift index dfda8d0f3..48ed22953 100644 --- a/src/app/Husk/OnboardingView.swift +++ b/src/app/Husk/OnboardingView.swift @@ -76,7 +76,7 @@ struct OnboardingView: View { onDone() } } label: { - Text(page < pages - 1 ? "Continue" : "Start using Husk") + Text(localizedKey(page < pages - 1 ? "Continue" : "Start using Husk")) } .buttonStyle(PrimaryButtonStyle()) .padding(.horizontal, 28) @@ -112,8 +112,8 @@ struct OnboardingView: View { Text("Husk").font(.system(size: 40, weight: .semibold, design: .rounded)) Text("Android apps, on your iPhone.") .font(.title3).foregroundStyle(.secondary) - Text("Husk runs a real Android system and opens APKs inside it. " - + "A few questions first — all of them can be changed later in Settings.") + Text(localizedKey("Husk runs a real Android system and opens APKs inside it. " + + "A few questions first — all of them can be changed later in Settings.")) .font(.callout).foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 34).padding(.top, 4) @@ -129,30 +129,30 @@ struct OnboardingView: View { .padding(.top, 34).padding(.bottom, 6) choice(icon: "bolt.fill", title: "Start Android on launch", - detail: "Boots the guest as soon as Husk opens, once JIT is " - + "available. Off means you start it yourself.", + detail: localizedKey("Boots the guest as soon as Husk opens, once JIT is " + + "available. Off means you start it yourself."), isOn: $autoStart) choice(icon: "rectangle.landscape.rotate", title: "Landscape screen", - detail: "Gives Android a landscape screen, which games fill " - + "properly. Portrait apps get letterboxed instead.", + detail: localizedKey("Gives Android a landscape screen, which games fill " + + "properly. Portrait apps get letterboxed instead."), isOn: $landscape) choice(icon: "speaker.wave.2.fill", title: "Sound", - detail: "Adds a sound device. Android cannot be saved while " - + "this is on, so every launch boots from cold.", + detail: localizedKey("Adds a sound device. Android cannot be saved while " + + "this is on, so every launch boots from cold."), isOn: $sound) choice(icon: "externaldrive.badge.checkmark", title: "Save automatically", - detail: "Saves the machine once Android settles, so later " - + "launches restore in seconds instead of booting.", + detail: localizedKey("Saves the machine once Android settles, so later " + + "launches restore in seconds instead of booting."), isOn: $autoSave) } .padding(.horizontal, 20).padding(.bottom, 20) } } - private func choice(icon: String, title: String, detail: String, + private func choice(icon: String, title: LocalizedStringKey, detail: LocalizedStringKey, isOn: Binding) -> some View { HStack(alignment: .top, spacing: 14) { Image(systemName: icon) @@ -174,7 +174,7 @@ struct OnboardingView: View { } /// What the JIT page says is already in place, if anything. - private var jitState: String? { + private var jitState: LocalizedStringKey? { if JITBootstrap.debuggedFlag { return "JIT is on." } if jit.method == .stikDebug { return "Husk will use StikDebug." } if jit.method == .trollStore { return "Husk will use TrollStore." } @@ -192,9 +192,9 @@ struct OnboardingView: View { .font(.system(size: 54)) .foregroundStyle(Theme.accent) Text("Turn on JIT").font(.largeTitle.weight(.semibold)) - Text("Android and Translation Layer games need JIT, which on iOS only an attached debugger can grant. " + Text(localizedKey("Android and Translation Layer games need JIT, which on iOS only an attached debugger can grant. " + "StikJIT is built into Husk and is the recommended way: it turns JIT on from inside the app, " - + "with no computer and no other app. StikDebug and TrollStore work too.") + + "with no computer and no other app. StikDebug and TrollStore work too.")) .font(.callout).foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 34) @@ -202,7 +202,7 @@ struct OnboardingView: View { Label(state, systemImage: "checkmark.circle.fill") .font(.callout.weight(.medium)).foregroundStyle(.green) } - Button(jitState == nil ? "Set Up StikJIT Now" : "Change JIT Setup") { settingUpJIT = true } + Button(localizedKey(jitState == nil ? "Set Up StikJIT Now" : "Change JIT Setup")) { settingUpJIT = true } .font(.body.weight(.semibold)) .foregroundStyle(Theme.accent) .padding(.top, 4) @@ -217,9 +217,9 @@ struct OnboardingView: View { .font(.system(size: 62)) .foregroundStyle(Theme.accent) Text("Ready").font(.largeTitle.weight(.semibold)) - Text("If JIT is not on when Android starts, Husk turns it on with the " + Text(localizedKey("If JIT is not on when Android starts, Husk turns it on with the " + "method you chose, or walks you through setting one up. You can " - + "change it any time in Settings › JIT & sideload.") + + "change it any time in Settings › JIT & sideload.")) .font(.callout).foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 34) diff --git a/src/app/Husk/QemuRunner.swift b/src/app/Husk/QemuRunner.swift index 98bcd0f37..741d3b44e 100644 --- a/src/app/Husk/QemuRunner.swift +++ b/src/app/Husk/QemuRunner.swift @@ -85,6 +85,8 @@ final class QemuRunner: ObservableObject { /// init are over in seconds, and the long tail is app compilation after /// bootanim exits. Evenly spaced numbers would race to 90% and then sit /// there, which is the specific thing progress bars are distrusted for. + /// The middle element is the key shown on the boot screen, not the + /// sentence: `localizedString` turns it into one where the message is built. nonisolated(unsafe) static let bootMilestones: [(String, String, Int)] = [ ("Linux version", "Starting the Linux kernel", 5), ("init: init first stage started","Android init, first stage", 10), @@ -1782,8 +1784,7 @@ final class QemuRunner: ObservableObject { Task { @MainActor in let r = QemuRunner.shared if percent > r.bootProgress { r.bootProgress = percent } - r.setupMessage = - "\(r.bootProgress)% · \(milestone) · \(stamp) elapsed" + r.setupMessage = String(localized: "\(r.bootProgress)% · \(localizedString(milestone)) · \(stamp) elapsed") } break } diff --git a/src/app/Husk/SettingsTab.swift b/src/app/Husk/SettingsTab.swift index c7c87b3c9..dc1255633 100644 --- a/src/app/Husk/SettingsTab.swift +++ b/src/app/Husk/SettingsTab.swift @@ -24,6 +24,7 @@ struct SettingsTab: View { row(LibrarySettings(), "square.grid.2x2.fill", .blue, "Library", "Your apps and their icons") row(PerformanceSettings(), "speedometer", .orange, "Performance", "Renderer, sound") row(AppearanceSettings(), "paintbrush.fill", .pink, "Appearance", "Light or dark, accent colour, app icon") + row(LanguageSettings(), "character.bubble.fill", .purple, "Language", "English or Simplified Chinese") } Section("Emulator") { @@ -51,7 +52,7 @@ struct SettingsTab: View { } private func row(_ destination: D, _ icon: String, _ tint: Color, - _ title: String, _ subtitle: String) -> some View { + _ title: LocalizedStringKey, _ subtitle: LocalizedStringKey) -> some View { NavigationLink { destination } label: { Label { VStack(alignment: .leading, spacing: 1) { @@ -97,8 +98,8 @@ struct LibrarySettings: View { value: "\(host.packages.filter { $0.iconPath != nil }.count)", mono: false) } footer: { - Text("The list is written to disk, so it is on screen before Android " - + "has finished starting.") + Text(localizedKey("The list is written to disk, so it is on screen before Android " + + "has finished starting.")) } Section { @@ -106,7 +107,7 @@ struct LibrarySettings: View { working = true Task { await host.refreshPackages(); working = false } } label: { - Label(working ? "Refreshing…" : "Refresh from Android", + Label(localizedKey(working ? "Refreshing…" : "Refresh from Android"), systemImage: "arrow.clockwise") } .disabled(working || !host.isReady) @@ -120,9 +121,9 @@ struct LibrarySettings: View { } .disabled(working || !host.isReady) } footer: { - Text("Names and icons come from Android's own launcher, which keeps " + Text(localizedKey("Names and icons come from Android's own launcher, which keeps " + "the version it draws. Re-fetching throws away Husk's copies and " - + "asks again.") + + "asks again.")) } } .huskForm() @@ -155,11 +156,11 @@ struct PerformanceSettings: View { } header: { Text("Renderer") } footer: { - Text(gpuMode + Text(localizedKey(gpuMode ? "Android draws on the real GPU through Metal — about four times " + "the frame rate. This is the default." : "Every pixel is drawn by the emulated CPU. Much slower, and only " - + "worth choosing if the GPU misbehaves.") + + "worth choosing if the GPU misbehaves.")) } Section { @@ -187,10 +188,10 @@ struct PerformanceSettings: View { } header: { Text("Sound") } footer: { - Text("Adds a sound device. While it is attached Android cannot be " + Text(localizedKey("Adds a sound device. While it is attached Android cannot be " + "saved — QEMU refuses to snapshot a machine with one — so every " + "launch boots from cold. Turning it on or off costs a cold boot " - + "either way.") + + "either way.")) } } .huskForm() @@ -210,7 +211,7 @@ struct InputSettings: View { /// Sizes worth offering without typing. Deliberately short: these are the /// shapes a phone guest is actually run at, not a catalogue of every panel /// ever made. - private static let presets: [(name: String, w: Int, h: Int)] = [ + private static let presets: [(name: LocalizedStringKey, w: Int, h: Int)] = [ ("Small — 360 × 800", 360, 800), ("HD — 720 × 1280", 720, 1280), ("Full HD — 1080 × 1920", 1080, 1920), @@ -246,14 +247,14 @@ struct InputSettings: View { } header: { Text("Screen") } footer: { - Text(customRes + Text(localizedKey(customRes ? "A custom resolution sets the shape itself, so this does nothing " + "while it is on. Type a wide size for landscape." : "Android cannot reshape a screen once it is running, so a " + "landscape game on a portrait screen gets letterboxed into a " + "band and looks tiny. Creating it landscape is the only way it " + "can fill it — portrait apps are letterboxed instead. Costs one " - + "cold boot.") + + "cold boot.")) } Section { @@ -315,18 +316,18 @@ struct InputSettings: View { } header: { Text("Resolution") } footer: { - Text("The panel is built when the machine starts, so a change costs " + Text(localizedKey("The panel is built when the machine starts, so a change costs " + "one cold boot, and the next save replaces the machine saved at " + "the old size — changing back costs another. Sizes are rounded " + "to a multiple of eight. Bigger is slower: every pixel is drawn " + "by an emulated phone. Android's density does not change with " - + "the panel, so a larger one shows more rather than bigger.") + + "the panel, so a larger one shows more rather than bigger.")) } Section { - Text("Touch is always on. The keyboard and the rotate control are " + Text(localizedKey("Touch is always on. The keyboard and the rotate control are " + "in the pill at the bottom of the guest's screen; a gamepad " - + "and a pointer are not wired through yet.") + + "and a pointer are not wired through yet.")) .font(.footnote).foregroundStyle(.secondary) } header: { Text("Controls") @@ -339,7 +340,7 @@ struct InputSettings: View { /// The panel the guest actually has, which only means anything while there /// is a guest: the stored value is last launch's until one starts. private var running: String { - guard QemuRunner.shared.isRunning else { return "not started" } + guard QemuRunner.shared.isRunning else { return localizedString("not started") } return "\(QemuRunner.lastGuestRes.w) × \(QemuRunner.lastGuestRes.h)" } @@ -369,18 +370,18 @@ struct NetworkSettings: View { UserDefaults.standard.set(v, forKey: "husk.keepNetwork") } } footer: { - Text(keepNetwork + Text(localizedKey(keepNetwork ? "Saving closes apps but leaves Android's framework running, so " + "the network still works after a restore." : "Saving stops the framework too. Clears every GPU resource, " + "which is steadier — but the network may not come back until a " - + "cold boot.") + + "cold boot.")) } Section { - Text("Android reaches the internet through a virtual ethernet card " + Text(localizedKey("Android reaches the internet through a virtual ethernet card " + "on QEMU's own network. Nothing on your phone's network can see " - + "the guest, and the guest cannot see it.") + + "the guest, and the guest cannot see it.")) .font(.footnote).foregroundStyle(.secondary) } header: { Text("How it connects") @@ -401,9 +402,9 @@ struct JITSettings: View { private var pairingLabel: String { switch jit.pairingSource { - case .onDevice: return "paired on this device" - case .imported: return "file imported" - case nil: return "not set up" + case .onDevice: return localizedString("paired on this device") + case .imported: return localizedString("file imported") + case nil: return localizedString("not set up") } } @@ -420,14 +421,14 @@ struct JITSettings: View { ForEach(JITMethod.allCases) { Text($0.title).tag($0) } } DetailRow(label: "StikDebug", - value: JITBootstrap.isStikDebugInstalled ? "installed" : "not found", mono: false) + value: localizedString(JITBootstrap.isStikDebugInstalled ? "installed" : "not found"), mono: false) DetailRow(label: "TrollStore", - value: JITBootstrap.isTrollStoreInstalled ? "installed" : "not found", mono: false) + value: localizedString(JITBootstrap.isTrollStoreInstalled ? "installed" : "not found"), mono: false) DetailRow(label: "Installed with TrollStore", - value: JITBootstrap.isInstalledWithTrollStore ? "yes" : "no", mono: false) + value: localizedString(JITBootstrap.isInstalledWithTrollStore ? "yes" : "no"), mono: false) DetailRow(label: "Jailbreak", - value: JITBootstrap.debuggedAtLaunch ? "JIT allowed for apps" - : JITBootstrap.isJailbroken ? "found; Allow JIT in Apps is off" : "not found", mono: false) + value: localizedString(JITBootstrap.debuggedAtLaunch ? "JIT allowed for apps" + : JITBootstrap.isJailbroken ? "found; Allow JIT in Apps is off" : "not found"), mono: false) DetailRow(label: "Built-in pairing", value: pairingLabel, mono: false) Button { jit.showSetup = true @@ -437,34 +438,40 @@ struct JITSettings: View { } header: { Text("Other Ways to Turn On JIT") } footer: { - Text(jit.method == .automatic - ? jit.automaticDescription + " Built-in StikJIT needs iOS 26, LocalDevVPN, and a " - + "pairing file, which Husk can make itself on iOS 27." - : HuskBuiltInJIT.unavailableReason ?? "Built-in StikJIT needs LocalDevVPN and a pairing " - + "file, which Husk can make itself on iOS 27.") + // Composed rather than concatenated: the method's own sentence and + // the caveat about the built-in route are two keys, and a + // concatenation of the two would be a third that no translator + // could see. + if jit.method == .automatic { + Text("\(jit.automaticDescription) Built-in StikJIT needs iOS 26, LocalDevVPN, and a pairing file, which Husk can make itself on iOS 27.") + } else if let reason = HuskBuiltInJIT.unavailableReason { + Text(localizedKey(reason)) + } else { + Text("Built-in StikJIT needs LocalDevVPN and a pairing file, which Husk can make itself on iOS 27.") + } } Section { DetailRow(label: "Debugger", - value: JITBootstrap.isDebuggerAttached ? "attached" : "not attached", + value: localizedString(JITBootstrap.isDebuggerAttached ? "attached" : "not attached"), mono: false) DetailRow(label: "Executable memory", - value: JITBootstrap.isLive ? "granted" : "not claimed", mono: false) + value: localizedString(JITBootstrap.isLive ? "granted" : "not claimed"), mono: false) // The two routes, named separately. Either one is enough, and // when someone reports "JIT does not work" these two rows are // the whole diagnosis. DetailRow(label: "Trap servicer", - value: JITBootstrap.prewarmed ? "answering" : "not answering", + value: localizedString(JITBootstrap.prewarmed ? "answering" : "not answering"), mono: false) // Cached answer only: running the probe from a view body // could freeze the app (see JITBootstrap.mapJITWorks). DetailRow(label: "MAP_JIT", - value: JITBootstrap.deviceEnforcesTXM ? "not used (TXM)" - : JITBootstrap.mapJITResult.map { $0 ? "executes" : "refused" } - ?? "not tested", + value: JITBootstrap.deviceEnforcesTXM ? localizedString("not used (TXM)") + : JITBootstrap.mapJITResult.map { $0 ? localizedString("executes") : localizedString("refused") } + ?? localizedString("not tested"), mono: false) DetailRow(label: "Debugger after setup", - value: JITBootstrap.detached ? "detached" : "attached", + value: localizedString(JITBootstrap.detached ? "detached" : "attached"), mono: false) if let why = JITBootstrap.lastFailure { Text(why).font(.caption).foregroundStyle(.orange) @@ -485,12 +492,12 @@ struct JITSettings: View { } header: { Text("JIT") } footer: { - Text("Husk needs memory it can write and then execute, which on iOS " + Text(localizedKey("Husk needs memory it can write and then execute, which on iOS " + "takes an attached debugger. There are two ways to get it: a " + "debugger that services trap requests, or a MAP_JIT mapping, " + "which the kernel allows any debugged process. Either one is " + "enough — which is available depends on the device and the iOS " - + "version, so Husk tests both rather than assuming.") + + "version, so Husk tests both rather than assuming.")) } Section { @@ -506,17 +513,17 @@ struct JITSettings: View { Toggle("Keep debugger attached", isOn: $keepAttached) .onChange(of: keepAttached) { v in JITBootstrap.keepDebuggerAttached = v } } footer: { - Text("Off by default. Husk detaches StikDebug as soon as the " + Text(localizedKey("Off by default. Husk detaches StikDebug as soon as the " + "JIT region is held, because a debugger that iOS has suspended " + "stops the whole app the next time it is needed. Turn this on " - + "only to collect StikDebug's own logs.") + + "only to collect StikDebug's own logs.")) } Section { - Text("APKs install from the Library's + button or from the Files tab. " + Text(localizedKey("APKs install from the Library's + button or from the Files tab. " + "Split sets — a base APK plus its config pieces — must be picked " + "together; installing the base alone fails on missing native " - + "libraries.") + + "libraries.")) .font(.footnote).foregroundStyle(.secondary) } header: { Text("Sideloading") @@ -549,15 +556,15 @@ struct SavedMachineSettings: View { Button { QemuRunner.shared.saveState(reason: "asked from settings") } label: { - Label(runner.isSavingState ? "Saving…" : "Save now", + Label(localizedKey(runner.isSavingState ? "Saving…" : "Save now"), systemImage: "externaldrive.badge.checkmark") } .disabled(runner.isSavingState) } footer: { - Text("Husk restores a saved machine instead of booting it, which takes " + Text(localizedKey("Husk restores a saved machine instead of booting it, which takes " + "seconds rather than minutes. The picture freezes while it writes. " + "With this off, nothing saves by itself — including after an " - + "install.") + + "install.")) } Section { @@ -569,11 +576,15 @@ struct SavedMachineSettings: View { Text(deleteResult).font(.caption).foregroundStyle(.secondary) } } footer: { - Text(QemuRunner.shared.hasSnapshot - ? "Currently saved: " - + ((QemuRunner.shared.snapshotDisplay ?? "sw").contains("gl") - ? "GPU" : "software") + "." - : "Nothing is saved, so Android boots from cold.") + // Composed rather than concatenated: "GPU" and "software" name the + // renderer the snapshot was taken on, and are keys of their own. + if QemuRunner.shared.hasSnapshot { + let kind = (QemuRunner.shared.snapshotDisplay ?? "sw").contains("gl") + ? localizedString("GPU") : localizedString("software") + Text("Currently saved: \(kind).") + } else { + Text("Nothing is saved, so Android boots from cold.") + } } Section { @@ -582,9 +593,9 @@ struct SavedMachineSettings: View { UserDefaults.standard.set(v, forKey: "husk.downloadSnapshot") } } footer: { - Text("Adds about 2 GB to the first download. It was captured on the " + Text(localizedKey("Adds about 2 GB to the first download. It was captured on the " + "software renderer, so it is not used on GPU — which cold-boots " - + "once and then saves its own.") + + "once and then saves its own.")) } } .huskForm() @@ -600,10 +611,13 @@ struct SavedMachineSettings: View { } private func forget(_ mode: String, _ name: String) { + // `name` is itself a key -- "GPU" or "software" -- so the sentence around + // it stays one piece for the translator rather than a concatenation. + let kind = localizedString(name) if QemuRunner.shared.forgetSnapshot(mode: mode) { - deleteResult = "Deleted the \(name) machine. The next launch boots from cold." + deleteResult = String(localized: "Deleted the \(kind) machine. The next launch boots from cold.") } else { - deleteResult = "No \(name) machine is saved, so nothing was deleted." + deleteResult = String(localized: "No \(kind) machine is saved, so nothing was deleted.") } } } @@ -632,8 +646,8 @@ struct AppearanceSettings: View { } header: { Text("Theme") } footer: { - Text("System follows the phone: light by day, dark by night. The guest's own " - + "screen stays dark either way — it is a picture of another phone.") + Text(localizedKey("System follows the phone: light by day, dark by night. The guest's own " + + "screen stays dark either way — it is a picture of another phone.")) } Section { @@ -646,7 +660,7 @@ struct AppearanceSettings: View { swatch(preset.color, selected: preset.color.themeHex == theme.accentColor.themeHex) } .buttonStyle(.plain) - .accessibilityLabel(preset.name) + .accessibilityLabel(localizedKey(preset.name)) } } .padding(.vertical, 6) @@ -689,8 +703,8 @@ struct AppearanceSettings: View { } header: { Text("App Icon") } footer: { - Text("Automatic follows the system appearance — light, dark and tinted. The others pin " - + "one look. iOS shows its own confirmation after a change; that alert cannot be turned off.") + Text(localizedKey("Automatic follows the system appearance — light, dark and tinted. The others pin " + + "one look. iOS shows its own confirmation after a change; that alert cannot be turned off.")) } } .navigationTitle("Appearance") @@ -738,17 +752,17 @@ struct AboutSettings: View { LabeledContent("Build", value: Bundle.main.commit) LabeledContent("Guest image", value: GuestImage.imageVersion) LabeledContent("Renderer", - value: runner.displayKind == .gl ? "GPU" + value: localizedString(runner.displayKind == .gl ? "GPU" : runner.displayKind == .software ? "CPU" - : "Not started") + : "Not started")) } Section { Toggle(isOn: $devInfo) { VStack(alignment: .leading, spacing: 3) { Text("Developer Info") - Text("Technical detail in the Android Translation Layer screens: " - + "library reports, device checks and run logs.") + Text(localizedKey("Technical detail in the Android Translation Layer screens: " + + "library reports, device checks and run logs.")) .font(.caption).foregroundStyle(.secondary) } } @@ -756,9 +770,9 @@ struct AboutSettings: View { Label("Open Console", systemImage: "terminal") } } footer: { - Text("Husk runs unmodified Android APKs in a real Android system on your iPhone. " + Text(localizedKey("Husk runs unmodified Android APKs in a real Android system on your iPhone. " + "The console shows Husk's live log, the guest's serial output and QEMU's own " - + "output — the three files any problem here is diagnosed from.") + + "output — the three files any problem here is diagnosed from.")) } } .navigationTitle("About") @@ -767,6 +781,47 @@ struct AboutSettings: View { } } +// MARK: - Language + +/// Which language Husk is drawn in. +/// +/// The picker writes `AppleLanguages` into Husk's own defaults, which iOS reads +/// once, when the process starts — hence the note rather than an instant change. +/// The catalogues are `Localizable.xcstrings` and `InfoPlist.xcstrings`; adding a +/// language is a matter of adding it to those, not of touching this file. +struct LanguageSettings: View { + @State private var language = AppLanguage.current + @State private var pending = AppLanguage.isPending + + var body: some View { + Form { + Section { + Picker("Language", selection: $language) { + ForEach(AppLanguage.allCases) { Text($0.title).tag($0) } + } + .onChange(of: language) { choice in + AppLanguage.apply(choice) + pending = AppLanguage.isPending + } + } footer: { + Text("The language Husk is drawn in. Android and the apps inside it keep their own, chosen in Android's settings.") + } + + Section { + // Say which of the two it is: a note that always claims a + // restart is needed, on a screen that changed nothing, is a note + // people learn to ignore. + Text(localizedKey(pending + ? "Restart Husk to see the change." + : "A change takes effect the next time Husk opens.")) + .font(.footnote).foregroundStyle(.secondary) + } + } + .huskForm() + .navigationTitle("Language") + } +} + extension Bundle { var version: String { (infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?" diff --git a/src/app/Husk/TLAppSettings.swift b/src/app/Husk/TLAppSettings.swift index 11af97e3e..81fb09e0f 100644 --- a/src/app/Husk/TLAppSettings.swift +++ b/src/app/Husk/TLAppSettings.swift @@ -10,7 +10,7 @@ struct TLAppSettings: Codable, Equatable { /// What the game asks for (its manifest), landscape when it does not say. case auto, landscape, portrait var id: String { rawValue } - var title: String { + var title: LocalizedStringKey { switch self { case .auto: return "Automatic" case .landscape: return "Landscape" @@ -23,7 +23,7 @@ struct TLAppSettings: Codable, Equatable { enum Resolution: String, Codable, CaseIterable, Identifiable { case low, medium, high, native var id: String { rawValue } - var title: String { + var title: LocalizedStringKey { switch self { case .low: return "Low" case .medium: return "Medium" @@ -33,10 +33,10 @@ struct TLAppSettings: Codable, Equatable { } var detail: String { switch self { - case .low: return "1 pixel per point. Fastest." - case .medium: return "1.5 pixels per point." - case .high: return "2 pixels per point. The default." - case .native: return "Every pixel the screen has. Sharpest, and heaviest." + case .low: return localizedString("1 pixel per point. Fastest.") + case .medium: return localizedString("1.5 pixels per point.") + case .high: return localizedString("2 pixels per point. The default.") + case .native: return localizedString("Every pixel the screen has. Sharpest, and heaviest.") } } @MainActor var scale: CGFloat { @@ -54,7 +54,7 @@ struct TLAppSettings: Codable, Equatable { /// For the games that cannot be played without a controller (Unreal), when none is connected. case auto, always, never var id: String { rawValue } - var title: String { + var title: LocalizedStringKey { switch self { case .auto: return "Automatic" case .always: return "Always" @@ -120,7 +120,7 @@ struct TLAppSettingsView: View { @ObservedObject private var store = TranslationLayerStore.shared @State private var settings: TLAppSettings @State private var name: String - @State private var dataSize: String = "…" + @State private var dataSize: String = localizedString("…") @State private var confirmReset = false @FocusState private var nameFocused: Bool @@ -171,8 +171,7 @@ struct TLAppSettingsView: View { } header: { Text("Display") } footer: { - Text("\(settings.resolution.detail) Orientation and resolution apply the next time the game starts, and a game already " - + "running in this session needs Husk closed and opened again.") + Text("\(settings.resolution.detail) Orientation and resolution apply the next time the game starts, and a game already running in this session needs Husk closed and opened again.") } Section { @@ -180,9 +179,9 @@ struct TLAppSettingsView: View { } header: { Text("Screenshots") } footer: { - Text("The game starts with nothing over it: no bar, no controller, no readout, the picture to every edge. Tap with three " + Text(localizedKey("The game starts with nothing over it: no bar, no controller, no readout, the picture to every edge. Tap with three " + "fingers at once to bring the bar back, and again to hide it. The Hide button in the bar does the same while " - + "playing, but leaves the strip the bar sat in.") + + "playing, but leaves the strip the bar sat in.")) } Section { @@ -199,8 +198,8 @@ struct TLAppSettingsView: View { } header: { Text("Controller") } footer: { - Text("Automatic offers the controller for games that cannot be played without one (Unreal Engine games), when no real " - + "controller is connected. Always offers it for any game that understands one. A paired controller is always used.") + Text(localizedKey("Automatic offers the controller for games that cannot be played without one (Unreal Engine games), when no real " + + "controller is connected. Always offers it for any game that understands one. A paired controller is always used.")) } Section { @@ -212,8 +211,8 @@ struct TLAppSettingsView: View { } header: { Text("Data") } footer: { - Text(inUse ? "The game is loaded in this session. Close Husk completely and open it again to reset its data." - : "Deletes what the game saved and downloaded here: saves, settings and caches. The game itself stays.") + Text(localizedKey(inUse ? "The game is loaded in this session. Close Husk completely and open it again to reset its data." + : "Deletes what the game saved and downloaded here: saves, settings and caches. The game itself stays.")) } } .huskForm() @@ -251,7 +250,7 @@ struct TLAppSettingsView: View { if values?.isRegularFile == true { bytes += Int64(values?.fileSize ?? 0) } } } - return bytes == 0 ? "nothing" : ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + return bytes == 0 ? localizedString("nothing") : ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) }.value } } diff --git a/src/app/Husk/TLUnityView.swift b/src/app/Husk/TLUnityView.swift index 2517d8a27..a85c34549 100644 --- a/src/app/Husk/TLUnityView.swift +++ b/src/app/Husk/TLUnityView.swift @@ -107,7 +107,7 @@ final class TLUnityUIView: UIView, UIKeyInput { husk_unity_perf_snapshot(&p) let text = p.fps > 0 ? String(format: "%.0f fps · %.1f ms · max %.0f", p.fps, p.mean_ms, p.max_ms) - : "starting" + : localizedString("starting") stats.text = text onStats?(text) } @@ -217,7 +217,7 @@ final class TLUnityUIView: UIView, UIKeyInput { typedLabel.autoresizingMask = [.flexibleWidth] bar.addSubview(typedLabel) let done = UIButton(type: .system) - done.setTitle("Done", for: .normal) + done.setTitle(localizedString("Done"), for: .normal) done.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) done.frame = CGRect(x: 100, y: 0, width: 80, height: 44) done.autoresizingMask = [.flexibleLeftMargin] @@ -365,7 +365,7 @@ final class TLUnityModel: ObservableObject { } } - var statusText: String { + var statusText: LocalizedStringKey { switch state { case Int32(HUSK_UNITY_STARTING): return "Loading the engine…" case Int32(HUSK_UNITY_RUNNING): return "Running" @@ -375,7 +375,7 @@ final class TLUnityModel: ObservableObject { } } - var subStatusText: String { + var subStatusText: LocalizedStringKey { switch state { case Int32(HUSK_UNITY_RUNNING): return "\(frames) frame(s) drawn · native runtime" case Int32(HUSK_UNITY_STARTING): return "Loading libraries and starting the engine" @@ -474,7 +474,7 @@ struct TLUnityAttemptView: View { if showLog { ScrollViewReader { proxy in ScrollView { - Text(model.logText.isEmpty ? "Starting…" : model.logText) + Text(localizedKey(model.logText.isEmpty ? "Starting…" : model.logText)) .font(.technical(11)) .foregroundStyle(Theme.text) .frame(maxWidth: .infinity, alignment: .leading) @@ -512,7 +512,7 @@ struct TLCocosAttemptView: View { @StateObject private var model = TLUnityModel() @AppStorage("husk.tl.unity.showLog") private var showLogSetting = false @AppStorage(TranslationLayer.devInfoKey) private var devInfo = false - @State private var stats = "starting" + @State private var stats = localizedString("starting") @ObservedObject private var pads = HuskGamepads.shared @StateObject private var virtualPad = VirtualPad() /// This game's own settings (TLAppSettings), read once as the screen opens. @@ -662,16 +662,18 @@ struct TLCocosAttemptView: View { } .tint(.white) Circle().fill(model.statusColor).frame(width: 7, height: 7) - Text(model.state == Int32(HUSK_UNITY_RUNNING) ? app.label : model.statusText) + Text(model.state == Int32(HUSK_UNITY_RUNNING) ? localizedKey(app.label) : model.statusText) .font(.system(size: 12, weight: .medium)).foregroundStyle(.white.opacity(0.85)).lineLimit(1) Spacer() if !pads.names.isEmpty { - Label(pads.names.count == 1 ? pads.names[0] : "\(pads.names.count) controllers", systemImage: "gamecontroller.fill") + Label(pads.names.count == 1 ? pads.names[0] + : String(localized: "\(pads.names.count) controllers"), + systemImage: "gamecontroller.fill") .font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1) } if padOffered { Button { settings.padShown.toggle(); settings.save(app.id) } label: { - Label(settings.padShown ? "Hide pad" : "Pad", systemImage: "gamecontroller").font(.system(size: 12, weight: .semibold)) + Label(localizedKey(settings.padShown ? "Hide pad" : "Pad"), systemImage: "gamecontroller").font(.system(size: 12, weight: .semibold)) } .tint(.white) } @@ -680,7 +682,7 @@ struct TLCocosAttemptView: View { } if devInfo { Button { withAnimation(.snappy(duration: 0.25)) { showLog.toggle() } } label: { - Text(showLog ? "Hide log" : "Log").font(.system(size: 12, weight: .semibold)) + Text(localizedKey(showLog ? "Hide log" : "Log")).font(.system(size: 12, weight: .semibold)) } .tint(.white) } @@ -706,7 +708,7 @@ struct TLCocosAttemptView: View { .padding(.horizontal, 10).padding(.vertical, 6) ScrollViewReader { proxy in ScrollView { - Text(model.logText.isEmpty ? "Starting…" : model.logText) + Text(localizedKey(model.logText.isEmpty ? "Starting…" : model.logText)) .font(.technical(10)) .foregroundStyle(Theme.text) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/src/app/Husk/Theme.swift b/src/app/Husk/Theme.swift index db7301c9b..40b1653fe 100644 --- a/src/app/Husk/Theme.swift +++ b/src/app/Husk/Theme.swift @@ -44,7 +44,7 @@ enum Theme { var id: String { rawValue } - var title: String { + var title: LocalizedStringKey { switch self { case .system: return "System" case .light: return "Light" @@ -203,8 +203,11 @@ struct Chip: View { } /// A small tag under a title — a category, an ABI, a state. +/// +/// The text is a key, so a category or an ABI that has no entry is drawn as +/// itself, which is what data should do. struct Tag: View { - let text: String + let text: LocalizedStringKey var tint: Color = Theme.textDim var body: some View { @@ -217,8 +220,11 @@ struct Tag: View { } /// A label and a value on one line, for anything worth reading off. +/// +/// The label is a key; the value is data — a count, a size, a state — and is +/// localized by whoever knows whether it is a sentence or a measurement. struct DetailRow: View { - let label: String + let label: LocalizedStringKey let value: String var mono: Bool = true @@ -240,8 +246,8 @@ struct DetailRow: View { /// chevron that says it goes somewhere. struct HuskRow: View { let systemImage: String - let title: String - var subtitle: String? = nil + let title: LocalizedStringKey + var subtitle: LocalizedStringKey? = nil var tint: Color = Theme.text var showsChevron = true @@ -297,8 +303,8 @@ struct RowDivider: View { /// A section label above a group. struct SectionHeader: View { - let title: String - var trailing: String? = nil + let title: LocalizedStringKey + var trailing: LocalizedStringKey? = nil var body: some View { HStack(alignment: .firstTextBaseline) { @@ -315,7 +321,7 @@ struct SectionHeader: View { /// A small status pill. The tint carries the meaning, the text the detail. struct StatusPill: View { - let text: String + let text: LocalizedStringKey let systemImage: String var tint: Color = Theme.accent @@ -330,10 +336,10 @@ struct StatusPill: View { /// What a screen shows when it has nothing to show. struct EmptyState: View { - let title: String - let message: String + let title: LocalizedStringKey + let message: LocalizedStringKey let systemImage: String - var actionTitle: String? = nil + var actionTitle: LocalizedStringKey? = nil var action: (() -> Void)? = nil var body: some View { diff --git a/src/app/Husk/TranslationLayer.swift b/src/app/Husk/TranslationLayer.swift index 88cded925..e62bcff98 100644 --- a/src/app/Husk/TranslationLayer.swift +++ b/src/app/Husk/TranslationLayer.swift @@ -63,15 +63,14 @@ extension TLReport { var nativeEngineName: String { nativeEngine == .cocos ? "Cocos2d-x" : nativeEngine == .minecraft ? "Minecraft" : nativeEngine == .sdl ? "SDL" : nativeEngine == .ue4 ? "Unreal Engine" : nativeEngine == .gta ? "Rockstar" : nativeEngine == .nativeactivity ? "NativeActivity" : "Unity" } var displaySummary: String { - guard runsOnNativeRuntime else { return summary } + guard runsOnNativeRuntime else { return localizedString(summary) } let flagged = libraries.filter { $0.abi == "arm64-v8a" && $0.status != "ok" }.count let total = libraries.filter { $0.abi == "arm64-v8a" }.count - var text = "A \(nativeEngineName) game. It runs through Husk's native runtime, which loads its \(total) arm64 libraries itself." - if nativeEngine == .cocos || nativeEngine == .minecraft || nativeEngine == .ue4 || nativeEngine == .gta { text += " It is a landscape game: Husk turns the screen for it." } - else if nativeEngine == .sdl || nativeEngine == .nativeactivity { text += " Husk turns the screen the way the game asks for." } + var text = String(localized: "A \(nativeEngineName) game. It runs through Husk's native runtime, which loads its \(total) arm64 libraries itself.") + if nativeEngine == .cocos || nativeEngine == .minecraft || nativeEngine == .ue4 || nativeEngine == .gta { text += " " + String(localized: "It is a landscape game: Husk turns the screen for it.") } + else if nativeEngine == .sdl || nativeEngine == .nativeactivity { text += " " + String(localized: "Husk turns the screen the way the game asks for.") } if flagged > 0 { - text += " \(flagged) of them use tricks the older loader could not handle; the native runtime handles those too, " - + "except for optional anti-tamper code, which it leaves out." + text += " " + String(localized: "\(flagged) of them use tricks the older loader could not handle; the native runtime handles those too, except for optional anti-tamper code, which it leaves out.") } return text } @@ -193,8 +192,8 @@ final class TranslationLayerStore: ObservableObject { /// picked together -- and report on it. func add(_ urls: [URL], move: Bool = false) { guard !urls.isEmpty, busy == nil else { return } - busy = urls.count == 1 ? "Adding \(urls[0].lastPathComponent)…" - : "Adding \(urls.count) APKs…" + busy = urls.count == 1 ? String(localized: "Adding \(urls[0].lastPathComponent)…") + : String(localized: "Adding \(urls.count) APKs…") Task.detached(priority: .userInitiated) { let failure = Self.ingest(urls, move: move) await MainActor.run { @@ -308,7 +307,7 @@ final class TranslationLayerStore: ObservableObject { } catch { try? fm.removeItem(at: dir) HuskLog.log("tl", "FAILED to add: \(error.localizedDescription)") - return "Husk could not copy it: \(error.localizedDescription)" + return String(localized: "Husk could not copy it: \(error.localizedDescription)") } let apks = ((try? fm.contentsOfDirectory(atPath: dir.path)) ?? []) @@ -485,9 +484,9 @@ struct TranslationLayerTab: View { } header: { Text("Apps") } footer: { - Text("Runs Android games straight on your iPhone, without starting Android. Add an APK, or a bundle " + Text(localizedKey("Runs Android games straight on your iPhone, without starting Android. Add an APK, or a bundle " + "(.xapk, .apkm, .apks) — or pick a base APK and its split pieces together. " - + "Husk keeps its own copy, apart from Android's.") + + "Husk keeps its own copy, apart from Android's.")) } } @@ -510,28 +509,28 @@ struct TranslationLayerTab: View { Button { store.runChecks() } label: { - Label(store.checking ? "Checking…" - : store.checks.isEmpty ? "Run checks" : "Run again", + Label(localizedKey(store.checking ? "Checking…" + : store.checks.isEmpty ? "Run checks" : "Run again"), systemImage: "stethoscope") } .disabled(store.checking) } header: { Text("This iPhone") } footer: { - Text("Android's native code expects things of the processor and of memory " + Text(localizedKey("Android's native code expects things of the processor and of memory " + "that only the phone can answer -- above all, whether a library's code " + "can run with its data writable right beside it. Enable JIT first, or " - + "the memory checks are skipped. The results go to the console too.") + + "the memory checks are skipped. The results go to the console too.")) } } private var progressSection: some View { Section { - DetailRow(label: "App reports", value: "working", mono: false) - DetailRow(label: "Device checks", value: "working", mono: false) - DetailRow(label: "Library loader", value: "working", mono: false) + DetailRow(label: "App reports", value: localizedString("working"), mono: false) + DetailRow(label: "Device checks", value: localizedString("working"), mono: false) + DetailRow(label: "Library loader", value: localizedString("working"), mono: false) DetailRow(label: "Android runtime", value: "Unity, cocos2d-x, GameActivity, SDL3", mono: false) - DetailRow(label: "Opening apps", value: "Unity, cocos2d-x, Minecraft and SDL3 games", mono: false) + DetailRow(label: "Opening apps", value: localizedString("Unity, cocos2d-x, Minecraft and SDL3 games"), mono: false) } header: { Text("Where it stands") } footer: { @@ -542,7 +541,7 @@ struct TranslationLayerTab: View { /// A verdict, as words and a colour. struct TLVerdict { - let title: String + let title: LocalizedStringKey let tint: Color init(_ report: TLReport?) { @@ -563,7 +562,7 @@ struct TLVerdict { /// What a person who is not debugging needs to know about an app: will it run here, or may it not. struct TLPlainStatus { - let title: String + let title: LocalizedStringKey let tint: Color init(_ report: TLReport?) { @@ -678,7 +677,7 @@ struct TLAppReportView: View { showAttempt = true } label: { HStack { - Label(devInfo ? "Run Translation Layer Attempt" : "Play", systemImage: "play.circle.fill") + Label(localizedKey(devInfo ? "Run Translation Layer Attempt" : "Play"), systemImage: "play.circle.fill") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.accent) Spacer() @@ -694,8 +693,8 @@ struct TLAppReportView: View { } } footer: { if devInfo { - Text("Loads arm64 native code into JIT memory on Apple Silicon and drives " - + "a NativeActivity lifecycle. Apps with Java/Dex require ART (milestone 2).") + Text(localizedKey("Loads arm64 native code into JIT memory on Apple Silicon and drives " + + "a NativeActivity lifecycle. Apps with Java/Dex require ART (milestone 2).")) } else { Text("Turn on JIT first. Close the game with Close at the top.") } @@ -708,7 +707,7 @@ struct TLAppReportView: View { } DetailRow(label: "Dex", value: dex(report), mono: false) DetailRow(label: "ABIs", value: report.abis.isEmpty - ? "none" : report.abis.joined(separator: ", ")) + ? localizedString("none") : report.abis.joined(separator: ", ")) DetailRow(label: "APKs", value: "\(app.apks.count)", mono: false) } header: { Text("What it is") @@ -723,9 +722,9 @@ struct TLAppReportView: View { } header: { Text("Android libraries it needs") } footer: { - Text("Its own libraries link against these, and it does not " + Text(localizedKey("Its own libraries link against these, and it does not " + "carry them. Each is something the translation layer has " - + "to provide.") + + "to provide.")) } } @@ -743,8 +742,8 @@ struct TLAppReportView: View { Label("Remove", systemImage: "trash") } } footer: { - Text("Deletes Husk's copy of the APKs. Anything installed in Android is " - + "untouched.") + Text(localizedKey("Deletes Husk's copy of the APKs. Anything installed in Android is " + + "untouched.")) } } .huskForm() @@ -769,16 +768,17 @@ struct TLAppReportView: View { } private func dex(_ report: TLReport) -> String { - let files = report.dexCount == 1 ? "1 file" : "\(report.dexCount) files" + let files = report.dexCount == 1 ? localizedString("1 file") + : String(localized: "\(report.dexCount) files") let size = ByteCountFormatter.string(fromByteCount: report.dexBytes, countStyle: .file) - return "\(files), \(size)" + return String(localized: "\(files), \(size)") } } private struct TLLibraryRows: View { let lib: TLLibrary - private var statusTitle: String { + private var statusTitle: LocalizedStringKey { switch lib.status { case "ok": return "Maps as it is" case "work": return "Needs loader work" @@ -828,7 +828,7 @@ private struct TLLibraryRows: View { DetailRow(label: "Thread register reads", value: "\(reads)") } if lib.tls == true { - DetailRow(label: "Thread-local storage", value: "yes", mono: false) + DetailRow(label: "Thread-local storage", value: localizedString("yes"), mono: false) } } } @@ -845,7 +845,7 @@ final class TLAttemptRunner: ObservableObject { private var timer: Timer? - var statusText: String { + var statusText: LocalizedStringKey { if isRunning { return "Running Attempt..." } guard let code = exitCode else { return "Ready" } switch code { @@ -865,7 +865,7 @@ final class TLAttemptRunner: ObservableObject { } } - var subStatusText: String { + var subStatusText: LocalizedStringKey { if isRunning { return "\(frameCount) frame(s) posted · driving lifecycle" } @@ -1061,7 +1061,7 @@ struct TLClassicAttemptView: View { if showLog { ScrollViewReader { proxy in ScrollView { - Text(runner.logText.isEmpty ? "Starting attempt..." : runner.logText) + Text(localizedKey(runner.logText.isEmpty ? "Starting attempt..." : runner.logText)) .font(.technical(11)) .foregroundStyle(Theme.text) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/src/translation-layer/husk-tl-probe.c b/src/translation-layer/husk-tl-probe.c index 7e7f227d4..464ec224e 100644 --- a/src/translation-layer/husk-tl-probe.c +++ b/src/translation-layer/husk-tl-probe.c @@ -583,7 +583,10 @@ static void check_dualmap(tl_json *j, size_t page, tl_dual_mapping *stik) uint32_t code[2] = { 0x52800540u, 0xD65F03C0u }; /* movz w0, #42; ret */ memcpy(rw, code, sizeof(code)); - sys_icache_invalidate(rx, sizeof(code)); + /* flush_icache, not sys_icache_invalidate: this block is compiled for + aarch64 Linux too (tests/translation-layer/run.sh builds it with + aarch64-linux-gnu-gcc), where the Apple call does not exist. */ + flush_icache(rx, sizeof(code)); guard_arm(); const char *status = "fail"; diff --git a/tests/translation-layer/run.sh b/tests/translation-layer/run.sh index 59ce781a1..710b9b151 100755 --- a/tests/translation-layer/run.sh +++ b/tests/translation-layer/run.sh @@ -37,14 +37,21 @@ if ! command -v aarch64-linux-gnu-gcc >/dev/null || ! command -v qemu-aarch64 >/ fi # The checks do not touch ZIP files, so the scanner's zlib is stubbed out # rather than cross-built. -cat > "$OUT/noscan.c" <<'EOF' +cat > "$OUT/nostubs.c" <<'EOF' #include +#include "husk-tl-internal.h" char *husk_tl_scan(const char *const *p, int n) { (void)p; (void)n; return NULL; } void *husk_tl_read_entry(const char *a, const char *b, size_t l, size_t *o) { (void)a; (void)b; (void)l; *o = 0; return NULL; } +/* The dual-map check asks whether a debugger granted a mapping. Its + implementation is husk-tl-load.c, the Apple-side loader -- it includes + mach/mach_time.h unguarded -- and is therefore not linked here. NULL is also + what the real one answers on a machine with no StikDebug, so the check skips + for the honest reason rather than a faked one. */ +tl_dual_mapping *tl_find_stikdebug_prewarmed(void) { return NULL; } EOF aarch64-linux-gnu-gcc $CFLAGS -static -o "$OUT/checks_arm64" "$HERE/scan_cli.c" \ - "$OUT/noscan.c" "$SRC"/husk-tl-json.c "$SRC"/husk-tl-probe.c \ + "$OUT/nostubs.c" "$SRC"/husk-tl-json.c "$SRC"/husk-tl-probe.c \ -Wl,--defsym=husk_tl_free=free -lpthread python3 - "$OUT/checks_arm64" <<'EOF' import json, subprocess, sys