diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9375fb4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,142 @@ +name: Release macOS app + +# Builds, signs, notarizes and staples "AirPort Utility.app", then attaches the +# zip to a GitHub release. +# +# Required repository secrets: +# MACOS_CERTIFICATE base64 of the "Developer ID Application" .p12 +# (base64 -i cert.p12 | pbcopy) +# MACOS_CERTIFICATE_PWD password used when exporting that .p12 +# KEYCHAIN_PASSWORD any throwaway string; unlocks the temp keychain +# NOTARY_APPLE_ID Apple ID used for notarization +# NOTARY_TEAM_ID 10-character Developer Team ID +# NOTARY_PASSWORD app-specific password for that Apple ID +# Optional: +# BUNDLE_ID overrides the bundle identifier + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Version to build (e.g. 0.1.0)" + required: true + default: "0.1.0" + +permissions: + contents: write + +jobs: + release: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + # Pinning a specific Xcode is wrong here: the project uses concurrency + # features that Swift 6.0 (Xcode 16) rejects, so pick the newest toolchain + # the runner provides rather than an arbitrary one. + - name: Select the newest Xcode on the runner + run: | + echo "Available:" + ls -d /Applications/Xcode*.app + LATEST=$(ls -d /Applications/Xcode*.app | sort -V | tail -1) + echo "Selecting $LATEST" + sudo xcode-select -s "$LATEST" + + - name: Show toolchain + run: | + swift --version + python3 --version + + - name: Run tests + run: | + swift test + python3 -m unittest Tests/BackendPythonTests/test_backend_modules.py + + - name: Derive version + id: version + run: | + if [ "${{ github.event_name }}" = "push" ]; then + VERSION="${GITHUB_REF_NAME#v}" + else + VERSION="${{ github.event.inputs.version }}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building version $VERSION" + + - name: Import Developer ID certificate + if: ${{ secrets.MACOS_CERTIFICATE != '' }} + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + CERT_PATH="$RUNNER_TEMP/certificate.p12" + KEYCHAIN="$RUNNER_TEMP/app-signing.keychain-db" + + echo -n "$MACOS_CERTIFICATE" | base64 --decode -o "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$CERT_PATH" -P "$MACOS_CERTIFICATE_PWD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN" + # Without this, codesign blocks on a UI prompt for keychain access. + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null + security list-keychain -d user -s "$KEYCHAIN" login.keychain-db + + rm -f "$CERT_PATH" + security find-identity -v -p codesigning "$KEYCHAIN" + + - name: Build, sign, notarize and staple + if: ${{ secrets.MACOS_CERTIFICATE != '' }} + env: + MARKETING_VERSION: ${{ steps.version.outputs.version }} + CURRENT_PROJECT_VERSION: ${{ github.run_number }} + BUNDLE_ID: ${{ secrets.BUNDLE_ID || 'io.github.jackhumphries.airport-utility' }} + NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} + NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }} + NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} + run: ./make-app.sh --sign --notarize --zip + + - name: Verify the shipped bundle + if: ${{ secrets.MACOS_CERTIFICATE != '' }} + run: | + set -euo pipefail + APP="dist/AirPort Utility.app" + echo "--- architectures ---" + lipo -archs "$APP/Contents/MacOS/AirPort Utility" + echo "--- signature ---" + codesign --verify --strict --verbose=2 "$APP" + echo "--- stapled ticket ---" + xcrun stapler validate "$APP" + echo "--- gatekeeper ---" + spctl --assess --type exec --verbose=2 "$APP" + + - uses: actions/upload-artifact@v4 + if: ${{ secrets.MACOS_CERTIFICATE != '' }} + with: + name: AirPort-Utility-${{ steps.version.outputs.version }} + path: dist/*.zip + + - name: Publish release + if: ${{ github.event_name == 'push' && secrets.MACOS_CERTIFICATE != '' }} + uses: softprops/action-gh-release@v2 + with: + files: dist/*.zip + generate_release_notes: true + + - name: Note that signing was skipped + if: ${{ secrets.MACOS_CERTIFICATE == '' }} + run: | + echo "::notice::Built and tested only. Signing, notarization and"\ + " release publishing were skipped because the signing secrets are"\ + " not configured for this repository." + + - name: Clean up keychain + if: always() + run: security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" || true diff --git a/.gitignore b/.gitignore index 0524ceb..dd67bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,13 @@ local-artifacts/network-captures/ *.log *.keylog DerivedData/ + +# macOS app bundle build output +dist/ +build/ +Build/ +*.xcuserdatad +xcuserdata/ + +# Local build overrides (bundle identifier, signing team) -- never committed +Packaging/Local.xcconfig diff --git a/AirPortUtility.xcodeproj/project.pbxproj b/AirPortUtility.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0221af2 --- /dev/null +++ b/AirPortUtility.xcodeproj/project.pbxproj @@ -0,0 +1,288 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 1A0000000000000000000011 /* AirPortUtilityApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000010 /* AirPortUtilityApp.swift */; }; + 1A0000000000000000000013 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 1A0000000000000000000012 /* AppIcon.icns */; }; + 1A0000000000000000000018 /* AirPortUtilityCore in Frameworks */ = {isa = PBXBuildFile; productRef = 1A0000000000000000000017 /* AirPortUtilityCore */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1A0000000000000000000005 /* AirPort Utility.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "AirPort Utility.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A0000000000000000000010 /* AirPortUtilityApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirPortUtilityApp.swift; sourceTree = ""; }; + 1A0000000000000000000012 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; + 1A000000000000000000001B /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; + 1A0000000000000000000014 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1A0000000000000000000015 /* AirPortUtility.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AirPortUtility.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1A0000000000000000000007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A0000000000000000000018 /* AirPortUtilityCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1A0000000000000000000002 = { + isa = PBXGroup; + children = ( + 1A0000000000000000000019 /* AirPortUtilityApp */, + 1A000000000000000000001A /* Packaging */, + 1A0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + 1A0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + 1A0000000000000000000005 /* AirPort Utility.app */, + ); + name = Products; + sourceTree = ""; + }; + 1A0000000000000000000019 /* AirPortUtilityApp */ = { + isa = PBXGroup; + children = ( + 1A0000000000000000000010 /* AirPortUtilityApp.swift */, + ); + name = AirPortUtilityApp; + path = Sources/AirPortUtilityApp; + sourceTree = ""; + }; + 1A000000000000000000001A /* Packaging */ = { + isa = PBXGroup; + children = ( + 1A000000000000000000001B /* Base.xcconfig */, + 1A0000000000000000000012 /* AppIcon.icns */, + 1A0000000000000000000014 /* Info.plist */, + 1A0000000000000000000015 /* AirPortUtility.entitlements */, + ); + path = Packaging; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1A0000000000000000000004 /* AirPort Utility App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A000000000000000000000B /* Build configuration list for PBXNativeTarget "AirPort Utility App" */; + buildPhases = ( + 1A0000000000000000000006 /* Sources */, + 1A0000000000000000000007 /* Frameworks */, + 1A0000000000000000000008 /* Resources */, + 1A0000000000000000000009 /* Embed Python backend */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "AirPort Utility App"; + packageProductDependencies = ( + 1A0000000000000000000017 /* AirPortUtilityCore */, + ); + productName = "AirPort Utility App"; + productReference = 1A0000000000000000000005 /* AirPort Utility.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1A0000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 1600; + TargetAttributes = { + 1A0000000000000000000004 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 1A000000000000000000000A /* Build configuration list for PBXProject "AirPortUtility" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1A0000000000000000000002; + packageReferences = ( + 1A0000000000000000000016 /* XCLocalSwiftPackageReference "." */, + ); + productRefGroup = 1A0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1A0000000000000000000004 /* AirPort Utility App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1A0000000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A0000000000000000000013 /* AppIcon.icns in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1A0000000000000000000009 /* Embed Python backend */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Embed Python backend"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n# The backend must land in Resources, not MacOS: codesign treats everything in\n# Contents/MacOS as code and rejects non-Mach-O files there.\n# __pycache__ is excluded so stale bytecode is never sealed into the signature.\nDEST=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/backend\"\nrm -rf \"$DEST\"\nmkdir -p \"$DEST\"\nfor f in \"$SRCROOT\"/backend/*.py; do\n cp \"$f\" \"$DEST/\"\ndone\nchmod +x \"$DEST/airport_backend.py\"\n\n# Bump the bundle mtime so Finder and LaunchServices re-read it instead of\n# serving a stale cached icon after the artwork changes.\ntouch \"$TARGET_BUILD_DIR/$WRAPPER_NAME\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1A0000000000000000000006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A0000000000000000000011 /* AirPortUtilityApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1A000000000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + MACOSX_DEPLOYMENT_TARGET = 13.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 1A000000000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_NO_COMMON_BLOCKS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + 1A000000000000000000000E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1A000000000000000000001B /* Base.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Packaging/AirPortUtility.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Packaging/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MARKETING_VERSION = 0.1.0; + PRODUCT_NAME = "AirPort Utility"; + }; + name = Debug; + }; + 1A000000000000000000000F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1A000000000000000000001B /* Base.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Packaging/AirPortUtility.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Packaging/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MARKETING_VERSION = 0.1.0; + PRODUCT_NAME = "AirPort Utility"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A000000000000000000000A /* Build configuration list for PBXProject "AirPortUtility" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000000000000000000000C /* Debug */, + 1A000000000000000000000D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A000000000000000000000B /* Build configuration list for PBXNativeTarget "AirPort Utility App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000000000000000000000E /* Debug */, + 1A000000000000000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 1A0000000000000000000016 /* XCLocalSwiftPackageReference "." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = .; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1A0000000000000000000017 /* AirPortUtilityCore */ = { + isa = XCSwiftPackageProductDependency; + productName = AirPortUtilityCore; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 1A0000000000000000000001 /* Project object */; +} diff --git a/AirPortUtility.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/AirPortUtility.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..cc6ed91 --- /dev/null +++ b/AirPortUtility.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,12 @@ + + + + + BuildLocationStyle + UseAppPreferences + BuildSystemType + Latest + DerivedDataLocationStyle + Default + + diff --git a/AirPortUtility.xcodeproj/xcshareddata/xcschemes/AirPort Utility App.xcscheme b/AirPortUtility.xcodeproj/xcshareddata/xcschemes/AirPort Utility App.xcscheme new file mode 100644 index 0000000..a5e7bb5 --- /dev/null +++ b/AirPortUtility.xcodeproj/xcshareddata/xcschemes/AirPort Utility App.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.swift b/Package.swift index 78cb30a..ea426bd 100644 --- a/Package.swift +++ b/Package.swift @@ -3,11 +3,16 @@ import PackageDescription let package = Package( name: "AirPortUtility", + defaultLocalization: "en", platforms: [ .macOS(.v13) ], products: [ - .executable(name: "AirPort Utility", targets: ["AirPortUtilityApp"]) + .executable(name: "AirPort Utility", targets: ["AirPortUtilityApp"]), + // Exposed as a product so AirPortUtility.xcodeproj can depend on it as a + // local package product. Xcode can only link products, not bare targets, + // and this keeps the app target from having to duplicate the source list. + .library(name: "AirPortUtilityCore", targets: ["AirPortUtilityCore"]), ], targets: [ .target( diff --git a/Packaging/AirPortUtility.entitlements b/Packaging/AirPortUtility.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/Packaging/AirPortUtility.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packaging/AppIcon.icns b/Packaging/AppIcon.icns new file mode 100644 index 0000000..3f5a187 Binary files /dev/null and b/Packaging/AppIcon.icns differ diff --git a/Packaging/AppIcon.png b/Packaging/AppIcon.png new file mode 100644 index 0000000..3aabcbc Binary files /dev/null and b/Packaging/AppIcon.png differ diff --git a/Packaging/Base.xcconfig b/Packaging/Base.xcconfig new file mode 100644 index 0000000..7ff1e08 --- /dev/null +++ b/Packaging/Base.xcconfig @@ -0,0 +1,14 @@ +// Shared build settings for AirPortUtility.xcodeproj. +// +// The bundle identifier is defined here rather than in the target so a fork can +// override it without touching a committed file. Create Packaging/Local.xcconfig +// (gitignored) to set your own: +// +// PRODUCT_BUNDLE_IDENTIFIER = com.example.airport-utility +// +// make-app.sh reads the same file, so both build paths stay in agreement. + +PRODUCT_BUNDLE_IDENTIFIER = io.github.jackhumphries.airport-utility + +// Optional include: absent by default, and the build must not fail without it. +#include? "Local.xcconfig" diff --git a/Packaging/Info.plist b/Packaging/Info.plist new file mode 100644 index 0000000..f15aa7d --- /dev/null +++ b/Packaging/Info.plist @@ -0,0 +1,78 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AirPort Utility + CFBundleDisplayName + AirPort Utility + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + CFBundleLocalizations + + en + fr + de + es + it + + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + NSHumanReadableCopyright + MIT License. Not affiliated with or endorsed by Apple Inc. + + + LSEnvironment + + PATH + /opt/homebrew/bin:/usr/local/bin:/Library/Frameworks/Python.framework/Versions/Current/bin:/usr/bin:/bin:/usr/sbin:/sbin + + PYTHONDONTWRITEBYTECODE + 1 + + + + NSLocalNetworkUsageDescription + AirPort Utility needs local network access to discover AirPort base stations and Time Capsules on your network and to read or change their settings. + NSBonjourServices + + _airport._tcp + + _afpovertcp._tcp + _smb._tcp + + + diff --git a/Packaging/README.md b/Packaging/README.md new file mode 100644 index 0000000..90b705d --- /dev/null +++ b/Packaging/README.md @@ -0,0 +1,54 @@ +# Packaging + +Assets shared by `make-app.sh` and `AirPortUtility.xcodeproj`. + +| File | Purpose | +| --- | --- | +| `Info.plist` | Bundle metadata. Uses `$(VAR)` placeholders that Xcode substitutes natively and `make-app.sh` substitutes with `sed`, so one file serves both build paths. | +| `AirPortUtility.entitlements` | Intentionally an empty dict — see below. | +| `AppIcon.png` | 1254×1254 icon source, transparent outside the squircle. | +| `AppIcon.icns` | Generated from `AppIcon.png`. Regenerate with the command below. | + +## Why the entitlements file is empty + +The app is **not sandboxed**, so it needs none of the sandbox-gated entitlements +(`com.apple.security.network.*`, `files.user-selected`, keychain). Those only +constrain sandboxed processes. + +Hardened Runtime *is* required for notarization, but it is enabled by +`codesign --options runtime`, not by an entitlement. No Hardened Runtime +exception is needed here: the app loads no third-party libraries into its own +process, and spawning the bundled Python backend as a child process needs no +`com.apple.security.cs.*` key. + +The file is kept, rather than omitted, so there is one obvious place to add a +key later — but note that **AMFI's parser rejects XML comments inside an +entitlements plist** (`AMFIUnserializeXML: syntax error`), which is why this +explanation lives here instead of in the file. + +## Info.plist keys that are load-bearing + +- `NSLocalNetworkUsageDescription` + `NSBonjourServices` — on macOS 15+ the app + is its own TCC principal. Without these, Bonjour discovery silently returns + nothing and ACP connections to port 5009 fail. Running from Terminal masks + this, because there the permission belongs to Terminal. +- `LSEnvironment/PATH` — Finder-launched apps inherit a minimal PATH, so the + backend's `#!/usr/bin/env python3` would resolve to `/usr/bin/python3` + (a Command Line Tools stub, Python 3.9.6). The backend's full test suite does + pass on 3.9.6, but this prefers a real interpreter when one is installed. +- `LSEnvironment/PYTHONDONTWRITEBYTECODE` — the backend ships inside the signed + bundle. Without this, Python writes `__pycache__` beside it on import, + mutating a sealed resource and invalidating the code signature. + +## Regenerating the icon + +```sh +SET=$(mktemp -d)/AppIcon.iconset && mkdir -p "$SET" +for spec in "16 icon_16x16" "32 icon_16x16@2x" "32 icon_32x32" "64 icon_32x32@2x" \ + "128 icon_128x128" "256 icon_128x128@2x" "256 icon_256x256" \ + "512 icon_256x256@2x" "512 icon_512x512" "1024 icon_512x512@2x"; do + sips -s format png -z "${spec%% *}" "${spec%% *}" Packaging/AppIcon.png \ + --out "$SET/${spec#* }.png" >/dev/null +done +iconutil -c icns "$SET" -o Packaging/AppIcon.icns +``` diff --git a/README.md b/README.md index fd6b310..706d55c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,28 @@ Apple's AirPort Utility is not guaranteed to run on macOS 27 and newer, so I reverse engineered the application and have reimplemented it for macOS 27 and newer with Swift (front-end code) and Python (backend protocol code). I leveraged Codex to accelerate this work. +## Download + +**[Download AirPort Utility](https://github.com/JYPochez/VNS-Airport-Utility/releases)** +— signed, notarized, and localized into English, French, German, Spanish and +Italian. Universal (Apple Silicon and Intel), macOS 13 or later. + +Unzip and drag **AirPort Utility.app** to your Applications folder. Because the +build is notarized by Apple, it opens without a Gatekeeper warning. + +Two things on first launch: + +- **Approve the Local Network prompt.** Base stations are found over Bonjour and + configured over ACP on port 5009, both of which count as local network access. + Declining it means no devices are discovered. +- **Python 3 must be present.** The app drives a Python backend bundled inside + it, run with the interpreter already on your Mac. The system `python3` works. + +This is a fork of [jackhumphries/airport-utility](https://github.com/jackhumphries/airport-utility) +adding a double-clickable app bundle and localization. Not affiliated with Apple. + +--- + ![AirPort Utility network topology](docs/images/airport-utility-topology.png) ![AirPort Utility Internet settings](docs/images/airport-utility-internet-settings.png) @@ -49,6 +71,101 @@ Then compile and run the application from the root of the repository: --- +### macOS application + +`run.sh` builds and launches the app from the command line. To get a real, +double-clickable `AirPort Utility.app` — with an icon, an `Info.plist`, and the +Python backend embedded so it is self-contained — use either of the two build +paths below. Both produce an identical bundle. + +**Shell script** (no Xcode project needed): + +```sh +./make-app.sh # dist/AirPort Utility.app, unsigned +./make-app.sh --sign # + Developer ID signature +./make-app.sh --sign --notarize --zip # + notarized, stapled, zipped +``` + +**Xcode**: + +```sh +open AirPortUtility.xcodeproj # scheme: "AirPort Utility App" +``` + +Set your team under Signing & Capabilities on first use. The project consumes +the package at the repository root as a local Swift package, so the source list +is never duplicated. + +Both paths build a universal binary (`arm64` + `x86_64`) and lay the bundle out +like this: + +``` +AirPort Utility.app/Contents/ + Info.plist + MacOS/AirPort Utility universal executable + Resources/AppIcon.icns + Resources/backend/ embedded Python backend + Resources/AirPortUtility_…Core.bundle SwiftPM resources +``` + +#### Things worth knowing + +- **Local Network permission.** On macOS 15 and newer a bundled app is its own + privacy principal. `Info.plist` therefore declares + `NSLocalNetworkUsageDescription` and `NSBonjourServices`; without them Bonjour + discovery silently returns nothing and ACP connections to port 5009 fail. + Running from Terminal hides this, because there the permission belongs to + Terminal. macOS prompts once on first launch — approving it is required. +- **Python.** The backend is loaded from `Contents/Resources/backend`. A + Finder-launched app inherits a minimal `PATH`, so `#!/usr/bin/env python3` + would resolve to `/usr/bin/python3` — the Command Line Tools stub, Python + 3.9.6. The backend's full test suite passes there, but `Info.plist` sets + `LSEnvironment/PATH` to prefer a Homebrew or python.org interpreter when one + is installed. +- **Signing.** `codesign` treats everything in `Contents/MacOS` as code, so the + backend must live in `Resources`. The bundle is signed with Hardened Runtime + (required for notarization) and is **not** sandboxed, so the entitlements file + is intentionally empty. + +`Packaging/README.md` documents each load-bearing key and how to regenerate the +icon. Tagging `v*` runs `.github/workflows/release.yml`, which builds, signs, +notarizes, staples and attaches the zip to a GitHub release. + +--- + +### Localization + +The app ships English, French, German, Spanish and Italian. Tables live in +`Sources/AirPortUtilityCore/Resources/.lproj/Localizable.strings` and are +reached through `AirPortLocalization`. + +Keys are the English source strings, so an untranslated string falls back to +readable English rather than a symbolic key, and English behaviour is +unchanged. + +To check another language without changing your system settings: + +```sh +swift run "AirPort Utility" -AppleLanguages '(fr)' +``` + +Three rules when adding strings: + +- **Never localize protocol text.** ACP keys (`syNm`), backend flags + (`--router-mode`), JSON keys, and any `Codable`-persisted raw value are wire + format. `Pane.rawValue` stays English for that reason — it is persisted, used + for snapshot file names, and used to build accessibility identifiers; the + localized title is `Pane.displayName`. +- **Budget the length.** The window is a fixed 800×504, and German and French + run 20–30% longer than English. `LocalizationTests` catches missing and + untranslated keys, but not overflow — render the pane and look at it. +- **Language resolution goes through `Locale.preferredLanguages`,** not the + one-argument `Bundle.preferredLocalizations(from:)`. That variant matches + against the *main* bundle's localizations, which are empty in command-line + and test builds, and would silently pin every lookup to English. + +--- + ### Testing Run the Swift unit tests from the root of the repository: diff --git a/Sources/AirPortUtilityApp/AirPortUtilityApp.swift b/Sources/AirPortUtilityApp/AirPortUtilityApp.swift index a0ee899..f141067 100644 --- a/Sources/AirPortUtilityApp/AirPortUtilityApp.swift +++ b/Sources/AirPortUtilityApp/AirPortUtilityApp.swift @@ -3,6 +3,12 @@ import AppKit import SwiftUI import UniformTypeIdentifiers +/// Menu titles live in the app target, so they reach the shared table through +/// AirPortUtilityCore's public entry point. +private func localized(_ key: String, context: String? = nil) -> String { + AirPortLocalization.text(key, context: context) +} + @main @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { @@ -48,6 +54,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Window + /// Places the window near the top-left of the active screen. + /// + /// Not centred: the window grows wider as base stations are discovered, and a + /// centred window expands in both directions, so on a busy network it ends up + /// partly off-screen. Anchored top-left it grows right and down into free + /// space. Uses visibleFrame, so it sits below the menu bar and clear of the + /// Dock, and falls back to centring if no screen is available. + private static func positionAtTopLeft(_ window: NSWindow) { + guard let screen = window.screen ?? NSScreen.main else { + window.center() + return + } + let margin: CGFloat = 20 + let visible = screen.visibleFrame + window.setFrameOrigin( + NSPoint( + x: visible.minX + margin, + y: visible.maxY - window.frame.height - margin)) + } + private func showMainWindow() { if let window = windowController?.window { window.makeKeyAndOrderFront(nil) @@ -68,13 +94,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { backing: .buffered, defer: false ) - window.title = "AirPort Utility" + window.title = localized("AirPort Utility") window.minSize = window.frameRect( forContentRect: NSRect(origin: .zero, size: AirPortMainWindowMetrics.contentSize) ).size window.contentViewController = NSHostingController(rootView: content) window.isReleasedWhenClosed = false - window.center() + Self.positionAtTopLeft(window) let controller = NSWindowController(window: window) windowController = controller @@ -174,113 +200,113 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func installMainMenu() { let mainMenu = NSMenu() mainMenu.addItem(menu("AirPort Utility", submenu: applicationMenu())) - mainMenu.addItem(menu("File", submenu: fileMenu())) - mainMenu.addItem(menu("Edit", submenu: editMenu())) + mainMenu.addItem(menu(localized("File"), submenu: fileMenu())) + mainMenu.addItem(menu(localized("Edit", context: "menu"), submenu: editMenu())) mainMenu.addItem(menu("Base Station", submenu: baseStationMenu())) - mainMenu.addItem(menu("Window", submenu: windowMenu())) - mainMenu.addItem(menu("Help", submenu: helpMenu())) + mainMenu.addItem(menu(localized("Window"), submenu: windowMenu())) + mainMenu.addItem(menu(localized("Help"), submenu: helpMenu())) NSApplication.shared.mainMenu = mainMenu - NSApplication.shared.windowsMenu = mainMenu.item(withTitle: "Window")?.submenu + NSApplication.shared.windowsMenu = mainMenu.item(withTitle: localized("Window"))?.submenu NSApp.servicesMenu = - mainMenu.item(withTitle: "AirPort Utility")?.submenu?.item(withTitle: "Services")?.submenu + mainMenu.item(withTitle: "AirPort Utility")?.submenu?.item(withTitle: localized("Services"))?.submenu } private func applicationMenu() -> NSMenu { let menu = NSMenu(title: "AirPort Utility") menu.addItem( item( - "About AirPort Utility", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + localized("About AirPort Utility"), action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), target: NSApp)) menu.addItem(NSMenuItem.separator()) menu.addItem( - item("Preferences...", action: #selector(showPreferences(_:)), key: ",", target: self)) + item(localized("Preferences..."), action: #selector(showPreferences(_:)), key: ",", target: self)) menu.addItem(NSMenuItem.separator()) - menu.addItem(self.menu("Services", submenu: NSMenu(title: "Services"))) + menu.addItem(self.menu(localized("Services"), submenu: NSMenu(title: localized("Services")))) menu.addItem(NSMenuItem.separator()) menu.addItem( item( - "Hide AirPort Utility", action: #selector(NSApplication.hide(_:)), key: "h", target: NSApp)) + localized("Hide AirPort Utility"), action: #selector(NSApplication.hide(_:)), key: "h", target: NSApp)) menu.addItem( item( - "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), key: "h", + localized("Hide Others"), action: #selector(NSApplication.hideOtherApplications(_:)), key: "h", modifiers: [.command, .option], target: NSApp)) menu.addItem( - item("Show All", action: #selector(NSApplication.unhideAllApplications(_:)), target: NSApp)) + item(localized("Show All"), action: #selector(NSApplication.unhideAllApplications(_:)), target: NSApp)) menu.addItem(NSMenuItem.separator()) menu.addItem( item( - "Quit AirPort Utility", action: #selector(NSApplication.terminate(_:)), key: "q", + localized("Quit AirPort Utility"), action: #selector(NSApplication.terminate(_:)), key: "q", target: NSApp)) return menu } private func fileMenu() -> NSMenu { - let menu = NSMenu(title: "File") + let menu = NSMenu(title: localized("File")) menu.addItem( - item("Configure Other...", action: #selector(configureOther(_:)), target: self)) + item(localized("Configure Other..."), action: #selector(configureOther(_:)), target: self)) menu.addItem(NSMenuItem.separator()) menu.addItem( item( - "Import Configuration File...", action: #selector(importConfigurationFile(_:)), + localized("Import Configuration File..."), action: #selector(importConfigurationFile(_:)), target: self)) menu.addItem( item( - "Export Configuration File...", action: #selector(exportConfigurationFile(_:)), + localized("Export Configuration File..."), action: #selector(exportConfigurationFile(_:)), target: self)) menu.addItem(NSMenuItem.separator()) - menu.addItem(item("Close", action: #selector(NSWindow.performClose(_:)), key: "w")) + menu.addItem(item(localized("Close"), action: #selector(NSWindow.performClose(_:)), key: "w")) return menu } private func editMenu() -> NSMenu { - let menu = NSMenu(title: "Edit") - menu.addItem(item("Undo", action: Selector(("undo:")), key: "z")) + let menu = NSMenu(title: localized("Edit")) + menu.addItem(item(localized("Undo"), action: Selector(("undo:")), key: "z")) menu.addItem( - item("Redo", action: Selector(("redo:")), key: "Z", modifiers: [.command, .shift])) + item(localized("Redo"), action: Selector(("redo:")), key: "Z", modifiers: [.command, .shift])) menu.addItem(NSMenuItem.separator()) - menu.addItem(item("Cut", action: #selector(NSText.cut(_:)), key: "x")) - menu.addItem(item("Copy", action: #selector(NSText.copy(_:)), key: "c")) - menu.addItem(item("Paste", action: #selector(NSText.paste(_:)), key: "v")) - menu.addItem(item("Delete", action: #selector(NSText.delete(_:)))) + menu.addItem(item(localized("Cut"), action: #selector(NSText.cut(_:)), key: "x")) + menu.addItem(item(localized("Copy"), action: #selector(NSText.copy(_:)), key: "c")) + menu.addItem(item(localized("Paste"), action: #selector(NSText.paste(_:)), key: "v")) + menu.addItem(item(localized("Delete"), action: #selector(NSText.delete(_:)))) menu.addItem(NSMenuItem.separator()) - menu.addItem(item("Select All", action: #selector(NSText.selectAll(_:)), key: "a")) + menu.addItem(item(localized("Select All"), action: #selector(NSText.selectAll(_:)), key: "a")) return menu } private func baseStationMenu() -> NSMenu { let menu = NSMenu(title: "Base Station") menu.addItem( - item("Refresh", action: #selector(refreshNetwork(_:)), key: "r", target: self)) + item(localized("Refresh"), action: #selector(refreshNetwork(_:)), key: "r", target: self)) menu.addItem(NSMenuItem.separator()) menu.addItem( - item("Show Passwords…", action: #selector(showPasswords(_:)), target: self)) + item(localized("Show Passwords…"), action: #selector(showPasswords(_:)), target: self)) menu.addItem(NSMenuItem.separator()) menu.addItem( - item("Restart…", action: #selector(restartBaseStation(_:)), target: self)) + item(localized("Restart…"), action: #selector(restartBaseStation(_:)), target: self)) menu.addItem( item( - "Restore Default Settings...", action: #selector(restoreDefaultSettings(_:)), + localized("Restore Default Settings..."), action: #selector(restoreDefaultSettings(_:)), target: self)) menu.addItem(NSMenuItem.separator()) - menu.addItem(disabledItem("Add WPS Printer…")) + menu.addItem(disabledItem(localized("Add WPS Printer…"))) return menu } private func windowMenu() -> NSMenu { - let menu = NSMenu(title: "Window") - menu.addItem(item("Minimize", action: #selector(NSWindow.performMiniaturize(_:)), key: "m")) - menu.addItem(item("Zoom", action: #selector(NSWindow.performZoom(_:)))) + let menu = NSMenu(title: localized("Window")) + menu.addItem(item(localized("Minimize"), action: #selector(NSWindow.performMiniaturize(_:)), key: "m")) + menu.addItem(item(localized("Zoom"), action: #selector(NSWindow.performZoom(_:)))) menu.addItem(NSMenuItem.separator()) menu.addItem( - item("Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), target: NSApp) + item(localized("Bring All to Front"), action: #selector(NSApplication.arrangeInFront(_:)), target: NSApp) ) return menu } private func helpMenu() -> NSMenu { - let menu = NSMenu(title: "Help") + let menu = NSMenu(title: localized("Help")) menu.addItem( - item("AirPort Utility Help", action: #selector(showHelp(_:)), key: "?", target: self)) + item(localized("AirPort Utility Help"), action: #selector(showHelp(_:)), key: "?", target: self)) return menu } @@ -328,7 +354,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc func importConfigurationFile(_ sender: Any?) { showMainWindow() let panel = NSOpenPanel() - panel.title = "Import Configuration File" + panel.title = localized("Import Configuration File") panel.allowedContentTypes = Self.configurationContentTypes panel.allowsMultipleSelection = false panel.canChooseDirectories = false @@ -338,7 +364,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { do { try self?.model.importConfiguration(from: url) } catch { - self?.presentFileOperationError(error, title: "Import Configuration File") + self?.presentFileOperationError(error, title: localized("Import Configuration File")) } } } @@ -347,7 +373,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc func exportConfigurationFile(_ sender: Any?) { showMainWindow() let panel = NSSavePanel() - panel.title = "Export Configuration File" + panel.title = localized("Export Configuration File") panel.allowedContentTypes = Self.configurationContentTypes panel.nameFieldStringValue = model.defaultConfigurationFileName panel.begin { [weak self] response in @@ -356,7 +382,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { do { try self?.model.exportConfiguration(to: url) } catch { - self?.presentFileOperationError(error, title: "Export Configuration File") + self?.presentFileOperationError(error, title: localized("Export Configuration File")) } } } diff --git a/Sources/AirPortUtilityCore/AdvancedPane.swift b/Sources/AirPortUtilityCore/AdvancedPane.swift index 015dded..fc32f08 100644 --- a/Sources/AirPortUtilityCore/AdvancedPane.swift +++ b/Sources/AirPortUtilityCore/AdvancedPane.swift @@ -6,6 +6,10 @@ private enum AdvancedPaneSection: String, CaseIterable, Identifiable { case accessControl = "Access Control" var id: String { rawValue } + + /// Localized section title. `rawValue` stays English because it is the + /// enum's identity, and a raw value must be a compile-time literal. + var displayName: String { localized(rawValue) } } struct AdvancedPane: View { @@ -30,7 +34,7 @@ struct AdvancedPane: View { if visibleSections.count > 1 { Picker("", selection: $selectedSection) { ForEach(visibleSections) { section in - Text(section.rawValue).tag(section) + Text(section.displayName).tag(section) } } .pickerStyle(.segmented) @@ -62,19 +66,19 @@ struct AdvancedPane: View { @ViewBuilder private var loggingSettings: some View { - Text("This AirPort wireless device supports log messages that may help diagnose a problem.") + Text(localized("This AirPort wireless device supports log messages that may help diagnose a problem.")) .font(.system(size: 13)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 12) - FormRow(title: "Syslog Destination Address:") { + FormRow(title: localized("Syslog Destination Address:")) { AirPortTextField( text: $model.advanced.syslogDestinationAddress, identifier: "advanced.logging.syslog.destination") } - FormRow(title: "Syslog Level:") { + FormRow(title: localized("Syslog Level:")) { Picker("", selection: $model.advanced.syslogLevel) { ForEach(SyslogLevelOption.allCases) { option in Text(option.label).tag(option.level) @@ -86,7 +90,7 @@ struct AdvancedPane: View { } Text( - "Simple Network Management Protocol (SNMP) allows you to query this device for statistics, including the number of wireless clients." + localized("Simple Network Management Protocol (SNMP) allows you to query this device for statistics, including the number of wireless clients.") ) .font(.system(size: 13)) .foregroundStyle(.secondary) @@ -95,7 +99,7 @@ struct AdvancedPane: View { .padding(.top, 8) AdvancedCheckbox( - "Allow SNMP", + localized("Allow SNMP"), isOn: $model.advanced.allowSNMP, identifier: "advanced.logging.allow.snmp") .padding(.leading, AirPortLayout.formControlLeading) @@ -106,7 +110,7 @@ struct AdvancedPane: View { } AdvancedCheckbox( - "Allow SNMP over WAN", + localized("Allow SNMP over WAN"), isOn: $model.advanced.allowSNMPOverWAN, identifier: "advanced.logging.allow.snmp.over.wan") .padding(.leading, AirPortLayout.formControlLeading + 20) @@ -116,36 +120,36 @@ struct AdvancedPane: View { @ViewBuilder private var pppDialInSettings: some View { AdvancedCheckbox( - "PPP Dial-in", + localized("PPP Dial-in"), isOn: $model.advanced.pppDialInEnabled, identifier: "advanced.ppp.dial.in.enabled") .padding(.leading, AirPortLayout.formControlLeading) Group { - FormRow(title: "Account Name:") { + FormRow(title: localized("Account Name:")) { AirPortTextField( text: $model.advanced.pppDialInAccount, identifier: "advanced.ppp.dial.in.account") } - FormRow(title: "Password:") { + FormRow(title: localized("Password:")) { AirPortSecureField( text: $model.advanced.pppDialInPassword, identifier: "advanced.ppp.dial.in.password") .frame(height: 24) } - FormRow(title: "Verify Password:") { + FormRow(title: localized("Verify Password:")) { AirPortSecureField( text: $model.advanced.pppDialInVerifyPassword, identifier: "advanced.ppp.dial.in.verify.password") .frame(height: 24) } - FormRow(title: "Answer on ring:") { + FormRow(title: localized("Answer on ring:")) { TextField("", value: $model.advanced.pppDialInAnswerOnRing, format: .number) .textFieldStyle(.plain) .airPortField() .accessibilityIdentifier("advanced.ppp.dial.in.answer.on.ring") } - FormRow(title: "Idle Disconnect After:") { + FormRow(title: localized("Idle Disconnect After:")) { Picker("", selection: $model.advanced.pppDialInIdleSeconds) { ForEach(ModemIdleOption.allCases) { option in Text(option.label).tag(option.seconds) @@ -155,7 +159,7 @@ struct AdvancedPane: View { .labelsHidden() .accessibilityIdentifier("advanced.ppp.dial.in.idle.disconnect") } - FormRow(title: "Maximum Connect Time:") { + FormRow(title: localized("Maximum Connect Time:")) { Picker("", selection: $model.advanced.pppDialInMaximumConnectSeconds) { ForEach(PPPDialInMaximumConnectOption.allCases) { option in Text(option.label).tag(option.seconds) @@ -171,10 +175,10 @@ struct AdvancedPane: View { @ViewBuilder private var accessControlSettings: some View { - FormRow(title: "Access Control:") { + FormRow(title: localized("Access Control:")) { Picker("", selection: $model.legacyDeviceOptions.accessControl.mode) { - Text("Not enabled").tag("not-enabled") - Text("Local").tag("local") + Text(localized("Not enabled")).tag("not-enabled") + Text(localized("Local")).tag("local") Text("RADIUS").tag("radius") } .pickerStyle(.menu) @@ -188,7 +192,7 @@ struct AdvancedPane: View { case "radius": radiusAccessControlSettings default: - Text("All wireless clients are allowed to join this network.") + Text(localized("All wireless clients are allowed to join this network.")) .font(.system(size: 13)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) @@ -198,7 +202,7 @@ struct AdvancedPane: View { @ViewBuilder private var localAccessControlSettings: some View { - Text("Allow only the wireless clients listed below.") + Text(localized("Allow only the wireless clients listed below.")) .font(.system(size: 13)) .foregroundStyle(.secondary) .padding(.leading, 12) @@ -208,7 +212,7 @@ struct AdvancedPane: View { ForEach($model.legacyDeviceOptions.accessControl.entries) { $entry in VStack(spacing: 8) { HStack(spacing: 8) { - Text("AirPort ID:") + Text(localized("AirPort ID:")) .font(.system(size: 12)) .frame(width: 74, alignment: .trailing) AirPortTextField( @@ -223,10 +227,10 @@ struct AdvancedPane: View { Image(systemName: "minus.circle") } .buttonStyle(.plain) - .accessibilityLabel("Remove access-control entry") + .accessibilityLabel(localized("Remove access-control entry")) } HStack(spacing: 8) { - Text("Description:") + Text(localized("Description:")) .font(.system(size: 12)) .frame(width: 74, alignment: .trailing) AirPortTextField( @@ -243,7 +247,7 @@ struct AdvancedPane: View { } .frame(maxHeight: 260) - Button("Add Client") { + Button(localized("Add Client")) { model.legacyDeviceOptions.accessControl.entries.append(AccessControlEntry()) } .accessibilityIdentifier("advanced.access.control.add.client") @@ -255,33 +259,33 @@ struct AdvancedPane: View { private var radiusAccessControlSettings: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { - FormRow(title: "RADIUS Type:") { + FormRow(title: localized("RADIUS Type:")) { Picker("", selection: $model.legacyDeviceOptions.accessControl.radiusType) { - Text("Default").tag("default") - Text("Alternate").tag("alternate") + Text(localized("Default")).tag("default") + Text(localized("Alternate")).tag("alternate") } .pickerStyle(.menu) .labelsHidden() .accessibilityIdentifier("advanced.access.control.radius.type") } - FormRow(title: "Primary Server:") { + FormRow(title: localized("Primary Server:")) { AirPortTextField( text: $model.legacyDeviceOptions.accessControl.primaryAddress, identifier: "advanced.access.control.radius.primary.address") } - FormRow(title: "Shared Secret:") { + FormRow(title: localized("Shared Secret:")) { AirPortSecureField( text: $model.legacyDeviceOptions.accessControl.primarySecret, identifier: "advanced.access.control.radius.primary.secret") .frame(height: 24) } - FormRow(title: "Verify Secret:") { + FormRow(title: localized("Verify Secret:")) { AirPortSecureField( text: $model.legacyDeviceOptions.accessControl.primaryVerifySecret, identifier: "advanced.access.control.radius.primary.verify.secret") .frame(height: 24) } - FormRow(title: "Primary Port:") { + FormRow(title: localized("Primary Port:")) { TextField( "", value: $model.legacyDeviceOptions.accessControl.primaryPort, format: .number ) @@ -289,24 +293,24 @@ struct AdvancedPane: View { .airPortField() .accessibilityIdentifier("advanced.access.control.radius.primary.port") } - FormRow(title: "Secondary Server:") { + FormRow(title: localized("Secondary Server:")) { AirPortTextField( text: $model.legacyDeviceOptions.accessControl.secondaryAddress, identifier: "advanced.access.control.radius.secondary.address") } - FormRow(title: "Shared Secret:") { + FormRow(title: localized("Shared Secret:")) { AirPortSecureField( text: $model.legacyDeviceOptions.accessControl.secondarySecret, identifier: "advanced.access.control.radius.secondary.secret") .frame(height: 24) } - FormRow(title: "Verify Secret:") { + FormRow(title: localized("Verify Secret:")) { AirPortSecureField( text: $model.legacyDeviceOptions.accessControl.secondaryVerifySecret, identifier: "advanced.access.control.radius.secondary.verify.secret") .frame(height: 24) } - FormRow(title: "Secondary Port:") { + FormRow(title: localized("Secondary Port:")) { TextField( "", value: $model.legacyDeviceOptions.accessControl.secondaryPort, format: .number ) diff --git a/Sources/AirPortUtilityCore/AirPlayPane.swift b/Sources/AirPortUtilityCore/AirPlayPane.swift index 28593ea..6fbc598 100644 --- a/Sources/AirPortUtilityCore/AirPlayPane.swift +++ b/Sources/AirPortUtilityCore/AirPlayPane.swift @@ -8,28 +8,28 @@ struct AirPlayPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) BaseStationCheckbox( - "Enable AirPlay", isOn: $model.airPlay.enabled, + localized("Enable AirPlay"), isOn: $model.airPlay.enabled, identifier: "airplay.enabled") } - FormRow(title: "AirPlay Speaker Name:") { + FormRow(title: localized("AirPlay Speaker Name:")) { AirPortTextField( text: $model.airPlay.speakerName, - placeholder: "Speaker name", + placeholder: localized("Speaker name"), identifier: "airplay.speaker.name") } .disabled(!model.airPlay.enabled) - FormRow(title: "AirPlay Speaker Password:") { + FormRow(title: localized("AirPlay Speaker Password:")) { AirPortSecureField( text: $model.airPlay.speakerPassword, - placeholder: "Speaker password", + placeholder: localized("Speaker password"), identifier: "airplay.speaker.password") .frame(height: 24) } .disabled(!model.airPlay.enabled) - FormRow(title: "Verify Password:") { + FormRow(title: localized("Verify Password:")) { AirPortSecureField( text: $model.airPlay.verifySpeakerPassword, - placeholder: "Verify speaker password", + placeholder: localized("Verify speaker password"), identifier: "airplay.speaker.verify.password") .frame(height: 24) } @@ -37,7 +37,7 @@ struct AirPlayPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) BaseStationCheckbox( - "Remember this password in my keychain", + localized("Remember this password in my keychain"), isOn: Binding( get: { model.airPlay.rememberPassword }, set: { model.updateRememberAirPlayPassword($0) }), @@ -47,7 +47,7 @@ struct AirPlayPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) BaseStationCheckbox( - "Enable AirPlay over WAN", isOn: $model.airPlay.overWAN, + localized("Enable AirPlay over WAN"), isOn: $model.airPlay.overWAN, identifier: "airplay.over.wan") } .disabled(!model.airPlay.enabled) diff --git a/Sources/AirPortUtilityCore/AirPortBonjourBrowser.swift b/Sources/AirPortUtilityCore/AirPortBonjourBrowser.swift index 1048e85..b04bca8 100644 --- a/Sources/AirPortUtilityCore/AirPortBonjourBrowser.swift +++ b/Sources/AirPortUtilityCore/AirPortBonjourBrowser.swift @@ -3,12 +3,26 @@ import Foundation final class AirPortBonjourBrowser: NSObject { private let serviceTypes = ["_airport._tcp."] + + /// File-sharing services a base station publishes when sharing is enabled, + /// mapped to the label shown in the device popover. + /// + /// Presence is all Bonjour reports. It does not advertise an SMB dialect -- + /// that is negotiated per connection -- so this can say SMB but never SMB2. + private static let fileSharingServiceTypes = [ + "_afpovertcp._tcp.": "AFP", + "_smb._tcp.": "SMB", + ] private static let stableIdentifierTXTKeys = ["wama", "rama", "sysn"] private static let knownModelNameFragments = ["express", "time capsule", "extreme"] private let onChange: @MainActor ([AirportDiscoveredDevice]) -> Void private var browsers: [NetServiceBrowser] = [] private var services: [String: NetService] = [:] private var txtRecords: [String: Data] = [:] + /// Lowercased service name -> protocol labels it publishes. A Time Capsule + /// publishes its file-sharing services under the device name, so the name is + /// what ties them back to the AirPort service. + private var fileSharingProtocols: [String: Set] = [:] init(onChange: @escaping @MainActor ([AirportDiscoveredDevice]) -> Void) { self.onChange = onChange @@ -17,7 +31,7 @@ final class AirPortBonjourBrowser: NSObject { func start() { stop() - for serviceType in serviceTypes { + for serviceType in serviceTypes + Array(Self.fileSharingServiceTypes.keys) { let browser = NetServiceBrowser() browser.delegate = self browser.searchForServices(ofType: serviceType, inDomain: "local.") @@ -38,6 +52,7 @@ final class AirPortBonjourBrowser: NSObject { } services.removeAll() txtRecords.removeAll() + fileSharingProtocols.removeAll() publish() } @@ -70,10 +85,18 @@ final class AirPortBonjourBrowser: NSObject { identifiers: Self.stableIdentifiers(fromTXTRecord: txtRecord), txtFields: txtFields, modelName: Self.modelName(fromTXTFields: txtFields), - productID: txtFields["syap"] ?? "" + productID: txtFields["syap"] ?? "", + publishedProtocols: publishedProtocols(forServiceNamed: service.name) ) } + /// Protocol labels published under `name`, ordered so the row reads the same + /// way every time rather than in set order. + private func publishedProtocols(forServiceNamed name: String) -> [String] { + let found = fileSharingProtocols[name.lowercased()] ?? [] + return Self.fileSharingServiceTypes.values.sorted().filter(found.contains) + } + static func stableIdentifiers(fromTXTRecord txtRecord: [String: Data]) -> [String] { let fields = airportTXTFields(from: txtRecord) return stableIdentifierTXTKeys.compactMap { key in @@ -166,6 +189,17 @@ extension AirPortBonjourBrowser: NetServiceBrowserDelegate { func netServiceBrowser( _ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool ) { + // A file-sharing service is evidence about a device, not a device. Record + // which protocols its name publishes and stop; letting it through would + // list every share as its own base station. + if let label = Self.fileSharingServiceTypes[service.type] { + fileSharingProtocols[service.name.lowercased(), default: []].insert(label) + if !moreComing { + publish() + } + return + } + let serviceKey = key(for: service) if let previousService = services[serviceKey], previousService !== service { previousService.stopMonitoring() @@ -185,6 +219,18 @@ extension AirPortBonjourBrowser: NetServiceBrowserDelegate { func netServiceBrowser( _ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool ) { + if let label = Self.fileSharingServiceTypes[service.type] { + let name = service.name.lowercased() + fileSharingProtocols[name]?.remove(label) + if fileSharingProtocols[name]?.isEmpty == true { + fileSharingProtocols.removeValue(forKey: name) + } + if !moreComing { + publish() + } + return + } + let serviceKey = key(for: service) guard let storedService = services[serviceKey], storedService === service else { return } services.removeValue(forKey: serviceKey) diff --git a/Sources/AirPortUtilityCore/AirPortCommandRunner.swift b/Sources/AirPortUtilityCore/AirPortCommandRunner.swift index a8f9a14..abde8a5 100644 --- a/Sources/AirPortUtilityCore/AirPortCommandRunner.swift +++ b/Sources/AirPortUtilityCore/AirPortCommandRunner.swift @@ -36,7 +36,7 @@ enum AirportCommandError: LocalizedError, Sendable { return "Command failed with exit \(result.exitCode)" } if DiskInventoryMessage.containsPendingPlaceholder(output) { - return "Disk information is not available yet." + return localized("Disk information is not available yet.") } let lowercased = output.lowercased() if lowercased.contains("nodename nor servname") @@ -50,7 +50,7 @@ enum AirportCommandError: LocalizedError, Sendable { "Could not find \(host). Check that the Time Capsule is on this network, or enter its IP address instead of the .local name." } return - "Could not find the Time Capsule. Check that it is on this network, or enter its IP address instead of the .local name." + localized("Could not find the Time Capsule. Check that it is on this network, or enter its IP address instead of the .local name.") } return output } diff --git a/Sources/AirPortUtilityCore/AirPortModels.swift b/Sources/AirPortUtilityCore/AirPortModels.swift index 53cbc2b..878067d 100644 --- a/Sources/AirPortUtilityCore/AirPortModels.swift +++ b/Sources/AirPortUtilityCore/AirPortModels.swift @@ -27,6 +27,17 @@ struct AirportConnection: Equatable, Sendable { return currentURL.path } + // A packaged .app ships the backend at Contents/Resources/backend. The + // executable-relative walk below only finds a backend that sits beside the + // binary, which is the layout of a source checkout, not of a bundle. + // Resources is the correct home for it: everything in Contents/MacOS is + // treated as code by codesign, and the backend is not a Mach-O executable. + if let resourceURL = Bundle.main.resourceURL, + containsBackendScripts(resourceURL, fileManager: fileManager) + { + return resourceURL.path + } + if let executableURL = Bundle.main.executableURL { var candidate = executableURL.deletingLastPathComponent() for _ in 0..<10 { @@ -97,11 +108,11 @@ struct WirelessClient: Codable, Equatable, Identifiable, Sendable { private var detailMACAddress: String { let address = macAddress.trimmingCharacters(in: .whitespacesAndNewlines) - return address.isEmpty ? "Unknown" : address.uppercased() + return address.isEmpty ? localized("Unknown") : address.uppercased() } private var qualityLabel: String { - guard let rssi = normalizedRSSI else { return "Unknown" } + guard let rssi = normalizedRSSI else { return localized("Unknown") } switch rssi { case ..<(-99): return "Poor" case -99...(-90): return "Fair" @@ -118,7 +129,7 @@ struct WirelessClient: Codable, Equatable, Identifiable, Sendable { dataRateMbps >= 0, dataRateMbps < Double(Int.max) else { - return "Unknown" + return localized("Unknown") } let value = dataRateMbps.rounded() == dataRateMbps @@ -128,13 +139,13 @@ struct WirelessClient: Codable, Equatable, Identifiable, Sendable { } private var rssiLabel: String { - guard let rssi = normalizedRSSI else { return "Unknown" } + guard let rssi = normalizedRSSI else { return localized("Unknown") } return "\(rssi) dBm" } private var phyModeLabel: String { let mode = phyMode?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return mode.isEmpty ? "Unknown" : mode + return mode.isEmpty ? localized("Unknown") : mode } } @@ -186,6 +197,13 @@ enum Pane: String, CaseIterable, Identifiable, Sendable, Codable { case firmware = "Firmware" var id: String { rawValue } + + /// Localized tab title. + /// + /// `rawValue` deliberately stays English: it is persisted through `Codable`, + /// used to build snapshot file names, and used for accessibility + /// identifiers, none of which may shift with the user's language. + var displayName: String { localized(rawValue) } } enum ConnectUsing: String, CaseIterable, Identifiable, Sendable, Codable { @@ -214,9 +232,9 @@ enum RouterMode: String, CaseIterable, Identifiable, Sendable, Codable { var id: String { rawValue } var label: String { switch self { - case .dhcpAndNat: "DHCP and NAT" - case .dhcpOnly: "DHCP Only" - case .natOnly: "NAT Only" + case .dhcpAndNat: localized("DHCP and NAT") + case .dhcpOnly: localized("DHCP Only") + case .natOnly: localized("NAT Only") case .bridge: "Off (Bridge Mode)" } } @@ -232,9 +250,9 @@ enum EraseMethod: String, CaseIterable, Identifiable, Sendable { var label: String { switch self { case .quick: "Quick Erase" - case .zero: "Zero Out Data" - case .sevenPass: "7-Pass Erase" - case .thirtyFivePass: "35-Pass Erase" + case .zero: localized("Zero Out Data") + case .sevenPass: localized("7-Pass Erase") + case .thirtyFivePass: localized("35-Pass Erase") } } } @@ -244,7 +262,8 @@ struct BaseStationState: Equatable, Codable { var serialNumber = "" var version = "" var productID = "" - var statusText = "Working normally" + // Must match DeviceStatusMessage.text([]) -- the topology compares them. + var statusText = localized("Working normally") var problemCodes: [String] = [] var newAdminPassword = "" var verifyAdminPassword = "" @@ -257,7 +276,7 @@ struct BaseStationState: Equatable, Codable { serialNumber: String = "", version: String = "", productID: String = "", - statusText: String = "Working normally", + statusText: String = localized("Working normally"), problemCodes: [String] = [], newAdminPassword: String = "", verifyAdminPassword: String = "", @@ -299,7 +318,7 @@ struct BaseStationState: Equatable, Codable { version = try container.decodeIfPresent(String.self, forKey: .version) ?? "" productID = try container.decodeIfPresent(String.self, forKey: .productID) ?? "" statusText = - try container.decodeIfPresent(String.self, forKey: .statusText) ?? "Working normally" + try container.decodeIfPresent(String.self, forKey: .statusText) ?? localized("Working normally") problemCodes = try container.decodeIfPresent([String].self, forKey: .problemCodes) ?? [] newAdminPassword = try container.decodeIfPresent(String.self, forKey: .newAdminPassword) ?? "" verifyAdminPassword = @@ -362,9 +381,9 @@ struct ModemIdleOption: Identifiable, Equatable, Sendable { ModemIdleOption(seconds: 120, label: "2 minutes"), ModemIdleOption(seconds: 300, label: "5 minutes"), ModemIdleOption(seconds: 600, label: "10 minutes"), - ModemIdleOption(seconds: 900, label: "15 minutes"), + ModemIdleOption(seconds: 900, label: localized("15 minutes")), ModemIdleOption(seconds: 1_200, label: "20 minutes"), - ModemIdleOption(seconds: 1_800, label: "30 minutes"), + ModemIdleOption(seconds: 1_800, label: localized("30 minutes")), ] } @@ -389,8 +408,8 @@ struct PPPoEConnectionOption: Identifiable, Equatable, Sendable { let label: String static let allCases: [PPPoEConnectionOption] = [ - PPPoEConnectionOption(value: "always-on", label: "Always On"), - PPPoEConnectionOption(value: "automatic", label: "Automatic"), + PPPoEConnectionOption(value: "always-on", label: localized("Always On")), + PPPoEConnectionOption(value: "automatic", label: localized("Automatic")), PPPoEConnectionOption(value: "manual", label: "Manual"), ] } @@ -477,11 +496,51 @@ struct WirelessRadioModeOption: Identifiable, Equatable, Sendable { } struct WirelessRegionOption: Identifiable, Equatable, Sendable { + /// ACP region index. Wire value -- never localized. var code: String + /// English region name. Doubles as the lookup key and the fallback. var name: String var id: String { code } + /// The region name in the user's language. + /// + /// Foundation is the source of truth here: hand-translating 172 country + /// names into four languages would be redundant, and Locale already carries + /// authoritative names for every one of them. Only the names Apple spells + /// differently from the current ISO English need an alias. + /// The ISO region this option maps to, if any. Internal so a test can assert + /// the whole list maps; an unmapped entry silently falls back to English. + var isoRegionCode: String? { Self.isoRegionCodesByEnglishName[name.lowercased()] } + + var localizedName: String { + guard let region = Self.isoRegionCodesByEnglishName[name.lowercased()] else { + return name + } + return AirPortLocalization.locale.localizedString(forRegionCode: region) ?? name + } + + /// Names this list spells differently from Foundation's current English. + private static let isoRegionAliases: [String: String] = [ + "czech republic": "CZ", "slovak republic": "SK", "hong kong s.a.r., china": "HK", + "china": "CN", "antigua and barbuda": "AG", "bosnia herzegovina": "BA", + "british indian ocean territory": "IO", "cocos islands": "CC", "congo": "CG", + "ivory coast": "CI", "east timor": "TL", "guinea bissau": "GW", "macau": "MO", + "macedonia": "MK", "trinidad and tobago": "TT", "turkey": "TR", + "us virgin islands": "VI", "myanmar": "MM", + ] + + private static let isoRegionCodesByEnglishName: [String: String] = { + let english = Locale(identifier: "en_US") + var map: [String: String] = [:] + for region in Locale.Region.isoRegions.map(\.identifier) { + if let name = english.localizedString(forRegionCode: region) { + map[name.lowercased()] = region + } + } + return map.merging(isoRegionAliases) { _, alias in alias } + }() + static let allCases = [ WirelessRegionOption(code: "0", name: "United States"), WirelessRegionOption(code: "1", name: "Canada"), @@ -688,11 +747,11 @@ struct DHCPLeaseUnitOption: Identifiable, Equatable, Sendable { let label: String static let allCases: [DHCPLeaseUnitOption] = [ - DHCPLeaseUnitOption(value: "seconds", label: "second"), - DHCPLeaseUnitOption(value: "minutes", label: "minute"), - DHCPLeaseUnitOption(value: "hours", label: "hour"), - DHCPLeaseUnitOption(value: "days", label: "day"), - DHCPLeaseUnitOption(value: "weeks", label: "week"), + DHCPLeaseUnitOption(value: "seconds", label: localized("second")), + DHCPLeaseUnitOption(value: "minutes", label: localized("minute")), + DHCPLeaseUnitOption(value: "hours", label: localized("hour")), + DHCPLeaseUnitOption(value: "days", label: localized("day")), + DHCPLeaseUnitOption(value: "weeks", label: localized("week")), ] } @@ -809,14 +868,14 @@ struct SyslogLevelOption: Identifiable, Equatable, Sendable { let label: String static let allCases = [ - SyslogLevelOption(level: 0, label: "0 - Emergency"), - SyslogLevelOption(level: 1, label: "1 - Alert"), - SyslogLevelOption(level: 2, label: "2 - Critical"), - SyslogLevelOption(level: 3, label: "3 - Error"), - SyslogLevelOption(level: 4, label: "4 - Warning"), - SyslogLevelOption(level: 5, label: "5 - Notice"), - SyslogLevelOption(level: 6, label: "6 - Informational"), - SyslogLevelOption(level: 7, label: "7 - Debug"), + SyslogLevelOption(level: 0, label: localized("0 - Emergency")), + SyslogLevelOption(level: 1, label: localized("1 - Alert")), + SyslogLevelOption(level: 2, label: localized("2 - Critical")), + SyslogLevelOption(level: 3, label: localized("3 - Error")), + SyslogLevelOption(level: 4, label: localized("4 - Warning")), + SyslogLevelOption(level: 5, label: localized("5 - Notice")), + SyslogLevelOption(level: 6, label: localized("6 - Informational")), + SyslogLevelOption(level: 7, label: localized("7 - Debug")), ] } @@ -826,13 +885,13 @@ struct PPPDialInMaximumConnectOption: Identifiable, Equatable, Sendable { let label: String static let allCases = [ - PPPDialInMaximumConnectOption(seconds: 0, label: "Never Disconnect"), - PPPDialInMaximumConnectOption(seconds: 900, label: "15 minutes"), - PPPDialInMaximumConnectOption(seconds: 1_800, label: "30 minutes"), - PPPDialInMaximumConnectOption(seconds: 3_600, label: "1 hour"), - PPPDialInMaximumConnectOption(seconds: 7_200, label: "2 hours"), - PPPDialInMaximumConnectOption(seconds: 14_400, label: "4 hours"), - PPPDialInMaximumConnectOption(seconds: 28_800, label: "8 hours"), + PPPDialInMaximumConnectOption(seconds: 0, label: localized("Never Disconnect")), + PPPDialInMaximumConnectOption(seconds: 900, label: localized("15 minutes")), + PPPDialInMaximumConnectOption(seconds: 1_800, label: localized("30 minutes")), + PPPDialInMaximumConnectOption(seconds: 3_600, label: localized("1 hour")), + PPPDialInMaximumConnectOption(seconds: 7_200, label: localized("2 hours")), + PPPDialInMaximumConnectOption(seconds: 14_400, label: localized("4 hours")), + PPPDialInMaximumConnectOption(seconds: 28_800, label: localized("8 hours")), ] } @@ -961,6 +1020,39 @@ struct DeviceCapabilities: Equatable, Codable { private static let legacyOptionProductIDs: Set = ["3"] } +/// Hardware generation for a base station, derived from its ACP product ID. +/// +/// Generations are numbered per product line, so a Time Capsule and an AirPort +/// Extreme can share a number without being the same hardware. The device does +/// not report a generation itself; the product ID is the only signal. +/// +/// Mapping taken from jamesyc/TimeCapsuleSMB, which reads the same `syAP` field. +enum AirPortDeviceGeneration { + private static let generationsByProductID = [ + "104": 1, "105": 2, "108": 3, "114": 4, "117": 5, "120": 6, + "106": 1, "109": 2, "113": 3, "116": 4, "119": 5, + ] + + /// Localized generation label, or nil for a product ID with no known + /// generation -- better to show nothing than to guess at unfamiliar hardware. + static func label(forProductID productID: String) -> String? { + let productID = productID.trimmingCharacters(in: .whitespacesAndNewlines) + guard let generation = generationsByProductID[productID] else { return nil } + // Written out per language rather than composed from a number: ordinals + // carry gender and abbreviation rules that a formatter gets wrong here + // ("1re génération", not "1er génération"). + switch generation { + case 1: return localized("1st generation") + case 2: return localized("2nd generation") + case 3: return localized("3rd generation") + case 4: return localized("4th generation") + case 5: return localized("5th generation") + case 6: return localized("6th generation") + default: return nil + } + } +} + struct FirmwareImage: Identifiable, Equatable, Sendable, Codable { var productID: String var version: String @@ -1011,7 +1103,7 @@ enum FirmwareTransferPhase: String, Equatable, Sendable { case .none: "" case .download: - "Downloading from Apple" + localized("Downloading from Apple") case .upload: "Uploading to AirPort" case .program: @@ -1152,6 +1244,17 @@ struct DiskRecord: Identifiable, Equatable, Sendable, Codable { var size: Int64? var sizeFree: Int64? var builtIn: Bool + /// Drive vendor and model as one string, e.g. "WDC WD20EARX-00PASB0". + /// Reported on the physical disk, so partitions inherit it. + var vendor: String = "" + /// Drive firmware revision, e.g. "51.0AB51". Also per physical disk. + var revision: String = "" + /// Bytes in use on this partition. + var sizeUsed: Int64? + /// SMART status as reported in the disk inventory, e.g. "Verified". Carried + /// down from the disk to its partitions, since the device reports it once per + /// physical disk. Empty when the device does not report one. + var smartStatus: String = "" } struct AirportDiscoveredDevice: Identifiable, Equatable, Sendable { @@ -1165,6 +1268,9 @@ struct AirportDiscoveredDevice: Identifiable, Equatable, Sendable { var modelName: String = "" var productID: String = "" var statusText: String = "" + /// File-sharing protocols the device advertises over Bonjour, such as AFP or + /// SMB. Presence only: Bonjour does not advertise an SMB dialect. + var publishedProtocols: [String] = [] var displayName: String { let displayName = name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1175,7 +1281,7 @@ struct AirportDiscoveredDevice: Identifiable, Equatable, Sendable { var displayModelName: String { let modelName = modelName.trimmingCharacters(in: .whitespacesAndNewlines) if !modelName.isEmpty { return modelName } - return "AirPort Base Station" + return localized("AirPort Base Station") } var connectionHost: String { diff --git a/Sources/AirPortUtilityCore/AirPortServices.swift b/Sources/AirPortUtilityCore/AirPortServices.swift index a1a3ccb..d4c46e8 100644 --- a/Sources/AirPortUtilityCore/AirPortServices.swift +++ b/Sources/AirPortUtilityCore/AirPortServices.swift @@ -5,7 +5,7 @@ import SwiftUI public final class AirportAppModel: ObservableObject { @Published var connection = AirportConnection() @Published var selectedPane: Pane = .baseStation - @Published var status = "Not connected" + @Published var status = localized("Not connected") @Published var isBusy = false @Published var logs: [String] = [] @Published var preview: CommandPreview? @@ -279,9 +279,9 @@ public final class AirportAppModel: ObservableObject { if mockMode { loadMockState() } else if liveCredentialsAvailable { - status = "Ready to connect to \(connection.host)" + status = localizedFormat("Ready to connect to %@", connection.host) } else { - status = "Enter base station password to load settings." + status = localized("Enter base station password to load settings.") } } @@ -297,7 +297,7 @@ public final class AirportAppModel: ObservableObject { hasTrustedConnectionPassword = true } let requestHost = AirportConnection.normalizedHost(connection.host) - runTask("Refreshing settings", requestHost: requestHost) { + runTask(localized("Refreshing settings"), requestHost: requestHost) { try await self.refreshSettings() } } @@ -306,27 +306,27 @@ public final class AirportAppModel: ObservableObject { let connection = connection let name = baseStation.name.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { - status = "Base Station Name cannot be empty." + status = localized("Base Station Name cannot be empty.") clearPreviewAfterValidationFailure() return } let args = AirportCommand.rawWrite( setting: "syNm", value: name, connection: connection, dryRun: true) - dryRun(title: "Base Station Name", args: args, connection: connection) + dryRun(title: localized("Base Station Name"), args: args, connection: connection) } func applyBaseStationName() { let connection = connection let name = baseStation.name.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { - status = "Base Station Name cannot be empty." + status = localized("Base Station Name cannot be empty.") clearPreviewAfterValidationFailure() return } let args = appliedWriteArguments( AirportCommand.rawWrite(setting: "syNm", value: name, connection: connection, dryRun: false)) apply( - title: "Base Station Name", args: args, connection: connection, + title: localized("Base Station Name"), args: args, connection: connection, cleanScope: .baseStationName) } @@ -338,13 +338,13 @@ public final class AirportAppModel: ObservableObject { guard !newPassword.isEmpty, newPassword == verifyPassword else { - status = "Admin passwords do not match." + status = localized("Admin passwords do not match.") clearPreviewAfterValidationFailure() return } let args = AirportCommand.rawWrite( setting: "syPW", value: newPassword, connection: connection, dryRun: true) - dryRun(title: "Admin Password", args: args, connection: connection) + dryRun(title: localized("Admin Password"), args: args, connection: connection) } func applyAdminPassword() { @@ -355,7 +355,7 @@ public final class AirportAppModel: ObservableObject { guard !newPassword.isEmpty, newPassword == verifyPassword else { - status = "Admin passwords do not match." + status = localized("Admin passwords do not match.") clearPreviewAfterValidationFailure() return } @@ -363,7 +363,7 @@ public final class AirportAppModel: ObservableObject { AirportCommand.rawWrite( setting: "syPW", value: newPassword, connection: connection, dryRun: false)) apply( - title: "Admin Password", args: args, connection: connection, cleanScope: .adminPassword, + title: localized("Admin Password"), args: args, connection: connection, cleanScope: .adminPassword, appliedAdminPassword: newPassword ) { self.updateConnectionPasswordAfterAdminChange(newPassword) @@ -379,7 +379,7 @@ public final class AirportAppModel: ObservableObject { } guard !commands.isEmpty else { preview = nil - status = "No pending Base Station changes to preview." + status = localized("No pending Base Station changes to preview.") return } dryRunSequence(title: "Base Station", commands: commands, connection: connection) @@ -393,7 +393,7 @@ public final class AirportAppModel: ObservableObject { } guard !commands.isEmpty else { preview = nil - status = "No pending Base Station changes to apply." + status = localized("No pending Base Station changes to apply.") return } let newPassword = baseStation.newAdminPassword.trimmingCharacters(in: .whitespacesAndNewlines) @@ -412,7 +412,7 @@ public final class AirportAppModel: ObservableObject { func previewInternet() { previewFriendlySettings( title: "Internet", - noChangesStatus: "No pending Internet changes to preview." + noChangesStatus: localized("No pending Internet changes to preview.") ) { internetFlags(changesOnly: true) } @@ -421,7 +421,7 @@ public final class AirportAppModel: ObservableObject { func applyInternet() { applyFriendlySettings( title: "Internet", - noChangesStatus: "No pending Internet changes to apply.", + noChangesStatus: localized("No pending Internet changes to apply."), cleanScope: .internet ) { internetFlags(changesOnly: true) @@ -486,7 +486,7 @@ public final class AirportAppModel: ObservableObject { func previewWireless() { previewFriendlySettings( title: "Wireless", - noChangesStatus: "No pending Wireless changes to preview." + noChangesStatus: localized("No pending Wireless changes to preview.") ) { wirelessFlags(changesOnly: true) } @@ -495,7 +495,7 @@ public final class AirportAppModel: ObservableObject { func applyWireless() { applyFriendlySettings( title: "Wireless", - noChangesStatus: "No pending Wireless changes to apply.", + noChangesStatus: localized("No pending Wireless changes to apply."), cleanScope: .wireless ) { wirelessFlags(changesOnly: true) @@ -505,7 +505,7 @@ public final class AirportAppModel: ObservableObject { func previewNetwork() { previewFriendlySettings( title: "Network", - noChangesStatus: "No pending Network changes to preview." + noChangesStatus: localized("No pending Network changes to preview.") ) { networkFlags(changesOnly: true) } @@ -514,7 +514,7 @@ public final class AirportAppModel: ObservableObject { func applyNetwork() { applyFriendlySettings( title: "Network", - noChangesStatus: "No pending Network changes to apply.", + noChangesStatus: localized("No pending Network changes to apply."), cleanScope: .network ) { networkFlags(changesOnly: true) @@ -523,13 +523,13 @@ public final class AirportAppModel: ObservableObject { func previewAirPlay() { guard supportsPane(.airPlay) else { - status = "This base station does not support AirPlay." + status = localized("This base station does not support AirPlay.") clearPreviewAfterValidationFailure() return } previewFriendlySettings( title: "AirPlay", - noChangesStatus: "No pending AirPlay changes to preview." + noChangesStatus: localized("No pending AirPlay changes to preview.") ) { airPlayFlags(changesOnly: true) } @@ -537,13 +537,13 @@ public final class AirportAppModel: ObservableObject { func applyAirPlay() { guard supportsPane(.airPlay) else { - status = "This base station does not support AirPlay." + status = localized("This base station does not support AirPlay.") clearPreviewAfterValidationFailure() return } applyFriendlySettings( title: "AirPlay", - noChangesStatus: "No pending AirPlay changes to apply.", + noChangesStatus: localized("No pending AirPlay changes to apply."), cleanScope: .airPlay, completion: { self.persistAuxiliaryPasswordPreferences(from: $0) } ) { @@ -577,7 +577,7 @@ public final class AirportAppModel: ObservableObject { let snapshot = currentSnapshot guard comparable(snapshot) != comparable(cleanSnapshot) else { preview = nil - status = "No pending changes to apply." + status = localized("No pending changes to apply.") return } var commands: [(String, [String])] = [] @@ -593,7 +593,7 @@ public final class AirportAppModel: ObservableObject { return } for command in baseCommands { - if command.0 == "Admin Password" { + if command.0 == localized("Admin Password") { finalCommands.append(command) } else { commands.append(command) @@ -667,7 +667,7 @@ public final class AirportAppModel: ObservableObject { if !flags.isEmpty { commands.append( ( - "Disk Sharing", + localized("Disk Sharing"), AirportCommand.friendlyWrite(connection: connection, flags: flags, dryRun: false) )) } @@ -698,14 +698,14 @@ public final class AirportAppModel: ObservableObject { clearPreviewAfterValidationFailure() return } - commandsForApply = [("Settings", combined)] + commandsForApply = [(localized("Settings"), combined)] } else { commandsForApply = pendingCommands } let orderedCommands = appliedFinalCommand(commandsForApply) guard !orderedCommands.isEmpty else { preview = nil - status = "No pending changes to apply." + status = localized("No pending changes to apply.") return } guard mockMode || liveCredentialsAvailable else { @@ -715,7 +715,7 @@ public final class AirportAppModel: ObservableObject { return } applySequence( - title: "Settings", commands: orderedCommands, connection: connection, cleanScope: .all, + title: localized("Settings"), commands: orderedCommands, connection: connection, cleanScope: .all, appliedSnapshot: snapshot, appliedAdminPassword: adminPassword ) { @@ -739,7 +739,7 @@ public final class AirportAppModel: ObservableObject { guard let passwordFlag = arguments.firstIndex(of: "--password"), arguments.indices.contains(passwordFlag + 1) else { - status = "Could not combine legacy settings into one update." + status = localized("Could not combine legacy settings into one update.") return nil } let payloadStart = passwordFlag + 2 @@ -773,7 +773,7 @@ public final class AirportAppModel: ObservableObject { let data = try? JSONSerialization.data(withJSONObject: values, options: [.sortedKeys]), let json = String(data: data, encoding: .utf8) else { - status = "Could not encode the combined legacy settings update." + status = localized("Could not encode the combined legacy settings update.") return nil } arguments += ["--values-json", json] @@ -937,13 +937,13 @@ public final class AirportAppModel: ObservableObject { func updateIdleConnectionStatus() { if mockMode { - status = "Connected to \(connection.host). Mock mode." + status = localizedFormat("Connected to %@. Mock mode.", connection.host) return } status = liveCredentialsAvailable - ? "Ready to connect to \(connection.host)" - : "Enter base station password to load settings." + ? localizedFormat("Ready to connect to %@", connection.host) + : localized("Enter base station password to load settings.") } nonisolated static func uniqueNonEmptyValues(_ values: [String]) -> [String] { @@ -1096,26 +1096,10 @@ public final class AirportAppModel: ObservableObject { rememberPassword: true, windowsWorkgroup: "WORKGROUP", winsServer: "", - inventory: [ - DiskRecord( - deviceName: "dk2", - name: "Jack's Time Capsule Home", - format: "HFS", - uuid: "adabbc6e09e0579081f8444e687f35b9", - size: 1_000_000_000_000, - sizeFree: 497_850_000_000, - builtIn: true - ), - DiskRecord( - deviceName: "dk3", - name: "USB Archive Disk", - format: "HFS", - uuid: "22222222222222222222222222222222", - size: 2_000_000_000_000, - sizeFree: 1_500_000_000_000, - builtIn: false - ), - ], + // Parsed from the same fixture the mock backend serves, rather than + // duplicated as literals: the two drifted, so the pane showed neither the + // drive detail nor the used size that the fixture already carried. + inventory: AirportMockBackend.diskInventoryRefreshResult.records, rawInventory: AirportMockBackend.maStJSON, didLoadInventory: true ) @@ -1134,9 +1118,9 @@ public final class AirportAppModel: ObservableObject { firmware.currentVersion = baseStation.version firmware.productID = mockProductID loadMockFirmwareImagesIfNeeded(force: true) - status = "Connected to \(connection.host). Mock mode." + status = localizedFormat("Connected to %@. Mock mode.", connection.host) showConnectionDetails = false - logs = ["Mock backend enabled with fixture Time Capsule settings."] + logs = [localized("Mock backend enabled with fixture Time Capsule settings.")] discoveredDevices = AirportMockBackend.discoveredDevices( statusText: mockStatusText, environmentValue: Self.environmentValue) diff --git a/Sources/AirPortUtilityCore/AirPortSetup.swift b/Sources/AirPortUtilityCore/AirPortSetup.swift index f5d80e3..06f28e6 100644 --- a/Sources/AirPortUtilityCore/AirPortSetup.swift +++ b/Sources/AirPortUtilityCore/AirPortSetup.swift @@ -9,9 +9,9 @@ enum AirPortSetupMode: String, CaseIterable, Identifiable { var title: String { switch self { - case .create: "Create a new network" - case .extend: "Add to an existing network" - case .replace: "Replace an existing device" + case .create: localized("Create a new network") + case .extend: localized("Add to an existing network") + case .replace: localized("Replace an existing device") } } } @@ -36,7 +36,7 @@ struct AirPortSetupState: Equatable { var sourceDeviceID = "" var airPlayEnabled = true var airPlaySpeakerName = "" - var progressText = "Examining the base station…" + var progressText = localized("Examining the base station…") var errorText = "" var profile: JSONValue? @@ -96,7 +96,7 @@ extension AirportAppModel { deviceName: suggestedName, networkName: suggestedName, airPlaySpeakerName: suggestedName, - progressText: "Examining the base station…") + progressText: localized("Examining the base station…")) isDevicePopoverPresented = false isEditingDevice = false isShowingRestartConfirmation = false @@ -112,7 +112,7 @@ extension AirportAppModel { try await Task.sleep(nanoseconds: 650_000_000) } else { guard self.setupSessionID == sessionID, self.isShowingSetup else { return } - self.setup.progressText = "Gathering information about your network…" + self.setup.progressText = localized("Gathering information about your network…") let profile = try await self.readSetupProfile() guard self.setupSessionID == sessionID, self.isShowingSetup else { return } self.setup.profile = profile @@ -124,7 +124,7 @@ extension AirportAppModel { } catch { guard self.setupSessionID == sessionID, self.isShowingSetup else { return } self.setup.errorText = Self.userFacingErrorDescription(error.localizedDescription) - self.setup.progressText = "Could not read the base station setup profile." + self.setup.progressText = localized("Could not read the base station setup profile.") self.setup.step = .details } } @@ -162,7 +162,7 @@ extension AirportAppModel { setup.step = .details case .details: guard setup.canContinueDetails else { - setup.errorText = "Enter names and matching passwords of at least 8 characters." + setup.errorText = localized("Enter names and matching passwords of at least 8 characters.") return } applySetup() @@ -201,7 +201,7 @@ extension AirportAppModel { isRestorePending = true pendingRestoreConnection = activeConnection pendingRestoreDeviceIdentifiers = deviceIdentifiers - status = "Waiting to restore default settings" + status = localized("Waiting to restore default settings") return } startRestoreDefaultSettings( @@ -243,13 +243,13 @@ extension AirportAppModel { private func performRestoreDefaultSettings(connection activeConnection: AirportConnection) { let commands = restoreDefaultCommandSequence(connection: activeConnection) applySequence( - title: "Restore Default Settings", commands: commands, + title: localized("Restore Default Settings"), commands: commands, connection: activeConnection, cleanScope: .none, delayBetweenCommandsNanoseconds: 8_000_000_000, allowsConnectionHostChange: true ) { self.isWaitingForRestoreRestart = true - self.status = "Waiting for this base station to restart with default settings." + self.status = localized("Waiting for this base station to restart with default settings.") self.completeRestoreIfResetDeviceAvailable() } failure: { description in self.isRestoringDefaults = false @@ -259,7 +259,7 @@ extension AirportAppModel { self.pendingRestoreDeviceIdentifiers = [] self.isShowingRestoreConfirmation = false self.clearBaseStationUpdate() - self.status = "Restore failed: \(description)" + self.status = localizedFormat("Restore failed: %@", description) } } @@ -282,11 +282,11 @@ extension AirportAppModel { var factoryConnection = activeConnection factoryConnection.password = "public" return [ - ("Identify Base Station", marker("lebl", connection: activeConnection)), - ("Restore Factory Defaults", marker("acRF", connection: activeConnection)), - ("Restart Base Station", marker("acRB", connection: activeConnection)), - ("Finish Factory Restore", marker("lebs", connection: factoryConnection)), - ("Restart with Default Settings", marker("acRB", connection: factoryConnection)), + (localized("Identify Base Station"), marker("lebl", connection: activeConnection)), + (localized("Restore Factory Defaults"), marker("acRF", connection: activeConnection)), + (localized("Restart Base Station"), marker("acRB", connection: activeConnection)), + (localized("Finish Factory Restore"), marker("lebs", connection: factoryConnection)), + (localized("Restart with Default Settings"), marker("acRB", connection: factoryConnection)), ] } let reset = AirportCommand.rawWriteJSON( @@ -297,17 +297,17 @@ extension AirportAppModel { let reboot = AirportCommand.rawWriteJSON( setting: "acRB", valueJSON: emptyBytes, connection: factoryConnection, dryRun: false ).usingAirPortBackendSubcommand("legacy-write") + ["--streaming", "--request-flags", "0"] - return [("Restore Factory Defaults", reset), ("Restart Base Station", reboot)] + return [(localized("Restore Factory Defaults"), reset), (localized("Restart Base Station"), reboot)] } func applySetup() { guard setup.canContinueDetails else { return } guard !isBusy else { - setup.errorText = "Wait for the current base station operation to finish, then try again." + setup.errorText = localized("Wait for the current base station operation to finish, then try again.") return } guard setup.profile != nil else { - setup.errorText = "The base station setup profile has not loaded. Go Back and try again." + setup.errorText = localized("The base station setup profile has not loaded. Go Back and try again.") return } let activeConnection = connection @@ -321,9 +321,9 @@ extension AirportAppModel { isWaitingForSetupRestart = false didSetupDeviceDisappear = false setup.step = .applying - setup.progressText = "Setting up this \(setupDeviceModelName)…" + setup.progressText = localizedFormat("Setting up this %@…", setupDeviceModelName) applySequence( - title: "Setup", commands: commands, connection: activeConnection, cleanScope: .none, + title: localized("Setup"), commands: commands, connection: activeConnection, cleanScope: .none, appliedAdminPassword: password, allowsConnectionHostChange: true ) { self.setupWriteDidSucceed(password: password) @@ -334,14 +334,14 @@ extension AirportAppModel { self.setupPreRestartBonjourSeed = "" self.clearBaseStationUpdate() self.setup.errorText = description - self.setup.progressText = "Setup failed" + self.setup.progressText = localized("Setup failed") self.setup.step = .details } } func setupWriteDidSucceed(password: String) { connection.password = password - setup.progressText = "Waiting for this base station to apply its settings and restart…" + setup.progressText = localized("Waiting for this base station to apply its settings and restart…") setup.password = "" setup.verifyPassword = "" isWaitingForSetupRestart = true @@ -368,7 +368,7 @@ extension AirportAppModel { if usesLegacyACP, let valuesJSON = try? setupLegacyAtomicValuesJSON() { return [ ( - "Setup", + localized("Setup"), AirportCommand.rawWriteValuesJSON( valuesJSON, connection: activeConnection, dryRun: false ).usingAirPortBackendSubcommand("legacy-write") + [ @@ -380,7 +380,7 @@ extension AirportAppModel { if !usesLegacyACP, let valuesJSON = try? setupAtomicValuesJSON() { return [ ( - "Setup", + localized("Setup"), AirportCommand.rawWriteValuesJSON( valuesJSON, connection: activeConnection, dryRun: false) + ["--no-verify"] ) @@ -429,8 +429,8 @@ extension AirportAppModel { let adminPassword = AirportCommand.rawWrite( setting: "syPW", value: password, connection: activeConnection, dryRun: false) var commands = appliedFinalCommand([ - ("Network Setup", settings), ("Base Station Name", name), - ("Base Station Password", adminPassword), + (localized("Network Setup"), settings), (localized("Base Station Name"), name), + (localized("Base Station Password"), adminPassword), ]) if !commands[commands.count - 1].1.contains("--setup-complete") { commands[commands.count - 1].1.append("--setup-complete") @@ -608,8 +608,8 @@ private enum AirPortSetupPayloadError: LocalizedError { var errorDescription: String? { switch self { - case .missingProfile: "The base station setup profile has not loaded yet." - case .incompleteProfile: "The base station setup profile is missing Wi-Fi or timezone settings." + case .missingProfile: localized("The base station setup profile has not loaded yet.") + case .incompleteProfile: localized("The base station setup profile is missing Wi-Fi or timezone settings.") } } } @@ -708,9 +708,9 @@ struct AirPortSetupSheet: View { private var recommendation: some View { VStack(spacing: 20) { deviceImage - Text("Set up this \(model.setupDeviceModelName) to create a new Wi-Fi network.") + Text(localizedFormat("Set up this %@ to create a new Wi-Fi network.", model.setupDeviceModelName)) .font(.system(size: 16, weight: .semibold)) - Text("This \(model.setupDeviceModelName) will create a network.") + Text(localizedFormat("This %@ will create a network.", model.setupDeviceModelName)) .font(.system(size: 13)).foregroundStyle(.secondary) } .padding(40) @@ -718,7 +718,7 @@ struct AirPortSetupSheet: View { private var choices: some View { VStack(alignment: .leading, spacing: 18) { - Text("What do you want to do with this \(model.setupDeviceModelName)?") + Text(localizedFormat("What do you want to do with this %@?", model.setupDeviceModelName)) .font(.system(size: 16, weight: .semibold)) Picker("", selection: $model.setup.mode) { ForEach(AirPortSetupMode.allCases) { mode in Text(mode.title).tag(mode) } @@ -734,30 +734,30 @@ struct AirPortSetupSheet: View { VStack(alignment: .leading, spacing: 14) { Text(model.setup.mode.title).font(.system(size: 16, weight: .semibold)) if model.setup.mode == .replace { - Picker("Base Station to Replace:", selection: $model.setup.sourceDeviceID) { - Text("Choose a base station").tag("") + Picker(localized("Base Station to Replace:"), selection: $model.setup.sourceDeviceID) { + Text(localized("Choose a base station")).tag("") ForEach(model.setupSourceDevices) { Text($0.displayName).tag($0.id) } } .frame(width: 440) } else if model.setup.mode == .extend, !model.setupNetworkSuggestions.isEmpty { - Picker("Network Name:", selection: $model.setup.networkName) { + Picker(localized("Network Name:"), selection: $model.setup.networkName) { ForEach(model.setupNetworkSuggestions, id: \.self) { Text($0).tag($0) } } .frame(width: 440) } else { - setupField("Network Name:", text: $model.setup.networkName, secure: false) + setupField(localized("Network Name:"), text: $model.setup.networkName, secure: false) } - setupField("Base Station Name:", text: $model.setup.deviceName, secure: false) - setupField("Password:", text: $model.setup.password, secure: true) - setupField("Verify Password:", text: $model.setup.verifyPassword, secure: true) - Toggle("Use a single password", isOn: $model.setup.useSinglePassword) + setupField(localized("Base Station Name:"), text: $model.setup.deviceName, secure: false) + setupField(localized("Password:"), text: $model.setup.password, secure: true) + setupField(localized("Verify Password:"), text: $model.setup.verifyPassword, secure: true) + Toggle(localized("Use a single password"), isOn: $model.setup.useSinglePassword) .toggleStyle(.checkbox).font(.system(size: 13)).padding(.leading, 170) if model.showsSetupAirPlayControls { - Toggle("Enable AirPlay", isOn: $model.setup.airPlayEnabled) + Toggle(localized("Enable AirPlay"), isOn: $model.setup.airPlayEnabled) .toggleStyle(.checkbox).font(.system(size: 13)).padding(.leading, 170) - setupField("AirPlay Speaker Name:", text: $model.setup.airPlaySpeakerName, secure: false) + setupField(localized("AirPlay Speaker Name:"), text: $model.setup.airPlaySpeakerName, secure: false) } - Text(model.setup.errorText.isEmpty ? "Password must be at least 8 characters." : model.setup.errorText) + Text(model.setup.errorText.isEmpty ? localized("Password must be at least 8 characters.") : model.setup.errorText) .font(.system(size: 11)) .foregroundStyle(model.setup.errorText.isEmpty ? Color.secondary : Color.red) .padding(.leading, 178) @@ -768,25 +768,25 @@ struct AirPortSetupSheet: View { private var completion: some View { VStack(spacing: 18) { deviceImage - Text("Setup Complete").font(.system(size: 18, weight: .semibold)) - Text("“\(model.setup.deviceName)” is now available.").font(.system(size: 13)) + Text(localized("Setup Complete")).font(.system(size: 18, weight: .semibold)) + Text(localizedFormat("“%@” is now available.", model.setup.deviceName)).font(.system(size: 13)) } } private var controls: some View { HStack { if model.setup.step == .recommendation { - Button("Other Options") { model.showSetupChoices() } + Button(localized("Other Options")) { model.showSetupChoices() } .accessibilityIdentifier("setup.other.options") } else if model.setup.step == .choices || model.setup.step == .details { - Button("Back") { model.setupBack() }.accessibilityIdentifier("setup.back") + Button(localized("Back")) { model.setupBack() }.accessibilityIdentifier("setup.back") } Spacer() if model.setup.step != .applying && model.setup.step != .complete { - Button("Cancel") { model.cancelSetup() }.accessibilityIdentifier("setup.cancel") + Button(localized("Cancel")) { model.cancelSetup() }.accessibilityIdentifier("setup.cancel") } if model.setup.step != .examining && model.setup.step != .applying { - Button(model.setup.step == .complete ? "Done" : "Next") { model.setupNext() } + Button(model.setup.step == .complete ? localized("Done") : localized("Next")) { model.setupNext() } .keyboardShortcut(.defaultAction) .disabled(model.setup.step == .details && !model.setup.canContinueDetails) .accessibilityIdentifier(model.setup.step == .complete ? "setup.done" : "setup.next") @@ -804,9 +804,9 @@ struct AirPortSetupSheet: View { private var choiceDescription: String { switch model.setup.mode { - case .create: "Create a separate Wi-Fi network using this base station." - case .extend: "Join or extend a Wi-Fi network that is already available." - case .replace: "Copy compatible settings from another AirPort base station." + case .create: localized("Create a separate Wi-Fi network using this base station.") + case .extend: localized("Join or extend a Wi-Fi network that is already available.") + case .replace: localized("Copy compatible settings from another AirPort base station.") } } @@ -825,17 +825,17 @@ struct RestartBaseStationSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - Text("Restart Base Station?").font(.system(size: 15, weight: .semibold)) + Text(localized("Restart Base Station?")).font(.system(size: 15, weight: .semibold)) Text( - "The device and its network services will be temporarily unavailable. Are you sure you want to continue?" + localized("The device and its network services will be temporarily unavailable. Are you sure you want to continue?") ) .font(.system(size: 13)) .fixedSize(horizontal: false, vertical: true) HStack { Spacer() - Button("Cancel") { model.isShowingRestartConfirmation = false } + Button(localized("Cancel")) { model.isShowingRestartConfirmation = false } .accessibilityIdentifier("restart.cancel") - Button("Continue") { model.restartBaseStation() } + Button(localized("Continue")) { model.restartBaseStation() } .keyboardShortcut(.defaultAction) .accessibilityIdentifier("restart.continue") } @@ -851,22 +851,22 @@ struct RestoreDefaultSettingsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { if model.isRestoringDefaults || model.isRestorePending { - Text("Restoring Base Station…").font(.system(size: 15, weight: .semibold)) + Text(localized("Restoring Base Station…")).font(.system(size: 15, weight: .semibold)) HStack(spacing: 12) { ProgressView().controlSize(.small) - Text("Waiting for this base station to restore its default settings and restart…") + Text(localized("Waiting for this base station to restore its default settings and restart…")) .font(.system(size: 13)).fixedSize(horizontal: false, vertical: true) } .accessibilityIdentifier("restore.progress") } else { - Text("Restore Default Settings?").font(.system(size: 15, weight: .semibold)) - Text("Restoring this Base Station to factory defaults erases its settings.") + Text(localized("Restore Default Settings?")).font(.system(size: 15, weight: .semibold)) + Text(localized("Restoring this Base Station to factory defaults erases its settings.")) .font(.system(size: 13)).fixedSize(horizontal: false, vertical: true) HStack { Spacer() - Button("Cancel") { model.isShowingRestoreConfirmation = false } + Button(localized("Cancel")) { model.isShowingRestoreConfirmation = false } .accessibilityIdentifier("restore.cancel") - Button("Continue") { model.restoreDefaultSettings() } + Button(localized("Continue")) { model.restoreDefaultSettings() } .keyboardShortcut(.defaultAction).accessibilityIdentifier("restore.continue") } } diff --git a/Sources/AirPortUtilityCore/AirportAppModelAdvanced.swift b/Sources/AirPortUtilityCore/AirportAppModelAdvanced.swift index 6b5fda2..e376795 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelAdvanced.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelAdvanced.swift @@ -4,13 +4,13 @@ import Foundation extension AirportAppModel { func previewAdvanced() { guard supportsPane(.advanced) else { - status = "This base station does not support advanced settings." + status = localized("This base station does not support advanced settings.") clearPreviewAfterValidationFailure() return } previewFriendlySettings( title: "Advanced", - noChangesStatus: "No pending Advanced changes to preview." + noChangesStatus: localized("No pending Advanced changes to preview.") ) { advancedFlags(changesOnly: true) } @@ -18,13 +18,13 @@ extension AirportAppModel { func applyAdvanced() { guard supportsPane(.advanced) else { - status = "This base station does not support advanced settings." + status = localized("This base station does not support advanced settings.") clearPreviewAfterValidationFailure() return } applyFriendlySettings( title: "Advanced", - noChangesStatus: "No pending Advanced changes to apply.", + noChangesStatus: localized("No pending Advanced changes to apply."), cleanScope: .advanced ) { advancedFlags(changesOnly: true) @@ -39,14 +39,14 @@ extension AirportAppModel { let cleanDestination = normalized(cleanSnapshot.advanced.syslogDestinationAddress) if !changesOnly || destination != cleanDestination { guard destination.isEmpty || isIPv4Address(destination) else { - status = "Syslog Destination Address must be an IPv4 address." + status = localized("Syslog Destination Address must be an IPv4 address.") return nil } flags.append(("--syslog-destination", destination.isEmpty ? "0.0.0.0" : destination)) } if !changesOnly || advanced.syslogLevel != cleanSnapshot.advanced.syslogLevel { guard (0...7).contains(advanced.syslogLevel) else { - status = "Syslog Level must be between 0 and 7." + status = localized("Syslog Level must be between 0 and 7.") return nil } flags.append(("--syslog-level", String(advanced.syslogLevel))) @@ -78,29 +78,29 @@ extension AirportAppModel { if advanced.pppDialInEnabled { guard internet.connectUsing != .modem && !internet.modemUseAOL else { status = - "PPP Dial-in is not allowed when configured to connect to the Internet via the Modem or AOL." + localized("PPP Dial-in is not allowed when configured to connect to the Internet via the Modem or AOL.") return nil } guard network.routerMode != .dhcpOnly else { - status = "PPP Dial-in is not allowed when configured to share a range of addresses." + status = localized("PPP Dial-in is not allowed when configured to share a range of addresses.") return nil } guard advanced.pppDialInPassword == advanced.pppDialInVerifyPassword else { - status = "PPP Dial-in passwords do not match." + status = localized("PPP Dial-in passwords do not match.") return nil } guard (1...255).contains(advanced.pppDialInAnswerOnRing) else { - status = "Answer on ring must be between 1 and 255." + status = localized("Answer on ring must be between 1 and 255.") return nil } let idleValues = Set(ModemIdleOption.allCases.map(\.seconds)) guard idleValues.contains(advanced.pppDialInIdleSeconds) else { - status = "Idle Disconnect After has an unsupported value." + status = localized("Idle Disconnect After has an unsupported value.") return nil } let maximumValues = Set(PPPDialInMaximumConnectOption.allCases.map(\.seconds)) guard maximumValues.contains(advanced.pppDialInMaximumConnectSeconds) else { - status = "Maximum Connect Time has an unsupported value." + status = localized("Maximum Connect Time has an unsupported value.") return nil } @@ -134,7 +134,7 @@ extension AirportAppModel { let cleanOptions = cleanSnapshot.legacyDeviceOptions.accessControl let modeChanged = options.mode != cleanOptions.mode guard ["not-enabled", "local", "radius"].contains(options.mode) else { - status = "Access Control mode is not supported." + status = localized("Access Control mode is not supported.") return nil } appendChanged( @@ -151,42 +151,42 @@ extension AirportAppModel { changesOnly: changesOnly && !modeChanged) } else if options.mode == "radius" { guard ["default", "alternate"].contains(options.radiusType) else { - status = "RADIUS type is not supported." + status = localized("RADIUS type is not supported.") return nil } let primaryAddress = normalized(options.primaryAddress) let secondaryAddress = normalized(options.secondaryAddress) guard isIPv4Address(primaryAddress) else { - status = "Primary RADIUS Server must be an IPv4 address." + status = localized("Primary RADIUS Server must be an IPv4 address.") return nil } guard options.primarySecret == options.primaryVerifySecret else { - status = "Primary RADIUS shared secrets do not match." + status = localized("Primary RADIUS shared secrets do not match.") return nil } guard !normalized(options.primarySecret).isEmpty else { - status = "Primary RADIUS Shared Secret cannot be empty." + status = localized("Primary RADIUS Shared Secret cannot be empty.") return nil } guard (1...65_535).contains(options.primaryPort) else { - status = "Primary RADIUS port must be between 1 and 65535." + status = localized("Primary RADIUS port must be between 1 and 65535.") return nil } if !secondaryAddress.isEmpty { guard isIPv4Address(secondaryAddress) else { - status = "Secondary RADIUS Server must be an IPv4 address." + status = localized("Secondary RADIUS Server must be an IPv4 address.") return nil } guard options.secondarySecret == options.secondaryVerifySecret else { - status = "Secondary RADIUS shared secrets do not match." + status = localized("Secondary RADIUS shared secrets do not match.") return nil } guard !normalized(options.secondarySecret).isEmpty else { - status = "Secondary RADIUS Shared Secret cannot be empty." + status = localized("Secondary RADIUS Shared Secret cannot be empty.") return nil } guard (1...65_535).contains(options.secondaryPort) else { - status = "Secondary RADIUS port must be between 1 and 65535." + status = localized("Secondary RADIUS port must be between 1 and 65535.") return nil } } @@ -232,11 +232,11 @@ extension AirportAppModel { $0.count == 2 && UInt8($0, radix: 16) != nil }) else { - status = "Each local access-control entry must contain a valid MAC address." + status = localized("Each local access-control entry must contain a valid MAC address.") return nil } guard entry.description.lengthOfBytes(using: .utf8) <= 34 else { - status = "Access-control descriptions may contain at most 34 UTF-8 bytes." + status = localized("Access-control descriptions may contain at most 34 UTF-8 bytes.") return nil } objects.append(["macAddress": macAddress, "description": entry.description]) @@ -245,7 +245,7 @@ extension AirportAppModel { let data = try? JSONSerialization.data(withJSONObject: objects, options: [.sortedKeys]), let text = String(data: data, encoding: .utf8) else { - status = "Could not encode local access-control settings." + status = localized("Could not encode local access-control settings.") return nil } return text diff --git a/Sources/AirPortUtilityCore/AirportAppModelCommands.swift b/Sources/AirPortUtilityCore/AirportAppModelCommands.swift index 5b37482..9fa1018 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelCommands.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelCommands.swift @@ -50,15 +50,15 @@ extension AirportAppModel { var postApplyDeviceNameForStatus: String { if usesLegacyACP { - return "AirPort Express" + return localized("AirPort Express") } switch baseStation.productID.trimmingCharacters(in: .whitespacesAndNewlines) { case "102", "107", "115": - return "AirPort Express" + return localized("AirPort Express") case "106", "109", "113", "116", "119": - return "Time Capsule" + return localized("Time Capsule") case "3", "104", "105", "108", "114", "117", "120": - return "AirPort Extreme" + return localized("AirPort Extreme") default: return "base station" } @@ -452,7 +452,7 @@ extension AirportAppModel { ignoreStaleOperation("Ignored \(busyStatus) failure for stale host \(requestHost).") } else { let errorDescription = Self.userFacingErrorDescription(error.localizedDescription) - if busyStatus == "Refreshing settings" { + if busyStatus == localized("Refreshing settings") { hasTrustedConnectionPassword = false } preview = nil diff --git a/Sources/AirPortUtilityCore/AirportAppModelConfiguration.swift b/Sources/AirPortUtilityCore/AirportAppModelConfiguration.swift index c4cba22..a2aaa4f 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelConfiguration.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelConfiguration.swift @@ -91,7 +91,7 @@ extension AirportAppModel { public var defaultConfigurationFileName: String { let name = baseStation.name.trimmingCharacters(in: .whitespacesAndNewlines) - let baseName = name.isEmpty ? "AirPort Configuration" : name + let baseName = name.isEmpty ? localized("AirPort Configuration") : name let invalidCharacters = CharacterSet(charactersIn: "/:") .union(.newlines) .union(.controlCharacters) @@ -100,7 +100,7 @@ extension AirportAppModel { .components(separatedBy: invalidCharacters) .joined(separator: " ") .trimmingCharacters(in: .whitespacesAndNewlines) - return (sanitized.isEmpty ? "AirPort Configuration" : sanitized) + ".baseconfig" + return (sanitized.isEmpty ? localized("AirPort Configuration") : sanitized) + ".baseconfig" } public func exportConfiguration(to url: URL) throws { diff --git a/Sources/AirPortUtilityCore/AirportAppModelDisks.swift b/Sources/AirPortUtilityCore/AirportAppModelDisks.swift index 648682b..2d16265 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelDisks.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelDisks.swift @@ -3,8 +3,8 @@ import Foundation extension AirportAppModel { func previewDiskSharing() { previewFriendlySettings( - title: "Disk Sharing", - noChangesStatus: "No pending Disk Sharing changes to preview." + title: localized("Disk Sharing"), + noChangesStatus: localized("No pending Disk Sharing changes to preview.") ) { diskSharingFlags(changesOnly: true) } @@ -12,8 +12,8 @@ extension AirportAppModel { func applyDiskSharing() { applyFriendlySettings( - title: "Disk Sharing", - noChangesStatus: "No pending Disk Sharing changes to apply.", + title: localized("Disk Sharing"), + noChangesStatus: localized("No pending Disk Sharing changes to apply."), cleanScope: .disks, completion: { self.persistAuxiliaryPasswordPreferences(from: $0) } ) { @@ -24,7 +24,7 @@ extension AirportAppModel { func dryRunErase(method: EraseMethod, volumeName: String? = nil) { let connection = connection dryRun( - title: "Erase Disk", + title: localized("Erase Disk"), args: AirportCommand.eraseDisk( connection: connection, method: method, volumeName: volumeName, partitionUUID: selectedEraseDiskUUID(), @@ -36,7 +36,7 @@ extension AirportAppModel { func applyErase(method: EraseMethod, volumeName: String? = nil) { let connection = connection apply( - title: "Erase Disk", + title: localized("Erase Disk"), args: AirportCommand.eraseDisk( connection: connection, method: method, volumeName: volumeName, partitionUUID: selectedEraseDiskUUID(), @@ -50,7 +50,7 @@ extension AirportAppModel { func dryRunArchive(name: String) { let connection = connection dryRun( - title: "Archive Disk", + title: localized("Archive Disk"), args: AirportCommand.archiveDisk( connection: connection, archiveName: name, confirmed: false, dryRun: true), connection: connection) @@ -63,7 +63,7 @@ extension AirportAppModel { applyArchive(args: args, connection: connection) } - private static let eraseDiskWarningMessage = "All users will be disconnected from this disk." + private static let eraseDiskWarningMessage = localized("All users will be disconnected from this disk.") private static let archiveStatusACPSettings = ["sySt"] private static let archiveCompletionPollIntervalNanoseconds: UInt64 = 15_000_000_000 private static let archiveCompletionPollLimit = 5_760 @@ -140,7 +140,7 @@ extension AirportAppModel { private func applyArchive(args: [String], connection: AirportConnection) { guard !isBusy else { return } let requestHost = AirportConnection.normalizedHost(connection.host) - runTask("Applying Archive Disk", requestHost: requestHost) { + runTask(localized("Applying Archive Disk"), requestHost: requestHost) { if self.mockMode { let redacted = AirportCommand.redact(args) let output = AirportMockBackend.output(for: args, dryRun: false) @@ -173,7 +173,7 @@ extension AirportAppModel { private func archiveDiskStarted(connection: AirportConnection, requestHost: String) { invalidateDiskInventory() - status = "Archive Disk started. Waiting for archive to complete." + status = localized("Archive Disk started. Waiting for archive to complete.") preview = nil archiveCompletionMonitorTask?.cancel() archiveCompletionMonitorTask = Task { @MainActor [weak self] in @@ -209,7 +209,7 @@ extension AirportAppModel { Self.archiveStatusACPSettings, connection: connection) if Self.archiveIsInProgress(reader: reader) { sawArchiveInProgress = true - status = "Archive Disk in progress." + status = localized("Archive Disk in progress.") continue } if sawArchiveInProgress { @@ -223,8 +223,8 @@ extension AirportAppModel { } guard connectionStillMatches(requestHost) else { return } - status = "Archive Disk status check timed out." - appendLog("Archive Disk status check timed out.") + status = localized("Archive Disk status check timed out.") + appendLog(localized("Archive Disk status check timed out.")) } private func finishArchiveCompletion(connection: AirportConnection, requestHost: String) async { @@ -232,8 +232,8 @@ extension AirportAppModel { ignoreStaleOperation("Ignored Archive Disk completion for stale host \(requestHost).") return } - status = "Archive Disk complete." - appendLog("Archive Disk complete.") + status = localized("Archive Disk complete.") + appendLog(localized("Archive Disk complete.")) if mockMode { applyDiskInventoryRefreshResult(AirportMockBackend.diskInventoryRefreshResult) return diff --git a/Sources/AirPortUtilityCore/AirportAppModelFirmware.swift b/Sources/AirPortUtilityCore/AirportAppModelFirmware.swift index 3fc333f..d06f940 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelFirmware.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelFirmware.swift @@ -4,18 +4,18 @@ import Foundation extension AirportAppModel { func refreshFirmwareImages() { guard supportsPane(.firmware) else { - status = "This base station does not support firmware updates." + status = localized("This base station does not support firmware updates.") return } guard !isBusy else { return } let productID = firmware.productID.trimmingCharacters(in: .whitespacesAndNewlines) guard !productID.isEmpty else { - status = "The base station product ID is not available." + status = localized("The base station product ID is not available.") return } let requestHost = AirportConnection.normalizedHost(connection.host) firmware.isLoading = true - runTask("Loading firmware list", requestHost: requestHost) { + runTask(localized("Loading firmware list"), requestHost: requestHost) { defer { self.firmware.isLoading = false } @@ -54,7 +54,7 @@ extension AirportAppModel { func previewSelectedFirmwareInstall() { guard supportsPane(.firmware) else { - status = "This base station does not support firmware updates." + status = localized("This base station does not support firmware updates.") clearPreviewAfterValidationFailure() return } @@ -63,19 +63,19 @@ extension AirportAppModel { } guard let image = firmware.selectedImage else { preview = nil - status = "No firmware image is selected." + status = localized("No firmware image is selected.") return } let args = AirportCommand.installFirmware( connection: connection, firmwarePath: image.location.absoluteString, dryRun: true) - dryRun(title: "Firmware", args: args, connection: connection) + dryRun(title: localized("Firmware"), args: args, connection: connection) } func installSelectedFirmware() { guard supportsPane(.firmware) else { - status = "This base station does not support firmware updates." + status = localized("This base station does not support firmware updates.") clearPreviewAfterValidationFailure() return } @@ -84,7 +84,7 @@ extension AirportAppModel { } guard let image = firmware.selectedImage else { preview = nil - status = "No firmware image is selected." + status = localized("No firmware image is selected.") return } guard mockMode || liveCredentialsAvailable else { @@ -118,21 +118,21 @@ extension AirportAppModel { phase: .download, completed: 1, total: 1, - detail: "Using bundled mock firmware.") + detail: localized("Using bundled mock firmware.")) } else if image.isLocalFile { firmwareSource = image.location.path self.updateFirmwareTransferProgress( phase: .download, completed: 1, total: 1, - detail: "Using selected firmware file.") + detail: localized("Using selected firmware file.")) } else { self.firmware.installStatus = "Downloading firmware \(image.version)." self.updateFirmwareTransferProgress( phase: .download, completed: 0, total: Double(max(image.sizeInBytes, 1)), - detail: "Starting download from Apple.") + detail: localized("Starting download from Apple.")) let localURL = try await self.downloadFirmwareImage(image) firmwareSource = localURL.path } @@ -150,8 +150,8 @@ extension AirportAppModel { phase: .upload, completed: 1, total: 1, - detail: "Mock firmware uploaded.") - self.firmware.installStatus = "Mock firmware upload accepted. Restart requested." + detail: localized("Mock firmware uploaded.")) + self.firmware.installStatus = localized("Mock firmware upload accepted. Restart requested.") self.firmwareUploadRestartStarted( image: image, connection: connection, @@ -168,7 +168,7 @@ extension AirportAppModel { phase: .upload, completed: 0, total: 1, - detail: "Starting upload to AirPort.") + detail: localized("Starting upload to AirPort.")) let result = try await self.runner.run( script: AirportCommand.writeScript, arguments: args, @@ -203,12 +203,12 @@ extension AirportAppModel { self.appendLog("Firmware upload: \(summary) Host: \(uploadResult.uploadHost).") } } else { - self.firmware.installStatus = "Firmware upload accepted. Waiting for restart." + self.firmware.installStatus = localized("Firmware upload accepted. Waiting for restart.") self.updateFirmwareTransferProgress( phase: .restart, completed: 1, total: 1, - detail: "Restart command sent.") + detail: localized("Restart command sent.")) } let suffix: String if let uploadResult, uploadResult.progressComplete == true { @@ -301,7 +301,7 @@ extension AirportAppModel { if mockMode { loadMockFirmwareImagesIfNeeded(force: true) if updatesStatus { - status = "Firmware list loaded. Mock mode." + status = localized("Firmware list loaded. Mock mode.") } return } @@ -316,8 +316,8 @@ extension AirportAppModel { guard updatesStatus else { return } status = images.isEmpty - ? "No Apple firmware images are listed for this base station." - : "Firmware list loaded." + ? localized("No Apple firmware images are listed for this base station.") + : localized("Firmware list loaded.") } private func downloadFirmwareImage(_ image: FirmwareImage) async throws -> URL { @@ -336,7 +336,7 @@ extension AirportAppModel { in: .whitespacesAndNewlines) if !name.isEmpty { return name } let fallback = url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) - return fallback.isEmpty ? "Chosen Firmware" : fallback + return fallback.isEmpty ? localized("Chosen Firmware") : fallback } private static func firmwareSourceIdentifier(for url: URL) -> String { @@ -464,9 +464,9 @@ extension AirportAppModel { phase: .restart, completed: 1, total: 1, - detail: "Restart command sent.") + detail: localized("Restart command sent.")) status = "Firmware uploaded. \(image.version) will install after restart." - firmware.installStatus = "Firmware uploaded. Restart requested." + firmware.installStatus = localized("Firmware uploaded. Restart requested.") preview = nil beginBaseStationUpdate(requestHost: requestHost) scheduleFirmwareCompletionMonitor( diff --git a/Sources/AirPortUtilityCore/AirportAppModelFlags.swift b/Sources/AirPortUtilityCore/AirportAppModelFlags.swift index 7e87ee7..3b349f0 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelFlags.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelFlags.swift @@ -11,7 +11,7 @@ extension AirportAppModel { let lanIPAddress = normalized(network.lanIPAddress) if !lanIPAddress.isEmpty { guard isIPv4Address(lanIPAddress) else { - status = "LAN IP Address must be an IPv4 address." + status = localized("LAN IP Address must be an IPv4 address.") return nil } flags.append(("--lan-ip-address", lanIPAddress)) @@ -30,7 +30,7 @@ extension AirportAppModel { "second", "seconds", "minute", "minutes", "hour", "hours", "day", "days", "week", "weeks", ], - statusMessage: "DHCP Lease unit is not supported." + statusMessage: localized("DHCP Lease unit is not supported.") ) else { return nil } let dhcpRangeStartChanged = @@ -48,19 +48,19 @@ extension AirportAppModel { let shouldValidateDHCPLease = !changesOnly || routerModeChanged || dhcpLeaseChanged || dhcpLeaseUnitChanged guard !shouldValidateDHCPRange || !normalized(network.dhcpRangeStart).isEmpty else { - status = "DHCP Range Beginning cannot be empty." + status = localized("DHCP Range Beginning cannot be empty.") return nil } guard !shouldValidateDHCPRange || isIPv4Address(network.dhcpRangeStart) else { - status = "DHCP Range Beginning must be an IPv4 address." + status = localized("DHCP Range Beginning must be an IPv4 address.") return nil } guard !shouldValidateDHCPRange || !normalized(network.dhcpRangeEnd).isEmpty else { - status = "DHCP Range Ending cannot be empty." + status = localized("DHCP Range Ending cannot be empty.") return nil } guard !shouldValidateDHCPRange || isIPv4Address(network.dhcpRangeEnd) else { - status = "DHCP Range Ending must be an IPv4 address." + status = localized("DHCP Range Ending must be an IPv4 address.") return nil } guard @@ -68,22 +68,22 @@ extension AirportAppModel { || DHCPRangeFields.fields(start: network.dhcpRangeStart, end: network.dhcpRangeEnd) != nil else { status = - "DHCP Range Beginning and Ending must use the same supported private subnet, with Ending not before Beginning." + localized("DHCP Range Beginning and Ending must use the same supported private subnet, with Ending not before Beginning.") return nil } guard !shouldValidateDHCPLease || !normalized(network.dhcpLease).isEmpty else { - status = "DHCP Lease cannot be empty." + status = localized("DHCP Lease cannot be empty.") return nil } guard !shouldValidateDHCPLease || isPositiveInteger(network.dhcpLease) else { - status = "DHCP Lease must be a positive number." + status = localized("DHCP Lease must be a positive number.") return nil } guard !shouldValidateDHCPLease || isSupportedDHCPLeaseDuration(value: network.dhcpLease, unit: network.dhcpLeaseUnit) else { - status = "DHCP Lease duration must be between 1 second and 10 years." + status = localized("DHCP Lease duration must be between 1 second and 10 years.") return nil } appendChanged( @@ -128,7 +128,7 @@ extension AirportAppModel { flags.append(("--clear-default-host", nil)) } else { guard isIPv4Address(defaultHost) else { - status = "Default Host must be an IPv4 address." + status = localized("Default Host must be an IPv4 address.") return nil } flags.append(("--default-host", defaultHost)) @@ -140,7 +140,7 @@ extension AirportAppModel { func airPlayFlags(changesOnly: Bool = false) -> [(String, String?)]? { guard supportsPane(.airPlay) else { - status = "This base station does not support AirPlay." + status = localized("This base station does not support AirPlay.") return nil } @@ -157,11 +157,11 @@ extension AirportAppModel { airPlay.enabled && (!changesOnly || enabledChanged || passwordChanged) guard !shouldValidateName || !speakerName.isEmpty else { - status = "AirPlay Speaker Name cannot be empty." + status = localized("AirPlay Speaker Name cannot be empty.") return nil } guard !shouldValidatePassword || speakerPassword == verifySpeakerPassword else { - status = "AirPlay passwords do not match." + status = localized("AirPlay passwords do not match.") return nil } @@ -211,7 +211,7 @@ extension AirportAppModel { cleanValue: cleanSnapshot.disks.secureSharedDisks, changesOnly: changesOnly, allowed: ["accounts", "disk-password", "device-password"], - statusMessage: "Secure Shared Disks mode is not supported." + statusMessage: localized("Secure Shared Disks mode is not supported.") ) else { return nil } guard @@ -220,7 +220,7 @@ extension AirportAppModel { cleanValue: cleanSnapshot.disks.guestAccess, changesOnly: changesOnly, allowed: ["not-allowed", "read-only", "read-write"], - statusMessage: "Guest Disk Access is not supported." + statusMessage: localized("Guest Disk Access is not supported.") ) else { return nil } if diskSecurity == "disk-password" { @@ -230,11 +230,11 @@ extension AirportAppModel { let diskSecurityChanged = diskSecurity != cleanDiskSecurity let diskPasswordChanged = diskPassword != cleanDiskPassword guard !(diskSecurityChanged || diskPasswordChanged) || !diskPassword.isEmpty else { - status = "Disk Password cannot be empty." + status = localized("Disk Password cannot be empty.") return nil } guard diskPassword.isEmpty || diskPassword == verifyDiskPassword else { - status = "Disk passwords do not match." + status = localized("Disk passwords do not match.") return nil } } @@ -269,7 +269,7 @@ extension AirportAppModel { && !normalized(disks.winsServer).isEmpty && !isIPv4Address(disks.winsServer) { - status = "WINS Server must be an IPv4 address." + status = localized("WINS Server must be an IPv4 address.") return nil } appendChanged( @@ -295,15 +295,15 @@ extension AirportAppModel { } for account in accounts { guard !account.password.isEmpty else { - status = "Account Password cannot be empty." + status = localized("Account Password cannot be empty.") return false } guard account.password == account.verifyPassword else { - status = "Account passwords do not match." + status = localized("Account passwords do not match.") return false } guard diskAccountAccessValue(account.access) != nil else { - status = "File Sharing Access must be read-write, read-only, or not-allowed." + status = localized("File Sharing Access must be read-write, read-only, or not-allowed.") return false } } @@ -355,7 +355,7 @@ extension AirportAppModel { let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), let text = String(data: data, encoding: .utf8) else { - status = "Could not encode disk account settings." + status = localized("Could not encode disk account settings.") return nil } return text @@ -366,14 +366,14 @@ extension AirportAppModel { let cleanName = cleanSnapshot.baseStation.name.trimmingCharacters(in: .whitespacesAndNewlines) let nameChanged = name != cleanName guard changesOnly && !nameChanged || !name.isEmpty else { - status = "Base Station Name cannot be empty." + status = localized("Base Station Name cannot be empty.") return nil } let newPassword = baseStation.newAdminPassword.trimmingCharacters(in: .whitespacesAndNewlines) let verifyPassword = baseStation.verifyAdminPassword.trimmingCharacters( in: .whitespacesAndNewlines) guard newPassword.isEmpty || newPassword == verifyPassword else { - status = "Admin passwords do not match." + status = localized("Admin passwords do not match.") return nil } let adminPasswordChanged = newPassword != normalized(cleanSnapshot.baseStation.newAdminPassword) @@ -389,7 +389,7 @@ extension AirportAppModel { if !changesOnly || nameChanged || includesLegacyAdvancedACPWrite { commands.append( ( - "Base Station Name", + localized("Base Station Name"), AirportCommand.rawWrite( setting: "syNm", value: name, connection: connection, dryRun: dryRun) )) @@ -397,7 +397,7 @@ extension AirportAppModel { if adminPasswordChanged { commands.append( ( - "Admin Password", + localized("Admin Password"), AirportCommand.rawWrite( setting: "syPW", value: newPassword, connection: connection, dryRun: dryRun) )) @@ -407,7 +407,7 @@ extension AirportAppModel { baseStation.allowSetupOverWAN ? "--allow-setup-over-wan" : "--no-allow-setup-over-wan" commands.append( ( - "Setup Over Ethernet WAN", + localized("Setup Over Ethernet WAN"), AirportCommand.friendlyWrite( connection: connection, flags: [(flag, nil)], dryRun: dryRun) )) @@ -419,7 +419,7 @@ extension AirportAppModel { cleanOptions.setTimeAutomatically ? normalized(cleanOptions.timeServer) : "" let effectiveTimeServer = options.setTimeAutomatically ? timeServer : "" guard !options.setTimeAutomatically || !timeServer.isEmpty else { - status = "Time Server cannot be empty when automatic time is enabled." + status = localized("Time Server cannot be empty when automatic time is enabled.") return nil } var flags: [(String, String?)] = [] @@ -437,7 +437,7 @@ extension AirportAppModel { if !flags.isEmpty { commands.append( ( - "Base Station Options", + localized("Base Station Options"), AirportCommand.friendlyWrite( connection: connection, flags: flags, dryRun: dryRun) )) @@ -459,24 +459,24 @@ extension AirportAppModel { private func advancedACPSettings(from text: String) -> [String: String]? { guard let data = text.data(using: .utf8) else { - status = "Advanced ACP JSON must be valid UTF-8." + status = localized("Advanced ACP JSON must be valid UTF-8.") return nil } let object: Any do { object = try JSONSerialization.jsonObject(with: data) } catch { - status = "Advanced ACP JSON is not valid JSON." + status = localized("Advanced ACP JSON is not valid JSON.") return nil } guard let dictionary = object as? [String: Any] else { - status = "Advanced ACP JSON must be an object keyed by setting name." + status = localized("Advanced ACP JSON must be an object keyed by setting name.") return nil } var settings: [String: String] = [:] for (setting, value) in dictionary { guard setting.count == 4 else { - status = "Advanced ACP setting names must be four characters." + status = localized("Advanced ACP setting names must be four characters.") return nil } guard JSONSerialization.isValidJSONObject(value), diff --git a/Sources/AirPortUtilityCore/AirportAppModelInternetFlags.swift b/Sources/AirPortUtilityCore/AirportAppModelInternetFlags.swift index 165dccd..8ca482b 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelInternetFlags.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelInternetFlags.swift @@ -16,7 +16,7 @@ extension AirportAppModel { cleanValue: cleanSnapshot.internet.configureIPv6, changesOnly: changesOnly, allowed: ["link-local", "automatic", "manual"], - statusMessage: "Configure IPv6 must be link-local, automatic, or manual." + statusMessage: localized("Configure IPv6 must be link-local, automatic, or manual.") ) else { return nil } guard @@ -25,7 +25,7 @@ extension AirportAppModel { cleanValue: cleanSnapshot.internet.ipv6Mode, changesOnly: changesOnly, allowed: ["host", "tunnel", "router"], - statusMessage: "IPv6 Mode must be host, tunnel, or router.", + statusMessage: localized("IPv6 Mode must be host, tunnel, or router."), allowEmpty: true ) else { return nil } @@ -43,16 +43,16 @@ extension AirportAppModel { normalized(internet.routerAddress) != normalized(cleanSnapshot.internet.routerAddress) guard !needsValidation(ipv4AddressChanged) || !normalized(internet.ipv4Address).isEmpty else { - status = "IPv4 Address cannot be empty." + status = localized("IPv4 Address cannot be empty.") return nil } guard !needsValidation(ipv4AddressChanged) || isIPv4Address(internet.ipv4Address) else { - status = "IPv4 Address must be an IPv4 address." + status = localized("IPv4 Address must be an IPv4 address.") return nil } guard !needsValidation(subnetMaskChanged) || !normalized(internet.subnetMask).isEmpty else { - status = "Subnet Mask cannot be empty." + status = localized("Subnet Mask cannot be empty.") return nil } if needsValidation(subnetMaskChanged), @@ -63,11 +63,11 @@ extension AirportAppModel { } guard !needsValidation(routerAddressChanged) || !normalized(internet.routerAddress).isEmpty else { - status = "Router Address cannot be empty." + status = localized("Router Address cannot be empty.") return nil } guard !needsValidation(routerAddressChanged) || isIPv4Address(internet.routerAddress) else { - status = "Router Address must be an IPv4 address." + status = localized("Router Address must be an IPv4 address.") return nil } appendChanged( @@ -105,10 +105,10 @@ extension AirportAppModel { cleanValue: cleanDNSServersForDiff, changesOnly: changesOnly, maxCount: 2, - countError: "DNS Servers accepts at most two IPv4 DNS servers.", - emptyValueError: "DNS Servers contains an empty value.", + countError: localized("DNS Servers accepts at most two IPv4 DNS servers."), + emptyValueError: localized("DNS Servers contains an empty value."), validator: isIPv4Address, - validationError: "DNS Server must be an IPv4 address.", + validationError: localized("DNS Server must be an IPv4 address."), slotValueFlags: ["--dns-server-1", "--dns-server-2"] ) else { return nil } @@ -122,10 +122,10 @@ extension AirportAppModel { cleanValue: cleanIPv6DNSServersForDiff, changesOnly: changesOnly, maxCount: 2, - countError: "IPv6 DNS Servers accepts at most two IPv6 DNS servers.", - emptyValueError: "IPv6 DNS Servers contains an empty value.", + countError: localized("IPv6 DNS Servers accepts at most two IPv6 DNS servers."), + emptyValueError: localized("IPv6 DNS Servers contains an empty value."), validator: isIPv6Address, - validationError: "IPv6 DNS Server must be an IPv6 address.", + validationError: localized("IPv6 DNS Server must be an IPv6 address."), normalizer: normalizedIPv6Address ) else { return nil } @@ -141,7 +141,7 @@ extension AirportAppModel { let ipv6Address = normalizedIPv6Address(internet.ipv6Address) if !ipv6Address.isEmpty { guard isIPv6Address(ipv6Address) else { - status = "IPv6 Address must be an IPv6 address." + status = localized("IPv6 Address must be an IPv6 address.") return nil } flags.append(("--ipv6-address", ipv6Address)) @@ -159,11 +159,11 @@ extension AirportAppModel { cleanValue: cleanSnapshot.internet.pppoeConnection, changesOnly: changesOnly, allowed: ["always-on", "automatic", "manual"], - statusMessage: "PPPoE Connection must be always-on, automatic, or manual." + statusMessage: localized("PPPoE Connection must be always-on, automatic, or manual.") ) else { return nil } guard !shouldValidatePPPoEAccount || !normalized(internet.pppoeAccount).isEmpty else { - status = "PPPoE Account Name cannot be empty." + status = localized("PPPoE Account Name cannot be empty.") return nil } appendChanged( @@ -181,12 +181,12 @@ extension AirportAppModel { } if internet.connectUsing == .modem { guard showsModemControls else { - status = "This base station does not support a modem connection." + status = localized("This base station does not support a modem connection.") return nil } let modemModeChanged = cleanSnapshot.internet.connectUsing != .modem guard internet.modemPassword == internet.modemVerifyPassword else { - status = "Modem passwords do not match." + status = localized("Modem passwords do not match.") return nil } let diffOnly = changesOnly && !modemModeChanged @@ -305,7 +305,7 @@ extension AirportAppModel { let shouldValidateGlobalHostname = !changesOnly || dynamicGlobalHostnameChanged || globalHostnameChanged guard !shouldValidateGlobalHostname || !normalized(internet.globalHostname).isEmpty else { - status = "Global Hostname cannot be empty." + status = localized("Global Hostname cannot be empty.") return nil } appendChanged( diff --git a/Sources/AirPortUtilityCore/AirportAppModelProfiles.swift b/Sources/AirPortUtilityCore/AirportAppModelProfiles.swift index 314ee8a..0b6aeb1 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelProfiles.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelProfiles.swift @@ -60,7 +60,7 @@ extension AirportAppModel { func refreshSettings() async throws { if mockMode { loadMockState() - appendLog("Mock refresh completed.") + appendLog(localized("Mock refresh completed.")) return } @@ -91,8 +91,8 @@ extension AirportAppModel { Self.accessControlFeatureACPSettings, in: reader) guard !isEditingDevice else { - status = "Finish editing before refreshing settings." - appendLog("Ignored settings refresh while editing.") + status = localized("Finish editing before refreshing settings.") + appendLog(localized("Ignored settings refresh while editing.")) return } guard connectionStillMatches(requestHost) else { @@ -162,13 +162,13 @@ extension AirportAppModel { markClean() hasLoadedSettings = true hasTrustedConnectionPassword = true - status = "Connected to \(connection.host)" + status = localizedFormat("Connected to %@", connection.host) clearBaseStationUpdate(requestHost: requestHost) showConnectionDetails = false saveConnectionPasswordIfRequested() scheduleAutomaticFirmwareCatalogRefreshIfNeeded(requestHost: requestHost) restartWirelessClientPollingIfPossible() - appendLog("Refresh completed.") + appendLog(localized("Refresh completed.")) } func readBaseStationIdentity(connection: AirportConnection? = nil) async throws -> ( @@ -1683,7 +1683,7 @@ extension AirportAppModel { productID: String = "" ) { guard !isEditingDevice else { - appendLog("Ignored identity refresh while editing.") + appendLog(localized("Ignored identity refresh while editing.")) return } guard connectionStillMatches(requestHost) else { diff --git a/Sources/AirPortUtilityCore/AirportAppModelRestart.swift b/Sources/AirPortUtilityCore/AirportAppModelRestart.swift index ffc5b98..e8d819a 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelRestart.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelRestart.swift @@ -324,7 +324,7 @@ extension AirportAppModel { private func restoreConnectionStatusAfterRestart() { if hasLoadedSettings, liveCredentialsAvailable { - status = "Connected to \(connection.host)" + status = localizedFormat("Connected to %@", connection.host) } else { updateIdleConnectionStatus() } diff --git a/Sources/AirPortUtilityCore/AirportAppModelTopology.swift b/Sources/AirPortUtilityCore/AirportAppModelTopology.swift index 7694d54..8dc5c23 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelTopology.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelTopology.swift @@ -40,15 +40,15 @@ extension AirportAppModel { clearLoadedDeviceDetails(name: "") guard !mockMode else { - appendLog("Mock network scan completed.") + appendLog(localized("Mock network scan completed.")) return } let browser = bonjourBrowser ?? makeBonjourBrowser() bonjourBrowser = browser hasStartedBonjourDiscovery = true browser.start() - status = "Scanning for AirPort base stations…" - appendLog("Rescanning the network for AirPort base stations.") + status = localized("Scanning for AirPort base stations…") + appendLog(localized("Rescanning the network for AirPort base stations.")) } func updateDiscoveredDevices(_ devices: [AirportDiscoveredDevice]) { @@ -251,18 +251,18 @@ extension AirportAppModel { } var internetTopologyAccessibilityTitle: String { - isHostInternetConnected ? "Internet working normally" : "Internet inactive" + isHostInternetConnected ? localized("Internet working normally") : localized("Internet inactive") } func deviceStatusText(for device: AirportDiscoveredDevice? = nil) -> String { if let device, isTopologyDeviceRestoring(device) { - return "Restoring" + return localized("Restoring") } if let device, isTopologyDeviceUpdating(device) { - return "Restarting" + return localized("Restarting") } if let device, device.requiresSetup { - return "New AirPort base station" + return localized("New AirPort base station") } if let device { let status = device.statusText.trimmingCharacters(in: .whitespacesAndNewlines) @@ -274,7 +274,7 @@ extension AirportAppModel { } } let status = baseStation.statusText.trimmingCharacters(in: .whitespacesAndNewlines) - return status.isEmpty ? "Working normally" : status + return status.isEmpty ? localized("Working normally") : status } func selectedDeviceStatusText() -> String { @@ -314,7 +314,7 @@ extension AirportAppModel { return "" } guard let image = availableAppleFirmwareUpdateImage(for: device, snapshot: snapshot) else { - return "Firmware update available" + return localized("Firmware update available") } return "Firmware \(image.version) available" } diff --git a/Sources/AirPortUtilityCore/AirportAppModelWirelessFlags.swift b/Sources/AirPortUtilityCore/AirportAppModelWirelessFlags.swift index 0511c1b..8012383 100644 --- a/Sources/AirPortUtilityCore/AirportAppModelWirelessFlags.swift +++ b/Sources/AirPortUtilityCore/AirportAppModelWirelessFlags.swift @@ -39,12 +39,12 @@ extension AirportAppModel { cleanValue: cleanSnapshot.wireless.security, changesOnly: changesOnly && !wirelessModeChanged, allowed: Set(wirelessSecurityOptions.map(\.rawValue)), - statusMessage: "Wireless Security is not supported." + statusMessage: localized("Wireless Security is not supported.") ) else { return nil } let shouldValidateWirelessName = !changesOnly || wirelessModeChanged || wirelessNameChanged guard !shouldValidateWirelessName || !normalized(wireless.networkName).isEmpty else { - status = "Wireless Network Name cannot be empty." + status = localized("Wireless Network Name cannot be empty.") return nil } appendChanged( @@ -69,14 +69,14 @@ extension AirportAppModel { cleanValue: cleanSnapshot.wireless.wdsMode, changesOnly: changesOnly, allowed: ["main", "relay", "remote", "off"], - statusMessage: "WDS Mode must be main, relay, remote, or off." + statusMessage: localized("WDS Mode must be main, relay, remote, or off.") ) else { return nil } appendChanged( &flags, "--wds-mode", wireless.wdsMode, cleanSnapshot.wireless.wdsMode, changesOnly: changesOnly) guard isValidWDSPeerAirPortIDs(wireless.wdsPeerAirPortIDs) else { - status = "WDS peer AirPort IDs must be one or two MAC addresses." + status = localized("WDS peer AirPort IDs must be one or two MAC addresses.") return nil } appendChanged( @@ -96,15 +96,15 @@ extension AirportAppModel { || (wirelessNameChanged && !wirelessPassword.isEmpty) if shouldRewriteWirelessPassword { guard !normalized(wireless.networkName).isEmpty else { - status = "Wireless Network Name cannot be empty." + status = localized("Wireless Network Name cannot be empty.") return nil } guard !wirelessPassword.isEmpty else { - status = "Wireless Password cannot be empty." + status = localized("Wireless Password cannot be empty.") return nil } guard wirelessPassword == verifyWirelessPassword else { - status = "Wireless passwords do not match." + status = localized("Wireless passwords do not match.") return nil } } @@ -127,7 +127,7 @@ extension AirportAppModel { "80211b", "80211bg", "80211g", "80211a", "80211n-a", "80211n-bg", "80211n-only-24", "80211n-only-5", ], - statusMessage: "Radio Mode is not supported.", + statusMessage: localized("Radio Mode is not supported."), allowEmpty: true ) else { return nil } @@ -135,7 +135,7 @@ extension AirportAppModel { && (!changesOnly || regionCode != normalized(cleanSnapshot.wireless.regionCode)) { guard let code = Int(regionCode), (0...255).contains(code) else { - status = "Region code must be between 0 and 255." + status = localized("Region code must be between 0 and 255.") return nil } } @@ -146,7 +146,7 @@ extension AirportAppModel { radioChannel == "automatic" || (Int(radioChannel).map { (1...200).contains($0) } ?? false) else { - status = "Radio channel must be 'automatic' or a channel number." + status = localized("Radio channel must be 'automatic' or a channel number.") return nil } } @@ -175,16 +175,16 @@ extension AirportAppModel { let cleanOptions = cleanSnapshot.legacyDeviceOptions.wireless guard MulticastRateOption.allCases.contains(where: { $0.value == options.multicastRate }) else { - status = "Multicast Rate is not supported." + status = localized("Multicast Rate is not supported.") return nil } guard TransmitPowerOption.allCases.contains(where: { $0.percent == options.transmitPower }) else { - status = "Transmit Power is not supported." + status = localized("Transmit Power is not supported.") return nil } guard (60...86_400).contains(options.groupKeyTimeoutSeconds) else { - status = "WPA Group Key Timeout must be between 60 seconds and 24 hours." + status = localized("WPA Group Key Timeout must be between 60 seconds and 24 hours.") return nil } appendChanged( diff --git a/Sources/AirPortUtilityCore/AirportMockBackend.swift b/Sources/AirPortUtilityCore/AirportMockBackend.swift index 8fe2a7e..be81d1b 100644 --- a/Sources/AirPortUtilityCore/AirportMockBackend.swift +++ b/Sources/AirPortUtilityCore/AirportMockBackend.swift @@ -10,6 +10,9 @@ enum AirportMockBackend { { "deviceName": "wd0", "builtIn": true, + "vendor": "WDC WD20EARX-00PASB0", + "revision": "51.0AB51", + "smartStatus": "verified", "partitions": [ { "deviceName": "dk2", @@ -17,7 +20,8 @@ enum AirportMockBackend { "format": "HFS", "uuid": {"type":"bytes","length":16,"hex":"adabbc6e09e0579081f8444e687f35b9"}, "size": 953674, - "sizeFree": 474787 + "sizeFree": 474787, + "sizeUsed": 478887 } ] }, @@ -55,19 +59,19 @@ enum AirportMockBackend { static func statusText(environmentValue: EnvironmentLookup) -> String { switch (environmentValue("AIRPORT_UTILITY_MOCK_STATUS") ?? "ok").lowercased() { case "archive", "archiving": - return "Archiving disk" + return localized("Archiving disk") case "corrupt", "corrupted", "disk-corrupted", "disk_corrupted", "repair": - return "Disk needs repair" + return localized("Disk needs repair") case "config", "configuration", "configuration-incorrect", "configuration_incorrect": - return "Configuration problem" + return localized("Configuration problem") case "double-nat", "double_nat": - return "Double NAT" + return localized("Double NAT") case "dns", "no-dns", "no_dns": - return "No DNS servers configured" + return localized("No DNS servers configured") case "restart", "restarting": - return "Restarting" + return localized("Restarting") default: - return "Working normally" + return localized("Working normally") } } @@ -95,7 +99,7 @@ enum AirportMockBackend { extendsDeviceID: "mock-time-capsule", modelName: "AirPort Express", productID: "115", - statusText: "Working normally") + statusText: localized("Working normally")) let extreme = AirportDiscoveredDevice( id: "mock-extreme", name: "guest extreme", @@ -104,7 +108,7 @@ enum AirportMockBackend { identifiers: ["mock-extreme"], modelName: "AirPort Extreme", productID: "117", - statusText: "Working normally") + statusText: localized("Working normally")) switch (environmentValue("AIRPORT_UTILITY_MOCK_TOPOLOGY") ?? "single").lowercased() { case "independent": diff --git a/Sources/AirPortUtilityCore/AirportServiceErrors.swift b/Sources/AirPortUtilityCore/AirportServiceErrors.swift index 65daa11..1b44df7 100644 --- a/Sources/AirPortUtilityCore/AirportServiceErrors.swift +++ b/Sources/AirPortUtilityCore/AirportServiceErrors.swift @@ -26,7 +26,7 @@ enum FirmwareInstallError: LocalizedError { case .uploadIncomplete(let progress): return "Firmware upload did not complete. Last reported progress: \(progress)." case .rebootNotSent: - return "Firmware upload completed, but the base station reboot command was not sent." + return localized("Firmware upload completed, but the base station reboot command was not sent.") case .versionMismatch(let expected, let actual): return "Firmware install completed, but the base station reports version \(actual) instead of \(expected)." diff --git a/Sources/AirPortUtilityCore/AppSheets.swift b/Sources/AirPortUtilityCore/AppSheets.swift index 4c9ac8a..42ec8ac 100644 --- a/Sources/AirPortUtilityCore/AppSheets.swift +++ b/Sources/AirPortUtilityCore/AppSheets.swift @@ -6,14 +6,18 @@ struct PasswordsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - Text("Passwords") + Text(localized("Passwords")) .font(.system(size: 13, weight: .semibold)) .padding(.bottom, 13) VStack(alignment: .leading, spacing: 8) { - passwordRow("Base Station Password:", value: baseStationPassword) + passwordRow( + localized("Base Station Password:"), value: baseStationPassword, + identifier: "passwords.base.station.value") if shouldShowDiskPassword { - passwordRow("Disk Password:", value: diskPassword) + passwordRow( + localized("Disk Password:"), value: diskPassword, + identifier: "passwords.disk.value") } } .padding(.bottom, 20) @@ -32,17 +36,20 @@ struct PasswordsSheet: View { .frame(width: 360, alignment: .leading) } - private func passwordRow(_ label: String, value: String) -> some View { + /// The accessibility identifier is passed in rather than derived from the + /// label. Deriving it meant matching the English word "Disk", which stops + /// matching as soon as the label is translated. + private func passwordRow( + _ label: String, value: String, identifier: String + ) -> some View { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(label) .font(.system(size: 13)) .frame(width: 145, alignment: .trailing) - Text(value.isEmpty ? "Not available" : value) + Text(value.isEmpty ? localized("Not available") : value) .font(.system(size: 13)) .textSelection(.enabled) - .accessibilityIdentifier( - label.localizedCaseInsensitiveContains("Disk") ? "passwords.disk.value" - : "passwords.base.station.value") + .accessibilityIdentifier(identifier) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -71,11 +78,11 @@ struct PreferencesSheet: View { var body: some View { VStack(alignment: .leading, spacing: 18) { - Text("Preferences") + Text(localized("Preferences")) .font(.system(size: 13, weight: .semibold)) Toggle( - "Show connection details in the Other Wi-Fi Devices menu", + localized("Show connection details in the Other Wi-Fi Devices menu"), isOn: $model.showConnectionDetails ) .toggleStyle(.checkbox) @@ -103,36 +110,36 @@ struct ConfigureOtherSheet: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - Text("Configure Other") + Text(localized("Configure Other")) .font(.system(size: 13, weight: .semibold)) VStack(alignment: .leading, spacing: 10) { - labeledField("Host:") { + labeledField(localized("Host:")) { AirPortTextField( text: $model.connection.host, placeholder: "Host", identifier: "configure.other.host") .frame(width: 240, height: 24) } - labeledField("Password:") { + labeledField(localized("Password:")) { AirPortSecureField( text: $model.connection.password, - placeholder: "Password", + placeholder: localized("Password"), identifier: "configure.other.password", onSubmit: submitConnection) .frame(width: 240, height: 24) } if !model.mockMode { - labeledField("Repository:") { + labeledField(localized("Repository:")) { AirPortTextField( text: $model.connection.repoPath, - placeholder: "Repository", + placeholder: localized("Repository"), identifier: "configure.other.repository") .frame(width: 240, height: 24) } } Toggle( - "Remember this password in my keychain", + localized("Remember this password in my keychain"), isOn: Binding( get: { model.rememberConnectionPassword }, set: { model.updateRememberConnectionPassword($0) })) @@ -152,12 +159,12 @@ struct ConfigureOtherSheet: View { HStack { Spacer() - Button("Cancel") { + Button(localized("Cancel")) { dismiss() } .accessibilityIdentifier("configure.other.cancel") .frame(width: 70) - Button(model.isBusy ? "Working" : "Connect") { + Button(model.isBusy ? localized("Working") : localized("Connect")) { submitConnection() } .accessibilityIdentifier("configure.other.connect") diff --git a/Sources/AirPortUtilityCore/BaseStationPane.swift b/Sources/AirPortUtilityCore/BaseStationPane.swift index e17374c..ffb4dfc 100644 --- a/Sources/AirPortUtilityCore/BaseStationPane.swift +++ b/Sources/AirPortUtilityCore/BaseStationPane.swift @@ -5,31 +5,44 @@ struct BaseStationPane: View { var body: some View { PaneBox { - FormRow(title: "Base Station Name:") { + // Read-only: the device reports a product ID, not a generation, and this + // is the only place the hardware it maps to is stated. + if let generation = AirPortDeviceGeneration.label( + forProductID: model.baseStation.productID) + { + FormRow(title: localized("Generation:")) { + Text(generation) + .font(.system(size: 13)) + .textSelection(.enabled) + .accessibilityIdentifier("base.station.generation") + .frame(maxWidth: .infinity, alignment: .leading) + } + } + FormRow(title: localized("Base Station Name:")) { AirPortTextField( text: $model.baseStation.name, - placeholder: "Name", + placeholder: localized("Name"), selectOnAppear: true, identifier: "base.station.name") } - FormRow(title: "Base Station Password:") { + FormRow(title: localized("Base Station Password:")) { AirPortSecureField( text: $model.baseStation.newAdminPassword, - placeholder: "New password", + placeholder: localized("New password"), identifier: "base.station.admin.password") .frame(height: 24) } - FormRow(title: "Verify Password:") { + FormRow(title: localized("Verify Password:")) { AirPortSecureField( text: $model.baseStation.verifyAdminPassword, - placeholder: "Verify password", + placeholder: localized("Verify password"), identifier: "base.station.admin.verify.password") .frame(height: 24) } HStack { Spacer().frame(width: AirPortLayout.formControlLeading) BaseStationCheckbox( - "Remember this password in my keychain", + localized("Remember this password in my keychain"), isOn: Binding( get: { model.rememberConnectionPassword }, set: { model.updateRememberConnectionPassword($0) }), @@ -38,17 +51,17 @@ struct BaseStationPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) BaseStationCheckbox( - "Allow setup over Ethernet WAN port", + localized("Allow setup over Ethernet WAN port"), isOn: $model.baseStation.allowSetupOverWAN, identifier: "base.station.allow.setup.over.wan") } if model.capabilities.supportsBaseStationMetadata { - FormRow(title: "Contact:") { + FormRow(title: localized("Contact:")) { AirPortTextField( text: $model.legacyDeviceOptions.baseStation.contact, identifier: "base.station.contact") } - FormRow(title: "Location:") { + FormRow(title: localized("Location:")) { AirPortTextField( text: $model.legacyDeviceOptions.baseStation.location, identifier: "base.station.location") @@ -57,11 +70,11 @@ struct BaseStationPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) BaseStationCheckbox( - "Set time automatically", + localized("Set time automatically"), isOn: $model.legacyDeviceOptions.baseStation.setTimeAutomatically, identifier: "base.station.set.time.automatically") } - FormRow(title: "Time Server:") { + FormRow(title: localized("Time Server:")) { AirPortTextField( text: $model.legacyDeviceOptions.baseStation.timeServer, placeholder: "time.apple.com", diff --git a/Sources/AirPortUtilityCore/DeviceStatusMessage.swift b/Sources/AirPortUtilityCore/DeviceStatusMessage.swift index 9d77720..14c4c0a 100644 --- a/Sources/AirPortUtilityCore/DeviceStatusMessage.swift +++ b/Sources/AirPortUtilityCore/DeviceStatusMessage.swift @@ -3,40 +3,40 @@ import Foundation enum DeviceStatusMessage { static func text(problemCodes: [String]) -> String { let codes = Set(normalizedProblemCodes(problemCodes)) - guard !codes.isEmpty else { return "Working normally" } + guard !codes.isEmpty else { return localized("Working normally") } if !codes.isDisjoint(with: ["ArcI"]) { - return "Archiving disk" + return localized("Archiving disk") } if !codes.isDisjoint(with: ["EraI"]) { - return "Erasing disk" + return localized("Erasing disk") } if !codes.isDisjoint(with: ["fsck", "Ifsc", "SSdF", "mgrt"]) { - return "Disk needs repair" + return localized("Disk needs repair") } if !codes.isDisjoint(with: ["Ifsl"]) { - return "Disk space is low" + return localized("Disk space is low") } if !codes.isDisjoint(with: ["DubN"]) { - return "Double NAT" + return localized("Double NAT") } if !codes.isDisjoint(with: ["nDNS"]) { - return "No DNS servers configured" + return localized("No DNS servers configured") } if !codes.isDisjoint(with: ["pubP"]) { - return "Default password" + return localized("Default password") } if !codes.isDisjoint(with: ["opNW"]) { - return "Open wireless network" + return localized("Open wireless network") } if !codes.isDisjoint(with: ["waCF"]) { - return "WAN setup over Ethernet" + return localized("WAN setup over Ethernet") } if !codes.isDisjoint(with: ["wdsP", "bsWD"]) { - return "Wireless extension problem" + return localized("Wireless extension problem") } if codes.contains(where: { $0.hasPrefix("vErr") }) { - return "Configuration problem" + return localized("Configuration problem") } return "Needs attention: \(codes.sorted().joined(separator: ", "))" } @@ -54,22 +54,22 @@ enum DeviceStatusMessage { var details: [String] = [] if codes.contains("DubN") { if routerMode == .bridge { - details.append("Reports Double NAT despite Bridge Mode.") + details.append(localized("Reports Double NAT despite Bridge Mode.")) } else { - details.append("Another router appears to be providing NAT upstream of this base station.") + details.append(localized("Another router appears to be providing NAT upstream of this base station.")) } } if codes.contains("pubP") { - details.append("The base station is still using the default admin password.") + details.append(localized("The base station is still using the default admin password.")) } if codes.contains("opNW") { - details.append("The wireless network is open and does not require a Wi-Fi password.") + details.append(localized("The wireless network is open and does not require a Wi-Fi password.")) } if codes.contains("waCF") { - details.append("Setup over the Ethernet WAN port is enabled.") + details.append(localized("Setup over the Ethernet WAN port is enabled.")) } if codes.contains("ctim") { - details.append("Initial setup has not been marked complete.") + details.append(localized("Initial setup has not been marked complete.")) } return details } diff --git a/Sources/AirPortUtilityCore/DiskActionSheets.swift b/Sources/AirPortUtilityCore/DiskActionSheets.swift index ef5ef9f..4805e9c 100644 --- a/Sources/AirPortUtilityCore/DiskActionSheets.swift +++ b/Sources/AirPortUtilityCore/DiskActionSheets.swift @@ -12,17 +12,17 @@ struct EraseDiskSheet: View { diskIcon .offset(x: 14, y: 19) - Text("Are you sure you want to erase the AirPort Time\nCapsule disk? ") + Text(localized("Are you sure you want to erase the AirPort Time Capsule disk?")) .font(.system(size: 13, weight: .semibold)) .frame(width: 401, height: 36, alignment: .topLeading) .offset(x: 101, y: 19) - Text("Erasing the AirPort Time Capsule disk deletes all files from the disk.") + Text(localized("Erasing the AirPort Time Capsule disk deletes all files from the disk.")) .font(.system(size: 13)) - .frame(width: 430, height: 31, alignment: .topLeading) + .frame(width: 430, height: 36, alignment: .topLeading) .offset(x: 101, y: 64) - sheetLabel("Name:") + sheetLabel(localized("Name:")) .offset(x: 126, y: 101) AirPortTextField( text: $diskName, @@ -30,9 +30,9 @@ struct EraseDiskSheet: View { .frame(width: 262, height: 24) .offset(x: 239, y: 99) - sheetLabel("Security Method:") + sheetLabel(localized("Security Method:")) .offset(x: 126, y: 127) - Picker("Security Method", selection: $method) { + Picker(localized("Security Method"), selection: $method) { ForEach(EraseMethod.allCases) { method in Text(method.eraseSheetLabel).tag(method) } @@ -50,16 +50,21 @@ struct EraseDiskSheet: View { .disabled(true) .offset(x: 101, y: 166) - DiskSheetButton( - "Cancel", width: 70, isDefault: true, - identifier: "erase.disk.cancel" - ) { dismiss() } - .offset(x: 356, y: 245) - DiskSheetButton("Erase", width: 62, identifier: "erase.disk.confirm") { - model.applyErase(method: method, volumeName: diskName) - dismiss() + // Right-anchored: a wider translated label would otherwise grow into the + // neighbouring button. Trailing edge (500) and 12pt gap match the English + // layout exactly (Cancel 356-426, Erase 438-500). + HStack(spacing: 12) { + DiskSheetButton( + localized("Cancel"), width: 70, isDefault: true, + identifier: "erase.disk.cancel" + ) { dismiss() } + DiskSheetButton(localized("Erase"), width: 62, identifier: "erase.disk.confirm") { + model.applyErase(method: method, volumeName: diskName) + dismiss() + } } - .offset(x: 438, y: 245) + .frame(width: 500, alignment: .trailing) + .offset(x: 0, y: 245) } .onAppear { if diskName.isEmpty { @@ -73,7 +78,7 @@ struct EraseDiskSheet: View { nonisolated static func initialDiskName(disks: DisksState) -> String { DisksPane.selectedDisk(in: disks)?.name ?? disks.inventory.first?.name - ?? "AirPort Time Capsule Disk" + ?? localized("AirPort Time Capsule Disk") } } @@ -87,22 +92,24 @@ struct ArchiveDiskSheet: View { .offset(x: 14, y: 19) Text( - "Are you sure you want to archive the AirPort Time Capsule\ndisk to a disk connected using USB? " + localized( + "Are you sure you want to archive the AirPort Time Capsule disk to a disk connected using USB?" + ) ) .font(.system(size: 13, weight: .semibold)) .frame(width: 402, height: 44, alignment: .topLeading) .offset(x: 101, y: 19) - Text("Archive the AirPort Time Capsule disk to back up your data.") + Text(localized("Archive the AirPort Time Capsule disk to back up your data.")) .font(.system(size: 13)) - .frame(width: 401, height: 19, alignment: .topLeading) + .frame(width: 401, height: 36, alignment: .topLeading) .offset(x: 101, y: 69) - sheetLabel("Destination:") + sheetLabel(localized("Destination:")) .offset(x: 126, y: 108) - Picker("Destination", selection: .constant(destinationName ?? "No AirPort disks available")) { - Text(destinationName ?? "No AirPort disks available") - .tag(destinationName ?? "No AirPort disks available") + Picker(localized("Destination"), selection: .constant(destinationName ?? localized("No AirPort disks available"))) { + Text(destinationName ?? localized("No AirPort disks available")) + .tag(destinationName ?? localized("No AirPort disks available")) } .labelsHidden() .pickerStyle(.menu) @@ -112,39 +119,44 @@ struct ArchiveDiskSheet: View { .offset(x: 239, y: 106) Text( - "Make sure the storage device has enough space for the archive before connecting it to the base station’s USB Port." + localized("Make sure the storage device has enough space for the archive before connecting it to the base station’s USB Port.") ) .font(.system(size: 13)) .foregroundStyle(Color.primary.opacity(0.82)) - .frame(width: 402, height: 42, alignment: .topLeading) + .frame(width: 402, height: 52, alignment: .topLeading) .disabled(true) .offset(x: 101, y: 148) - if destinationName == nil { - DiskSheetButton( - "Cancel", width: 70, isDefault: true, - identifier: "archive.disk.cancel" - ) { dismiss() } - .offset(x: 343, y: 203) - DiskSheetButton( - "Archive", width: 75, isEnabled: false, - identifier: "archive.disk.confirm" - ) {} - .offset(x: 425, y: 203) - } else { - DiskSheetButton("Cancel", width: 70, identifier: "archive.disk.cancel") { dismiss() } - .offset(x: 343, y: 203) - DiskSheetButton( - "Archive", width: 75, isDefault: true, - identifier: "archive.disk.confirm" - ) { - if destinationName != nil { - model.applyArchive(name: "") + // Right-anchored: a wider translated label would otherwise grow into the + // neighbouring button. Trailing edge (500) and 12pt gap match the English + // layout exactly (Cancel 343-413, Archive 425-500). + HStack(spacing: 12) { + if destinationName == nil { + DiskSheetButton( + localized("Cancel"), width: 70, isDefault: true, + identifier: "archive.disk.cancel" + ) { dismiss() } + DiskSheetButton( + localized("Archive"), width: 75, isEnabled: false, + identifier: "archive.disk.confirm" + ) {} + } else { + DiskSheetButton(localized("Cancel"), width: 70, identifier: "archive.disk.cancel") { dismiss() } + DiskSheetButton( + localized("Archive"), width: 75, isDefault: true, + identifier: "archive.disk.confirm" + ) { + if destinationName != nil { + model.applyArchive(name: "") + dismiss() + } + } } - .offset(x: 425, y: 203) } + .frame(width: 500, alignment: .trailing) + .offset(x: 0, y: 203) } .frame(width: 521, height: 244, alignment: .topLeading) .background(AirPortSheetBackground()) @@ -188,7 +200,13 @@ private struct DiskSheetButton: NSViewRepresentable { button.setButtonType(.momentaryPushIn) button.alignment = .center button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: width).isActive = true + // Exact width, but never narrower than the label needs. The constants were + // measured against English and truncate longer translations; a plain + // greaterThanOrEqual constraint instead lets the button expand to fill, + // which changes the English layout. + button.widthAnchor.constraint( + equalToConstant: max(width, button.intrinsicContentSize.width) + ).isActive = true button.heightAnchor.constraint(equalToConstant: 22).isActive = true configure(button) return button @@ -245,13 +263,13 @@ extension EraseMethod { fileprivate var eraseSheetLabel: String { switch self { case .quick: - return "Quick Erase (non-secure)" + return localized("Quick Erase (non-secure)") case .zero: - return "Zero Out Data" + return localized("Zero Out Data") case .sevenPass: - return "7-Pass Erase" + return localized("7-Pass Erase") case .thirtyFivePass: - return "35-Pass Erase" + return localized("35-Pass Erase") } } @@ -259,16 +277,16 @@ extension EraseMethod { switch self { case .quick: return - "Erases directory information so that data is no longer accessible. The data is left unchanged on disk until its disk space is required and it is written over. Data is potentially recoverable until then. This option is the quickest, but least secure." + localized("Erases directory information so that data is no longer accessible. The data is left unchanged on disk until its disk space is required and it is written over. Data is potentially recoverable until then. This option is the quickest, but least secure.") case .zero: return - "Writes zeros over all data on the disk. This option provides better security than a quick erase, but takes longer." + localized("Writes zeros over all data on the disk. This option provides better security than a quick erase, but takes longer.") case .sevenPass: return - "Writes over disk data seven times. This option is more secure and takes significantly longer." + localized("Writes over disk data seven times. This option is more secure and takes significantly longer.") case .thirtyFivePass: return - "Writes over disk data thirty-five times. This option is the most secure and takes the longest." + localized("Writes over disk data thirty-five times. This option is the most secure and takes the longest.") } } } diff --git a/Sources/AirPortUtilityCore/DiskInventoryMessage.swift b/Sources/AirPortUtilityCore/DiskInventoryMessage.swift index f75418b..84b8647 100644 --- a/Sources/AirPortUtilityCore/DiskInventoryMessage.swift +++ b/Sources/AirPortUtilityCore/DiskInventoryMessage.swift @@ -42,7 +42,7 @@ enum DiskInventoryMessage { static func userFacingErrorDescription(_ description: String) -> String { if containsPendingPlaceholder(description) { - return "Disk information is not available yet." + return localized("Disk information is not available yet.") } if containsSettingReference(description) { return friendlyErrorDescription(description) @@ -52,7 +52,7 @@ enum DiskInventoryMessage { static func userFacingCommandOutput(_ output: String) -> String { if isOnlyPendingOutput(output) || looksLikePendingFailure(output) { - return "Disk information is not available yet." + return localized("Disk information is not available yet.") } let output = removingPendingLines(from: output) if containsSettingReference(output) { diff --git a/Sources/AirPortUtilityCore/DiskInventoryParser.swift b/Sources/AirPortUtilityCore/DiskInventoryParser.swift index cd1331f..c9d0401 100644 --- a/Sources/AirPortUtilityCore/DiskInventoryParser.swift +++ b/Sources/AirPortUtilityCore/DiskInventoryParser.swift @@ -10,11 +10,25 @@ enum DiskInventoryParser { return records(in: value, parentBuiltIn: nil) } - private static func records(in value: JSONValue, parentBuiltIn: Bool?) -> [DiskRecord] { + /// Values a partition can inherit from the disk it lives on. The device + /// reports some of them once per physical disk rather than per partition. + struct InheritedDiskValues { + var smartStatus: String = "" + var size: Int64? + var sizeFree: Int64? + var vendor: String = "" + var revision: String = "" + } + + private static func records( + in value: JSONValue, parentBuiltIn: Bool?, inherited: InheritedDiskValues = .init() + ) -> [DiskRecord] { switch value { case .array(let values): let diskDefaultBuiltIn = isSingleUnlabeledDiskArray(values) ? true : parentBuiltIn - return values.flatMap { records(in: $0, parentBuiltIn: diskDefaultBuiltIn) } + return values.flatMap { + records(in: $0, parentBuiltIn: diskDefaultBuiltIn, inherited: inherited) + } case .object(let object): if let settings = object["settings"], case .object(let settingsObject) = settings, @@ -26,16 +40,32 @@ enum DiskInventoryParser { return records(in: mast, parentBuiltIn: nil) } if let decoded = object["decoded"] { - return records(in: decoded, parentBuiltIn: parentBuiltIn) + return records(in: decoded, parentBuiltIn: parentBuiltIn, inherited: inherited) } if let disks = object["disks"] { return records(in: disks, parentBuiltIn: nil) } if case .array(let partitions) = object["partitions"] { let diskBuiltIn = diskBuiltIn(object, defaultBuiltIn: parentBuiltIn) - return partitions.flatMap { records(in: $0, parentBuiltIn: diskBuiltIn) } + // SMART, and on some devices the capacity too, are reported once per + // physical disk rather than per partition. Carry them down so a + // partition can fall back to its disk's values. + let diskSMART = string(object["smartStatus"]) + var childInherited = inherited + if !diskSMART.isEmpty { childInherited.smartStatus = diskSMART } + if let size = maStByteCount(object["size"]) { childInherited.size = size } + if let sizeFree = maStByteCount(object["sizeFree"]) { childInherited.sizeFree = sizeFree } + // Vendor and firmware revision describe the physical drive, so they are + // only ever present on the disk, never on a partition. + let vendor = string(object["vendor"]) + if !vendor.isEmpty { childInherited.vendor = vendor } + let revision = string(object["revision"]) + if !revision.isEmpty { childInherited.revision = revision } + return partitions.flatMap { + records(in: $0, parentBuiltIn: diskBuiltIn, inherited: childInherited) + } } - if let record = record(from: object, parentBuiltIn: parentBuiltIn) { + if let record = record(from: object, parentBuiltIn: parentBuiltIn, inherited: inherited) { return [record] } return [] @@ -44,8 +74,10 @@ enum DiskInventoryParser { } } - private static func record(from object: [String: JSONValue], parentBuiltIn: Bool?) -> DiskRecord? - { + private static func record( + from object: [String: JSONValue], parentBuiltIn: Bool?, + inherited: InheritedDiskValues = .init() + ) -> DiskRecord? { let uuid = string(object["uuid"]) let name = string(object["name"]) let deviceName = string(object["deviceName"]) @@ -55,9 +87,22 @@ enum DiskInventoryParser { name: name.isEmpty ? deviceName : name, format: string(object["format"]), uuid: uuid, - size: maStByteCount(object["size"]), - sizeFree: maStByteCount(object["sizeFree"]), - builtIn: diskBuiltIn(object, defaultBuiltIn: parentBuiltIn) + size: maStByteCount(object["size"]) ?? inherited.size, + sizeFree: maStByteCount(object["sizeFree"]) ?? inherited.sizeFree, + builtIn: diskBuiltIn(object, defaultBuiltIn: parentBuiltIn), + vendor: { + let own = string(object["vendor"]) + return own.isEmpty ? inherited.vendor : own + }(), + revision: { + let own = string(object["revision"]) + return own.isEmpty ? inherited.revision : own + }(), + sizeUsed: maStByteCount(object["sizeUsed"]), + smartStatus: { + let own = string(object["smartStatus"]) + return own.isEmpty ? inherited.smartStatus : own + }() ) } @@ -119,6 +164,11 @@ enum DiskInventoryParser { return safeInt64(number) case .string(let text): return Int64(text) + case .object(let object): + // The device does not send bare numbers: an integer arrives wrapped as + // {"type": "integer", "decimal": "623863", "width": 4}. Without this the + // sizes decode to nil and the Disks pane shows no free space. + return int64(object["decimal"]) default: return nil } diff --git a/Sources/AirPortUtilityCore/DisksPane.swift b/Sources/AirPortUtilityCore/DisksPane.swift index 2f9f740..723bd7e 100644 --- a/Sources/AirPortUtilityCore/DisksPane.swift +++ b/Sources/AirPortUtilityCore/DisksPane.swift @@ -16,51 +16,50 @@ struct DisksPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) DisksPaneButton( - "Erase Disk…", width: 107, isEnabled: selectedDisk != nil, + localized("Erase Disk…"), width: 107, isEnabled: selectedDisk != nil, identifier: "disks.erase.open" ) { showErase = true } Spacer() DisksPaneButton( - "Archive Disk…", width: 120, isEnabled: canArchiveDisk, + localized("Archive Disk…"), width: 120, isEnabled: canArchiveDisk, identifier: "disks.archive.open" ) { showArchive = true } } - .frame(width: 485) HStack { Spacer().frame(width: AirPortLayout.formControlLeading) DisksCheckbox( - "Enable file sharing", isOn: $model.disks.fileSharing, + localized("Enable file sharing"), isOn: $model.disks.fileSharing, identifier: "disks.file.sharing") } .padding(.top, 11) - FormRow(title: "Secure Shared Disks:") { + FormRow(title: localized("Secure Shared Disks:")) { DiskSecurityPopup( selection: $model.disks.secureSharedDisks, identifier: "disks.secure.shared.disks") .frame(width: 279, height: 20) } if model.disks.secureSharedDisks == "disk-password" { - FormRow(title: "Disk Password:") { + FormRow(title: localized("Disk Password:")) { AirPortSecureField( text: $model.disks.diskPassword, - placeholder: "Disk password", + placeholder: localized("Disk password"), identifier: "disks.disk.password") .frame(height: 24) } - FormRow(title: "Verify Password:") { + FormRow(title: localized("Verify Password:")) { AirPortSecureField( text: $model.disks.verifyDiskPassword, - placeholder: "Verify disk password", + placeholder: localized("Verify disk password"), identifier: "disks.verify.password") .frame(height: 24) } } if model.disks.secureSharedDisks == "accounts" { - FormRow(title: "Accounts:") { + FormRow(title: localized("Accounts:")) { DiskAccountsEditor( accounts: $model.disks.fileSharingAccounts, selectedID: $model.disks.selectedFileSharingAccountID, @@ -69,28 +68,28 @@ struct DisksPane: View { if model.supportsDiskFileSharingAccountEditing, let selectedAccount = selectedFileSharingAccountBinding { - FormRow(title: "Account Name:") { + FormRow(title: localized("Account Name:")) { AirPortTextField( text: selectedAccount.name, - placeholder: "Account name", + placeholder: localized("Account name"), identifier: "disks.account.name") .frame(height: 24) } - FormRow(title: "Password:") { + FormRow(title: localized("Password:")) { AirPortSecureField( text: selectedAccount.password, - placeholder: "Account password", + placeholder: localized("Account password"), identifier: "disks.account.password") .frame(height: 24) } - FormRow(title: "Verify Password:") { + FormRow(title: localized("Verify Password:")) { AirPortSecureField( text: selectedAccount.verifyPassword, - placeholder: "Verify account password", + placeholder: localized("Verify account password"), identifier: "disks.account.verify.password") .frame(height: 24) } - FormRow(title: "File Sharing Access:") { + FormRow(title: localized("File Sharing Access:")) { DiskAccountAccessPopup( selection: selectedAccount.access, identifier: "disks.account.file.sharing.access") @@ -101,7 +100,7 @@ struct DisksPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) DisksCheckbox( - "Remember this password in my keychain", + localized("Remember this password in my keychain"), isOn: Binding( get: { model.remembersCurrentDiskPassword }, set: { model.updateRememberCurrentDiskPassword($0) }), @@ -194,7 +193,13 @@ struct DisksPaneButton: NSViewRepresentable { button.setButtonType(.momentaryPushIn) button.alignment = .center button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: width).isActive = true + // Exact width, but never narrower than the label needs. The constants were + // measured against English and truncate longer translations; a plain + // greaterThanOrEqual constraint instead lets the button expand to fill, + // which changes the English layout. + button.widthAnchor.constraint( + equalToConstant: max(width, button.intrinsicContentSize.width) + ).isActive = true button.heightAnchor.constraint(equalToConstant: 22).isActive = true button.isEnabled = isEnabled button.setAccessibilityTitle(title) @@ -241,9 +246,9 @@ private struct DiskSecurityPopup: NSViewRepresentable { var identifier: String private let options = [ - ("With accounts", "accounts"), - ("With a disk password", "disk-password"), - ("With device password", "device-password"), + (localized("With accounts"), "accounts"), + (localized("With a disk password"), "disk-password"), + (localized("With device password"), "device-password"), ] func makeNSView(context: Context) -> NSPopUpButton { @@ -315,9 +320,9 @@ private struct DiskAccountAccessPopup: NSViewRepresentable { var identifier: String private let options = [ - ("Read and Write", "read-write"), - ("Read Only", "read-only"), - ("Not Allowed", "not-allowed"), + (localized("Read and Write"), "read-write"), + (localized("Read Only"), "read-only"), + (localized("Not Allowed"), "not-allowed"), ] func makeNSView(context: Context) -> NSPopUpButton { @@ -458,7 +463,7 @@ private struct DiskAccountsEditor: View { VStack(alignment: .leading, spacing: 6) { VStack(spacing: 0) { HStack { - Text("Account Name") + Text(localized("Account Name")) .font(.system(size: 12)) .foregroundStyle(Color.white.opacity(0.78)) Spacer() @@ -584,7 +589,7 @@ private struct DiskAccountRow: View { .disabled(!isEnabled) .contentShape(Rectangle()) .onTapGesture(perform: select) - .accessibilityLabel(name.isEmpty ? "File sharing account" : name) + .accessibilityLabel(name.isEmpty ? localized("File sharing account") : name) .accessibilityValue(isSelected ? "selected" : "") .accessibilityIdentifier(identifier ?? "") } @@ -633,7 +638,7 @@ struct DiskInventoryList: View { private func partitionsRow(@ViewBuilder content: () -> Content) -> some View { HStack(alignment: .top, spacing: AirPortLayout.formColumnSpacing) { - Text("Partitions:") + Text(localized("Partitions:")) .font(.system(size: 13)) .frame(width: AirPortLayout.formLabelWidth, alignment: .trailing) .padding(.top, 8) @@ -658,19 +663,70 @@ struct DiskInventoryList: View { nonisolated static func emptyStateText(didLoadInventory: Bool, isLoading: Bool) -> String { if isLoading { - return "Loading disk information..." + return localized("Loading disk information...") } if didLoadInventory { - return "No disk partitions found." + return localized("No disk partitions found.") } - return "No disk information loaded." + return localized("No disk information loaded.") } } -private struct DiskInventoryRow: View { +struct DiskInventoryRow: View { var record: DiskRecord var isSelected: Bool + /// The drive behind this volume: vendor and model, then firmware revision. + static func hardwareLine(for record: DiskRecord) -> String? { + let parts = [record.vendor, record.revision] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + /// Capacity and health: total, how much is used, and the SMART status. + /// + /// Shown beside the volume rather than behind a button: the device publishes + /// all of it with the disk inventory, so there is nothing to run. The SMART + /// value passes through `localized`, so a known term is translated and + /// anything unfamiliar appears verbatim rather than being guessed at. + static func capacityLine(for record: DiskRecord) -> String? { + var parts: [String] = [] + let byteCount = { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } + // "1.34 TB used / 2 TB" rather than a bare "1.34 TB / 2 TB": without the + // word there is nothing to say which number is which. The row only has + // about 205pt of text width, which a full-precision pair overruns even + // without the word, so the line is allowed to scale down slightly (see + // `body`) instead of being kept short at the cost of being ambiguous. + switch (record.sizeUsed, record.size, record.sizeFree) { + case let (used?, total?, _): + parts.append(localizedFormat("%1$@ used / %2$@", byteCount(used), byteCount(total))) + case let (nil, total?, free?): + parts.append( + localizedFormat("%1$@ used / %2$@", byteCount(total - free), byteCount(total))) + case let (used?, nil, _): + parts.append(localizedFormat("%@ used", byteCount(used))) + case let (nil, nil, free?): + parts.append(localizedFormat("%@ free", byteCount(free))) + case let (nil, total?, nil): + parts.append(byteCount(total)) + default: + break + } + let smartStatus = record.smartStatus.trimmingCharacters(in: .whitespacesAndNewlines) + if !smartStatus.isEmpty { + parts.append(localizedFormat("SMART: %@", localized(smartStatus))) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + /// Long vendor strings and full-precision capacity pairs ("502.15 GB used / + /// 931.32 GB · SMART: verified" is 244pt in Spanish against 205pt of room) + /// overrun the row. Shrinking to 8pt keeps the whole line readable, where + /// truncation would drop the SMART status — the part worth reading — off the + /// end. Ordinary values render at the full 10pt. + static let detailMinimumScale: CGFloat = 0.8 + var body: some View { HStack(spacing: 6) { airPortResourceImage(named: iconResourceName, fallbackSystemName: iconFallbackSystemName) @@ -678,13 +734,23 @@ private struct DiskInventoryRow: View { .scaledToFit() .frame(width: 50, height: 50) .accessibilityLabel(iconAccessibilityLabel) + VStack(alignment: .leading, spacing: 3) { DiskNameTextField(record.name) .frame(height: 19) - if let free = record.sizeFree { - Text("\(ByteCountFormatter.string(fromByteCount: free, countStyle: .file)) Free") - .font(.system(size: 12)) - .foregroundStyle(Color.white.opacity(isSelected ? 0.78 : 0.42)) + if let hardware = Self.hardwareLine(for: record) { + Text(hardware) + .font(.system(size: 10)) + .lineLimit(1) + .minimumScaleFactor(Self.detailMinimumScale) + .foregroundStyle(Color.white.opacity(isSelected ? 0.7 : 0.38)) + } + if let capacity = Self.capacityLine(for: record) { + Text(capacity) + .font(.system(size: 10)) + .lineLimit(1) + .minimumScaleFactor(Self.detailMinimumScale) + .foregroundStyle(Color.white.opacity(isSelected ? 0.7 : 0.38)) } } Spacer() diff --git a/Sources/AirPortUtilityCore/FirmwarePane.swift b/Sources/AirPortUtilityCore/FirmwarePane.swift index 03e8209..7b97c40 100644 --- a/Sources/AirPortUtilityCore/FirmwarePane.swift +++ b/Sources/AirPortUtilityCore/FirmwarePane.swift @@ -11,15 +11,15 @@ struct FirmwarePane: View { var body: some View { PaneBox { - FormRow(title: "Version:") { + FormRow(title: localized("Version:")) { Text(currentVersionText) .frame(width: 279, alignment: .leading) .accessibilityIdentifier("firmware.current.version") } - FormRow(title: "Available Firmware:") { + FormRow(title: localized("Available Firmware:")) { Picker("", selection: $model.firmware.selectedImageID) { if model.firmware.images.isEmpty { - Text("No firmware images loaded").tag("") + Text(localized("No firmware images loaded")).tag("") } else { ForEach(model.firmware.images) { image in Text(image.displayName).tag(image.id) @@ -34,13 +34,13 @@ struct FirmwarePane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) DisksPaneButton( - "Check for Updates", width: 126, isEnabled: !model.isBusy, + localized("Check for Updates"), width: 126, isEnabled: !model.isBusy, identifier: "firmware.check.for.updates" ) { model.refreshFirmwareImages() } DisksPaneButton( - "Choose...", width: 72, isEnabled: !model.isBusy, + localized("Choose..."), width: 72, isEnabled: !model.isBusy, identifier: "firmware.choose.image" ) { isChoosingFirmwareImage = true @@ -77,7 +77,7 @@ struct FirmwarePane: View { } } if model.firmware.transferProgress.isVisible { - FormRow(title: "Progress:") { + FormRow(title: localized("Progress:")) { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Text(model.firmware.transferProgress.phase.label) @@ -131,7 +131,7 @@ struct FirmwarePane: View { private var currentVersionText: String { let version = model.firmware.currentVersion.trimmingCharacters(in: .whitespacesAndNewlines) - return version.isEmpty ? "Unknown" : version + return version.isEmpty ? localized("Unknown") : version } private var canInstall: Bool { @@ -139,8 +139,8 @@ struct FirmwarePane: View { } private var installButtonTitle: String { - guard let image = model.firmware.selectedImage else { return "Install" } - return image.version == model.firmware.currentVersion ? "Reinstall" : "Install" + guard let image = model.firmware.selectedImage else { return localized("Install") } + return image.version == model.firmware.currentVersion ? localized("Reinstall") : localized("Install") } @ViewBuilder diff --git a/Sources/AirPortUtilityCore/InternetOptionsSheet.swift b/Sources/AirPortUtilityCore/InternetOptionsSheet.swift index acd7ab7..624f587 100644 --- a/Sources/AirPortUtilityCore/InternetOptionsSheet.swift +++ b/Sources/AirPortUtilityCore/InternetOptionsSheet.swift @@ -9,7 +9,7 @@ struct InternetOptionsSheet: View { var body: some View { ZStack(alignment: .topLeading) { - Text("Internet Options") + Text(localized("Internet Options")) .font(.system(size: 13, weight: .semibold)) .frame(width: 150, alignment: .leading) .offset(x: 18, y: 11) @@ -18,12 +18,12 @@ struct InternetOptionsSheet: View { .offset(x: 20, y: 36) if model.showsIPv6InternetControls { - optionLabel("Configure IPv6:", width: 159) + optionLabel(localized("Configure IPv6:"), width: 159) .offset(x: 61, y: 50) - Picker("Configure IPv6", selection: $draft.configureIPv6) { - Text("Link-local only").tag("link-local") - Text("Automatically").tag("automatic") - Text("Manually").tag("manual") + Picker(localized("Configure IPv6"), selection: $draft.configureIPv6) { + Text(localized("Link-local only")).tag("link-local") + Text(localized("Automatically")).tag("automatic") + Text(localized("Manually")).tag("manual") } .labelsHidden() .pickerStyle(.menu) @@ -31,13 +31,13 @@ struct InternetOptionsSheet: View { .frame(width: 276, height: 23) .offset(x: 224, y: 45) - optionLabel("IPv6 Mode:", width: 159) + optionLabel(localized("IPv6 Mode:"), width: 159) .offset(x: 61, y: 78) - Picker("IPv6 Mode", selection: $draft.ipv6Mode) { - Text("Default").tag("") - Text("Host").tag("host") - Text("Tunnel").tag("tunnel") - Text("Router").tag("router") + Picker(localized("IPv6 Mode"), selection: $draft.ipv6Mode) { + Text(localized("Default")).tag("") + Text(localized("Host")).tag("host") + Text(localized("Tunnel")).tag("tunnel") + Text(localized("Router")).tag("router") } .labelsHidden() .pickerStyle(.menu) @@ -45,7 +45,7 @@ struct InternetOptionsSheet: View { .frame(width: 276, height: 23) .offset(x: 224, y: 73) - optionLabel("Default Route:", width: 159) + optionLabel(localized("Default Route:"), width: 159) .offset(x: 61, y: 106) AirPortTextField( text: $draft.ipv6DefaultRoute, @@ -54,7 +54,7 @@ struct InternetOptionsSheet: View { .offset(x: 224, y: 104) InternetOptionsCheckbox( - "Block incoming IPv6 connections", + localized("Block incoming IPv6 connections"), isOn: $draft.ipv6Firewall, identifier: "internet.options.ipv6.firewall") .frame(width: 279, height: 18, alignment: .leading) @@ -63,14 +63,14 @@ struct InternetOptionsSheet: View { if model.showsDynamicGlobalHostnameControls { InternetOptionsCheckbox( - "Use dynamic global hostname", + localized("Use dynamic global hostname"), isOn: $draft.dynamicGlobalHostname, identifier: "internet.options.dynamic.global.hostname") .frame(width: 279, height: 18, alignment: .leading) .offset(x: 224, y: dynamicHostnameOffset) Group { - optionLabel("Hostname:", width: 133, enabled: draft.dynamicGlobalHostname) + optionLabel(localized("Hostname:"), width: 133, enabled: draft.dynamicGlobalHostname) .offset(x: 86, y: dynamicHostnameOffset + 31) AirPortTextField( text: $draft.globalHostname, @@ -79,7 +79,7 @@ struct InternetOptionsSheet: View { .disabled(!draft.dynamicGlobalHostname) .internetOptionsDisabledField(!draft.dynamicGlobalHostname) .offset(x: 224, y: dynamicHostnameOffset + 29) - optionLabel("User:", width: 133, enabled: draft.dynamicGlobalHostname) + optionLabel(localized("User:"), width: 133, enabled: draft.dynamicGlobalHostname) .offset(x: 86, y: dynamicHostnameOffset + 58) AirPortTextField( text: $draft.globalHostnameUser, @@ -88,7 +88,7 @@ struct InternetOptionsSheet: View { .disabled(!draft.dynamicGlobalHostname) .internetOptionsDisabledField(!draft.dynamicGlobalHostname) .offset(x: 224, y: dynamicHostnameOffset + 56) - optionLabel("Password:", width: 133, enabled: draft.dynamicGlobalHostname) + optionLabel(localized("Password:"), width: 133, enabled: draft.dynamicGlobalHostname) .offset(x: 86, y: dynamicHostnameOffset + 85) AirPortSecureField( text: $draft.globalHostnamePassword, @@ -98,7 +98,7 @@ struct InternetOptionsSheet: View { .internetOptionsDisabledField(!draft.dynamicGlobalHostname) .offset(x: 224, y: dynamicHostnameOffset + 83) InternetOptionsCheckbox( - "Configure automatically", + localized("Configure automatically"), isOn: $draft.dynamicGlobalHostnameAutoConfig, identifier: "internet.options.dynamic.global.hostname.auto.config") .frame(width: 279, height: 18, alignment: .leading) @@ -108,13 +108,22 @@ struct InternetOptionsSheet: View { } } - InternetOptionsButton("Cancel", identifier: "internet.options.cancel") { dismiss() } - .offset(x: 348, y: 283) - InternetOptionsButton("Save", isDefault: true, identifier: "internet.options.save") { - model.internet = draft - dismiss() + // Right-anchored rather than fixed x offsets: a wider translated label + // ("Abbrechen") would otherwise grow rightward into the Save button. + // The trailing edge and 12pt gap reproduce the English layout exactly. + HStack(spacing: 12) { + InternetOptionsButton(localized("Cancel"), identifier: "internet.options.cancel") { + dismiss() + } + InternetOptionsButton( + localized("Save"), isDefault: true, identifier: "internet.options.save" + ) { + model.internet = draft + dismiss() + } } - .offset(x: 430, y: 283) + .frame(width: 500, alignment: .trailing) + .offset(x: 0, y: 283) } .onAppear { if !loaded { @@ -168,7 +177,13 @@ private struct InternetOptionsButton: NSViewRepresentable { button.setButtonType(.momentaryPushIn) button.alignment = .center button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: 70).isActive = true + // Exact width, but never narrower than the label needs. The constants were + // measured against English and truncate longer translations; a plain + // greaterThanOrEqual constraint instead lets the button expand to fill, + // which changes the English layout. + button.widthAnchor.constraint( + equalToConstant: max(70, button.intrinsicContentSize.width) + ).isActive = true button.heightAnchor.constraint(equalToConstant: 22).isActive = true configure(button) return button @@ -232,7 +247,13 @@ struct InternetOptionsCheckbox: NSViewRepresentable { button.isBordered = false button.allowsMixedState = false button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: 279).isActive = true + // Exact width, but never narrower than the label needs. The constants were + // measured against English and truncate longer translations; a plain + // greaterThanOrEqual constraint instead lets the button expand to fill, + // which changes the English layout. + button.widthAnchor.constraint( + equalToConstant: max(279, button.intrinsicContentSize.width) + ).isActive = true button.heightAnchor.constraint(equalToConstant: 18).isActive = true button.setAccessibilityTitle(title) button.identifier = identifier.map { NSUserInterfaceItemIdentifier($0) } diff --git a/Sources/AirPortUtilityCore/InternetPane.swift b/Sources/AirPortUtilityCore/InternetPane.swift index 12510fe..5781301 100644 --- a/Sources/AirPortUtilityCore/InternetPane.swift +++ b/Sources/AirPortUtilityCore/InternetPane.swift @@ -13,7 +13,7 @@ struct InternetPane: View { var body: some View { PaneBox { VStack(alignment: .leading, spacing: 12) { - InternetFormRow(title: "Connect Using:") { + InternetFormRow(title: localized("Connect Using:")) { Picker("", selection: $model.internet.connectUsing) { ForEach(model.internetConnectUsingOptions) { value in Text(value.label).tag(value) @@ -25,26 +25,26 @@ struct InternetPane: View { .onChange(of: model.internet.connectUsing) { model.handleInternetConnectUsingChanged($0) } } if model.internet.connectUsing == .pppoe { - InternetFormRow(title: "Account Name:") { + InternetFormRow(title: localized("Account Name:")) { AirPortTextField( text: $model.internet.pppoeAccount, identifier: "internet.pppoe.account") } .internetEditableRow() - InternetFormRow(title: "Password:") { + InternetFormRow(title: localized("Password:")) { AirPortSecureField( text: $model.internet.pppoePassword, identifier: "internet.pppoe.password") .frame(height: 24) } .internetEditableRow() - InternetFormRow(title: "Service Name:") { + InternetFormRow(title: localized("Service Name:")) { AirPortTextField( text: $model.internet.pppoeService, identifier: "internet.pppoe.service") } .internetEditableRow() - InternetFormRow(title: "Connection:") { + InternetFormRow(title: localized("Connection:")) { Picker("", selection: $model.internet.pppoeConnection) { ForEach(PPPoEConnectionOption.allCases) { option in Text(option.label).tag(option.value) @@ -58,33 +58,33 @@ struct InternetPane: View { .padding(.bottom, 10) } if model.internet.connectUsing == .modem && model.showsModemControls { - InternetFormRow(title: "Phone Number:") { + InternetFormRow(title: localized("Phone Number:")) { AirPortTextField( text: $model.internet.modemPhoneNumber, identifier: "internet.modem.phone.number") } .internetEditableRow() - InternetFormRow(title: "Alternate Number:") { + InternetFormRow(title: localized("Alternate Number:")) { AirPortTextField( text: $model.internet.modemAlternateNumber, identifier: "internet.modem.alternate.number") } .internetEditableRow() if model.showsExtendedModemControls { - InternetFormRow(title: "Account Name:") { + InternetFormRow(title: localized("Account Name:")) { AirPortTextField( text: $model.internet.modemAccount, identifier: "internet.modem.account") } .internetEditableRow() - InternetFormRow(title: "Password:") { + InternetFormRow(title: localized("Password:")) { AirPortSecureField( text: $model.internet.modemPassword, identifier: "internet.modem.password") .frame(height: 24) } .internetEditableRow() - InternetFormRow(title: "Verify Password:") { + InternetFormRow(title: localized("Verify Password:")) { AirPortSecureField( text: $model.internet.modemVerifyPassword, identifier: "internet.modem.verify.password") @@ -93,7 +93,7 @@ struct InternetPane: View { .internetEditableRow() } InternetOptionsCheckbox( - "Use AOL", + localized("Use AOL"), isOn: $model.internet.modemUseAOL, identifier: "internet.modem.use.aol") .frame(width: AirPortLayout.formControlWidth, alignment: .leading) @@ -102,7 +102,7 @@ struct InternetPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) InternetPaneButton( - "Modem Options...", width: 147, + localized("Modem Options..."), width: 147, identifier: "internet.modem.options.open" ) { showModemOptions = true } .frame(width: 147, height: 22) @@ -111,7 +111,7 @@ struct InternetPane: View { } } if model.internet.connectUsing != .modem { - InternetFormRow(title: "IPv4 Address:") { + InternetFormRow(title: localized("IPv4 Address:")) { if model.internet.connectUsing == .static { AirPortTextField( text: $model.internet.ipv4Address, @@ -122,18 +122,18 @@ struct InternetPane: View { .frame(maxWidth: .infinity, alignment: .leading) if model.internet.connectUsing == .dhcp { InternetPaneButton( - "Renew DHCP Lease", width: 148, + localized("Renew DHCP Lease"), width: 148, identifier: "internet.renew.dhcp.lease" ) { model.renewDHCPLease() } - .frame(width: 148, height: 22) + .frame(minWidth: 148).frame(height: 22) } } } } .internetEditableRow(enabled: model.internet.connectUsing == .static) - InternetFormRow(title: "Subnet Mask:") { + InternetFormRow(title: localized("Subnet Mask:")) { if model.internet.connectUsing == .static { AirPortTextField( text: $model.internet.subnetMask, @@ -144,7 +144,7 @@ struct InternetPane: View { } } .internetEditableRow(enabled: model.internet.connectUsing == .static) - InternetFormRow(title: "Router Address:") { + InternetFormRow(title: localized("Router Address:")) { if model.internet.connectUsing == .static { AirPortTextField( text: $model.internet.routerAddress, @@ -155,7 +155,7 @@ struct InternetPane: View { } } .internetEditableRow(enabled: model.internet.connectUsing == .static) - InternetFormRow(title: "DNS Servers:") { + InternetFormRow(title: localized("DNS Servers:")) { DNSServerFields( text: $model.internet.dnsServers, placeholderText: model.internet.connectUsing == .dhcp @@ -165,7 +165,7 @@ struct InternetPane: View { ) } if model.showsIPv6InternetControls { - InternetFormRow(title: "IPv6 DNS Servers:") { + InternetFormRow(title: localized("IPv6 DNS Servers:")) { DNSServerFields( text: $model.internet.ipv6DNSServers, placeholderText: model.internet.connectUsing == .dhcp @@ -174,14 +174,14 @@ struct InternetPane: View { ) } } - InternetFormRow(title: "Domain Name:") { + InternetFormRow(title: localized("Domain Name:")) { AirPortTextField( text: $model.internet.domainName, identifier: "internet.domain.name") } .internetEditableRow() if model.showsIPv6InternetControls { - InternetFormRow(title: "IPv6 Address:") { + InternetFormRow(title: localized("IPv6 Address:")) { Text(model.internet.ipv6Address) .frame(maxWidth: .infinity, alignment: .leading) } @@ -190,7 +190,7 @@ struct InternetPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) InternetPaneButton( - "Internet Options...", width: 147, + localized("Internet Options..."), width: 147, identifier: "internet.options.open" ) { showOptions = true } .frame(width: 147, height: 22) @@ -236,7 +236,13 @@ private struct InternetPaneButton: NSViewRepresentable { button.setButtonType(.momentaryPushIn) button.alignment = .center button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: width).isActive = true + // Exact width, but never narrower than the label needs. The constants were + // measured against English and truncate longer translations; a plain + // greaterThanOrEqual constraint instead lets the button expand to fill, + // which changes the English layout. + button.widthAnchor.constraint( + equalToConstant: max(width, button.intrinsicContentSize.width) + ).isActive = true button.heightAnchor.constraint(equalToConstant: 22).isActive = true button.setAccessibilityTitle(title) button.identifier = identifier.map { NSUserInterfaceItemIdentifier($0) } diff --git a/Sources/AirPortUtilityCore/Localization.swift b/Sources/AirPortUtilityCore/Localization.swift new file mode 100644 index 0000000..3ab6f83 --- /dev/null +++ b/Sources/AirPortUtilityCore/Localization.swift @@ -0,0 +1,101 @@ +import Foundation + +/// UI string localization for the app. +/// +/// Keys are the English source text itself, so English needs no table and any +/// untranslated string falls back to readable English instead of a symbolic key. +/// +/// IMPORTANT: never route protocol text through here. ACP setting keys (`syNm`), +/// backend flags (`--router-mode`), JSON keys, and enum raw values that are +/// persisted or sent to the backend are wire format, not display text. +/// Localizing them corrupts the protocol. When an enum raw value doubles as a +/// label, add a separate `displayName` rather than translating the raw value. +public enum AirPortLocalization { + /// The `.lproj` bundle matching the user's preferred languages. + /// + /// Resolved from `Locale.preferredLanguages` rather than the one-argument + /// `Bundle.preferredLocalizations(from:)`, because that variant matches + /// against the *main* bundle's localizations. Those are empty for a + /// command-line build or a test harness, which silently pins every lookup to + /// English even when the user's language is French. + static let bundle: Bundle = { + let available = Bundle.module.localizations + let preferred = Bundle.preferredLocalizations( + from: available, forPreferences: preferredLanguages) + for language in preferred { + if let url = Bundle.module.url(forResource: language, withExtension: "lproj"), + let bundle = Bundle(url: url) + { + return bundle + } + } + return .module + }() + + /// The language preference used to pick a table. + /// + /// Under XCTest this is pinned to the development language. Many tests assert + /// on English UI strings, and without pinning, the same test passes on an + /// English machine and fails on a French one -- so a developer's system + /// language would decide whether the suite is green. + private static var preferredLanguages: [String] { + if NSClassFromString("XCTest") != nil { + return ["en"] + } + return Locale.preferredLanguages + } + + /// The locale matching the resolved table. + /// + /// Foundation-provided text (region names, for example) must agree with the + /// rest of the UI, and must follow the same XCTest pinning -- otherwise a + /// test sees English labels beside French country names. + static let locale: Locale = { + let available = Bundle.module.localizations + let preferred = Bundle.preferredLocalizations( + from: available, forPreferences: preferredLanguages) + return Locale(identifier: preferred.first ?? "en") + }() + + /// Looks up `key`, falling back to the key itself when untranslated. + /// + /// `context` disambiguates one English word that needs different translations + /// in different places. "Edit" is the Edit *menu* in the menu bar ("Édition") + /// but a verb on a button ("Modifier"); with the English source as the key + /// there is otherwise no way to tell them apart. A contextual entry is stored + /// as "context.key" and falls back to the plain key when absent. + public static func text(_ key: String, context: String? = nil) -> String { + if let context { + let qualified = "\(context).\(key)" + let sentinel = "\u{0}" + let value = bundle.localizedString(forKey: qualified, value: sentinel, table: nil) + if value != sentinel { + return value + } + } + return bundle.localizedString(forKey: key, value: key, table: nil) + } + + /// The resource bundle holding the `.lproj` tables. + static var resourceBundle: Bundle { .module } + + /// Language codes with a translation table, for diagnostics and tests. + static var availableLanguages: [String] { + Bundle.module.localizations.sorted() + } +} + +/// Looks up a format string and fills it in. +/// +/// Interpolated text ("Set up this \(model) to ...") cannot be a key, because +/// the key would change with the value. The table stores a format instead +/// ("Set up this %@ to ...") so a translation can move the placeholder to +/// wherever its grammar needs it. +func localizedFormat(_ key: String, _ arguments: CVarArg...) -> String { + String(format: AirPortLocalization.text(key), arguments: arguments) +} + +/// Shorthand for ``AirPortLocalization/text(_:)`` inside this module. +func localized(_ key: String, context: String? = nil) -> String { + AirPortLocalization.text(key, context: context) +} diff --git a/Sources/AirPortUtilityCore/ModemOptionsSheet.swift b/Sources/AirPortUtilityCore/ModemOptionsSheet.swift index fffdf71..44c3166 100644 --- a/Sources/AirPortUtilityCore/ModemOptionsSheet.swift +++ b/Sources/AirPortUtilityCore/ModemOptionsSheet.swift @@ -8,12 +8,12 @@ struct ModemOptionsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - Text("Modem Options") + Text(localized("Modem Options")) .font(.system(size: 13, weight: .semibold)) Divider() if model.showsModemControls { - modemRow("Disconnect if Idle:") { + modemRow(localized("Disconnect if Idle:")) { Picker("", selection: $draft.modemIdleSeconds) { ForEach(ModemIdleOption.allCases) { option in Text(option.label).tag(option.seconds) @@ -23,7 +23,7 @@ struct ModemOptionsSheet: View { .pickerStyle(.menu) .accessibilityIdentifier("internet.modem.options.idle") } - modemRow("Country Code:") { + modemRow(localized("Country Code:")) { Picker("", selection: $draft.modemCountryCode) { ForEach(ModemCountryOption.allCases) { option in Text(option.name).tag(option.code) @@ -33,7 +33,7 @@ struct ModemOptionsSheet: View { .pickerStyle(.menu) .accessibilityIdentifier("internet.modem.options.country") } - modemRow("Protocol:") { + modemRow(localized("Protocol:")) { Picker("", selection: $draft.modemProtocol) { Text("v.34").tag("v34") Text("v.90").tag("v90") @@ -42,10 +42,10 @@ struct ModemOptionsSheet: View { .pickerStyle(.menu) .accessibilityIdentifier("internet.modem.options.protocol") } - modemRow("Dialing:") { + modemRow(localized("Dialing:")) { Picker("", selection: $draft.modemPulseDialing) { - Text("Tone").tag(false) - Text("Pulse").tag(true) + Text(localized("Tone")).tag(false) + Text(localized("Pulse")).tag(true) } .labelsHidden() .pickerStyle(.menu) @@ -54,25 +54,25 @@ struct ModemOptionsSheet: View { VStack(alignment: .leading, spacing: 10) { InternetOptionsCheckbox( - "Automatically Dial", + localized("Automatically Dial"), isOn: $draft.modemAutomaticallyDial, identifier: "internet.modem.options.automatically.dial") InternetOptionsCheckbox( - "Ignore Dial Tone", + localized("Ignore Dial Tone"), isOn: $draft.modemIgnoreDialTone, identifier: "internet.modem.options.ignore.dial.tone") } .padding(.leading, 164) } else { - Text("This base station does not support modem options.") + Text(localized("This base station does not support modem options.")) } Spacer() HStack { Spacer() - Button("Cancel") { dismiss() } + Button(localized("Cancel")) { dismiss() } .accessibilityIdentifier("internet.modem.options.cancel") - Button("Save") { + Button(localized("Save")) { model.internet = draft dismiss() } diff --git a/Sources/AirPortUtilityCore/NetworkOptionsSheet.swift b/Sources/AirPortUtilityCore/NetworkOptionsSheet.swift index eaabf61..6b4ac2c 100644 --- a/Sources/AirPortUtilityCore/NetworkOptionsSheet.swift +++ b/Sources/AirPortUtilityCore/NetworkOptionsSheet.swift @@ -15,7 +15,7 @@ struct NetworkOptionsSheet: View { var body: some View { ZStack(alignment: .topLeading) { - Text("Network Options") + Text(localized("Network Options")) .font(.system(size: 13, weight: .semibold)) .frame(width: 150, height: 20, alignment: .leading) .offset(x: 18, y: 10) @@ -23,7 +23,7 @@ struct NetworkOptionsSheet: View { .frame(width: 471) .offset(x: 20, y: 42) - optionLabel("DHCP Lease:", width: 190) + optionLabel(localized("DHCP Lease:"), width: 190) .offset(x: 19, y: 50) NetworkOptionsTextField( text: $draft.dhcpLease, @@ -42,7 +42,7 @@ struct NetworkOptionsSheet: View { .frame(width: 143, height: 23) .offset(x: 347, y: 49) - optionLabel("IPv4 DHCP Range:", width: 190) + optionLabel(localized("IPv4 DHCP Range:"), width: 190) .offset(x: 19, y: 83) Picker("", selection: $dhcpRangePrefix) { Text("10.0").tag("10.0") @@ -83,7 +83,7 @@ struct NetworkOptionsSheet: View { .offset(x: 448, y: 83) if model.capabilities.supportsLegacyDHCPOptions { - optionLabel("DHCP Message:", width: 190) + optionLabel(localized("DHCP Message:"), width: 190) .offset(x: 19, y: 116) NetworkOptionsTextField( text: $legacyDraft.message, @@ -91,7 +91,7 @@ struct NetworkOptionsSheet: View { .frame(width: 279) .offset(x: 211, y: 115) - optionLabel("LDAP Server:", width: 190) + optionLabel(localized("LDAP Server:"), width: 190) .offset(x: 19, y: 147) NetworkOptionsTextField( text: $legacyDraft.ldapServer, @@ -101,7 +101,7 @@ struct NetworkOptionsSheet: View { } NetworkOptionsCheckbox( - "Enable NAT Port Mapping Protocol", + localized("Enable NAT Port Mapping Protocol"), isOn: $draft.natPMP, identifier: "network.options.nat.pmp" ) @@ -109,7 +109,7 @@ struct NetworkOptionsSheet: View { .offset(x: 212, y: 149 + legacyVerticalOffset) NetworkOptionsCheckbox( - "Enable default host at:", + localized("Enable default host at:"), isOn: $defaultHostEnabled, identifier: "network.options.default.host.enabled") .frame(width: 164, height: 18, alignment: .leading) @@ -122,11 +122,16 @@ struct NetworkOptionsSheet: View { .opacity(defaultHostEnabled ? 1 : 0.58) .offset(x: 211, y: 172 + legacyVerticalOffset) - NetworkOptionsButton("Cancel", identifier: "network.options.cancel") { dismiss() } + // Right-anchored rather than fixed x offsets: a wider translated label + // ("Abbrechen") would otherwise grow rightward into the Save button. + // The trailing edge and 12pt gap reproduce the English layout exactly. + HStack(spacing: 12) { + NetworkOptionsButton(localized("Cancel"), identifier: "network.options.cancel") { + dismiss() + } .frame(width: 70, height: 22) - .offset(x: 339, y: 220 + legacyVerticalOffset) - NetworkOptionsButton( - "Save", isDefault: true, isEnabled: canSave, + NetworkOptionsButton( + localized("Save"), isDefault: true, isEnabled: canSave, identifier: "network.options.save" ) { guard applyDHCPRange() else { return } @@ -139,8 +144,10 @@ struct NetworkOptionsSheet: View { } dismiss() } - .frame(width: 70, height: 22) - .offset(x: 421, y: 220 + legacyVerticalOffset) + .frame(width: 70, height: 22) + } + .frame(width: 491, alignment: .trailing) + .offset(x: 0, y: 220 + legacyVerticalOffset) } .onAppear { if !loaded { diff --git a/Sources/AirPortUtilityCore/NetworkPane.swift b/Sources/AirPortUtilityCore/NetworkPane.swift index 99a4dcf..0d76c4a 100644 --- a/Sources/AirPortUtilityCore/NetworkPane.swift +++ b/Sources/AirPortUtilityCore/NetworkPane.swift @@ -7,7 +7,7 @@ struct NetworkPane: View { var body: some View { PaneBox { - FormRow(title: "Router Mode:") { + FormRow(title: localized("Router Mode:")) { Picker("", selection: $model.network.routerMode) { ForEach(RouterMode.allCases) { mode in Text(mode.label).tag(mode) @@ -20,25 +20,25 @@ struct NetworkPane: View { if model.network.routerMode == .bridge { Spacer().frame(height: 42) } else { - FormRow(title: "LAN IP Address:") { + FormRow(title: localized("LAN IP Address:")) { AirPortTextField( text: $model.network.lanIPAddress, - placeholder: "LAN IP address", + placeholder: localized("LAN IP address"), identifier: "network.lan.ip.address") } - FormRow(title: "DHCP Range:") { + FormRow(title: localized("DHCP Range:")) { DHCPRangeSummary(network: $model.network) } } NetworkTableSection( - title: "DHCP Reservations:", - columns: ("Description", "IP Address"), + title: localized("DHCP Reservations:"), + columns: (localized("Description"), localized("IP Address")), tableIdentifier: "dhcpTable", disabled: model.network.routerMode == .bridge ) NetworkTableSection( - title: "Port Settings:", - columns: ("Description", "Type"), + title: localized("Port Settings:"), + columns: (localized("Description"), localized("Type")), tableIdentifier: "natTable", disabled: model.network.routerMode != .dhcpAndNat ) @@ -46,7 +46,7 @@ struct NetworkPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) NetworkPaneButton( - "Network Options...", identifier: "network.options.open" + localized("Network Options..."), identifier: "network.options.open" ) { showOptions = true } .frame(width: 147, height: 22) .disabled(model.network.routerMode == .bridge) @@ -250,7 +250,7 @@ private struct NetworkTableEditButton: NSViewRepresentable { var tableIdentifier: String func makeNSView(context: Context) -> NSButton { - let button = NetworkTableEditNSButton(title: "Edit", target: nil, action: nil) + let button = NetworkTableEditNSButton(title: localized("Edit"), target: nil, action: nil) button.bezelStyle = .rounded button.controlSize = .regular button.font = .systemFont(ofSize: 13) @@ -264,7 +264,7 @@ private struct NetworkTableEditButton: NSViewRepresentable { } private func configure(_ button: NSButton) { - button.title = "Edit" + button.title = localized("Edit") button.target = nil button.action = nil button.isEnabled = false @@ -347,8 +347,8 @@ private struct EmptyNetworkTable: NSViewRepresentable { private func configure(_ tableView: NSTableView) { tableView.tableColumns.forEach { tableView.removeTableColumn($0) } - let firstWidth: CGFloat = columns.1 == "Type" ? 228 : 156.5 - let secondWidth: CGFloat = columns.1 == "Type" ? 47 : 118.5 + let firstWidth: CGFloat = columns.1 == localized("Type") ? 228 : 156.5 + let secondWidth: CGFloat = columns.1 == localized("Type") ? 47 : 118.5 for (index, columnTitle) in [columns.0, columns.1].enumerated() { let tableColumn = NSTableColumn( identifier: NSUserInterfaceItemIdentifier("AutomaticTableColumnIdentifier.\(index)") diff --git a/Sources/AirPortUtilityCore/PaneChrome.swift b/Sources/AirPortUtilityCore/PaneChrome.swift index 6d96494..03040dd 100644 --- a/Sources/AirPortUtilityCore/PaneChrome.swift +++ b/Sources/AirPortUtilityCore/PaneChrome.swift @@ -29,25 +29,34 @@ enum AirPortLayout { panes.reduce(CGFloat(0)) { $0 + topTabWidth(for: $1) } } + /// Smallest gap between a tab's label and its edges. + /// + /// 19pt is the tightest padding the measured English widths below imply + /// ("Advanced", 60pt of text in a 79pt tab), so sizing to text + this value + /// can never make an English tab wider than it already is. + static let topTabHorizontalPadding: CGFloat = 19 + + /// Width of one tab. + /// + /// The constants are the widths measured against the English labels, kept as + /// a floor so the English tab bar is unchanged. A translated label that needs + /// more room than its English counterpart gets it, instead of being cramped + /// or clipped. static func topTabWidth(for pane: Pane) -> CGFloat { - switch pane { - case .baseStation: - 99 - case .internet: - 70 - case .wireless: - 74 - case .network: - 73 - case .airPlay: - 67 - case .disks: - 55 - case .advanced: - 79 - case .firmware: - 82 - } + let englishWidth: CGFloat = + switch pane { + case .baseStation: 99 + case .internet: 70 + case .wireless: 74 + case .network: 73 + case .airPlay: 67 + case .disks: 55 + case .advanced: 79 + case .firmware: 82 + } + let label = pane.displayName.size( + withAttributes: [.font: NSFont.systemFont(ofSize: 13)]) + return max(englishWidth, ceil(label.width) + topTabHorizontalPadding) } } @@ -80,11 +89,11 @@ struct ConfigurationSheet: View { } else { Spacer() } - SheetFooterButton("Cancel", width: 70, identifier: "sheet.cancel") { + SheetFooterButton(localized("Cancel"), width: 70, identifier: "sheet.cancel") { model.cancelEditing() } SheetFooterButton( - "Update", + localized("Update"), width: 73, isDefault: true, isEnabled: model.canApplyPendingChanges, @@ -116,12 +125,25 @@ struct ConfigurationSheet: View { AirPortLayout.configurationSheetWidth(for: model.visiblePanes) } + /// Whether `status` is one of the connection-state messages the footer hides. + /// + /// The prefix is taken from the same format string the status was built from, + /// so this keeps working in every language. Hardcoding the English prefix + /// silently stopped matching as soon as those messages were localized. + private func isConnectionStatus(_ status: String) -> Bool { + for key in ["Connected to %@", "Ready to connect to %@"] { + let prefix = localized(key).components(separatedBy: "%@")[0] + if !prefix.isEmpty, status.hasPrefix(prefix) { + return true + } + } + return false + } + private var footerStatus: String? { let status = model.status.trimmingCharacters(in: .whitespacesAndNewlines) guard !status.isEmpty else { return nil } - guard !status.hasPrefix("Connected"), !status.hasPrefix("Ready to connect"), - status != "Not connected" - else { + guard !isConnectionStatus(status), status != localized("Not connected") else { return nil } return status @@ -179,7 +201,13 @@ private struct SheetFooterButton: NSViewRepresentable { button.setButtonType(.momentaryPushIn) button.alignment = .center button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: width).isActive = true + // Exact width, but never narrower than the label needs. The constants were + // measured against English and truncate longer translations; a plain + // greaterThanOrEqual constraint instead lets the button expand to fill, + // which changes the English layout. + button.widthAnchor.constraint( + equalToConstant: max(width, button.intrinsicContentSize.width) + ).isActive = true button.heightAnchor.constraint(equalToConstant: 22).isActive = true configure(button) return button @@ -323,7 +351,7 @@ private final class TopTabsNSView: NSView { init(action: TopTabsControl.Coordinator) { self.control = NSSegmentedControl( - labels: Pane.allCases.map(\.rawValue), + labels: Pane.allCases.map(\.displayName), trackingMode: .selectOne, target: action, action: #selector(TopTabsControl.Coordinator.selectPane(_:))) @@ -383,7 +411,7 @@ private final class TopTabsNSView: NSView { control.segmentCount = panes.count tabElements = [] for (index, pane) in panes.enumerated() { - control.setLabel(pane.rawValue, forSegment: index) + control.setLabel(pane.displayName, forSegment: index) control.setWidth(Self.width(for: pane), forSegment: index) let element = TopTabAccessibilityElement(owner: self, pane: pane) element.setAccessibilityParent(self) @@ -424,7 +452,7 @@ private final class TopTabAccessibilityElement: NSAccessibilityElement { } override func accessibilityTitle() -> String? { - pane.rawValue + pane.displayName } override func accessibilityIdentifier() -> String? { @@ -518,7 +546,7 @@ struct CommandPreviewView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - Text(model.preview?.title ?? "Command Log") + Text(model.preview?.title ?? localized("Command Log")) .font(.subheadline.weight(.semibold)) Spacer() if model.isBusy { @@ -530,7 +558,7 @@ struct CommandPreviewView: View { Text(AirportCommand.display(AirportCommand.writeScript, preview.redactedArguments)) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) - Text(preview.output.isEmpty ? "Dry-run completed without output." : preview.output) + Text(preview.output.isEmpty ? localized("Dry-run completed without output.") : preview.output) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(4) @@ -570,7 +598,10 @@ struct AirPortButtonStyle: ButtonStyle { .foregroundStyle( emphasized && isEnabled ? Color.white : Color.primary.opacity(isEnabled ? 1 : 0.45) ) - .frame(width: width, height: 22) + // minWidth, not width: the fixed widths throughout these panes were + // measured against English labels. A minimum keeps English pixel-identical + // while letting longer translations grow instead of truncating. + .frame(minWidth: width).frame(height: 22) .background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 5)) .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.black.opacity(0.20), lineWidth: 1)) diff --git a/Sources/AirPortUtilityCore/Resources/de.lproj/Localizable.strings b/Sources/AirPortUtilityCore/Resources/de.lproj/Localizable.strings new file mode 100644 index 0000000..4396a0d --- /dev/null +++ b/Sources/AirPortUtilityCore/Resources/de.lproj/Localizable.strings @@ -0,0 +1,556 @@ +/* AirPort Utility - de + Keys are the English source strings. Untranslated keys fall back to + English automatically, so this table may be incomplete. + Do NOT add ACP keys, backend flags or persisted raw values here. */ + +"%1$@ used / %2$@" = "%1$@ belegt / %2$@"; +"%@ free" = "%@ frei"; +"%@ used" = "%@ belegt"; +"0 - Emergency" = "0 – Notfall"; +"1 - Alert" = "1 – Alarm"; +"1 hour" = "1 Stunde"; +"15 minutes" = "15 Minuten"; +"1st generation" = "1. Generation"; +"2 - Critical" = "2 – Kritisch"; +"2 hours" = "2 Stunden"; +"24 hours" = "24 Stunden"; +"2nd generation" = "2. Generation"; +"3 - Error" = "3 – Fehler"; +"30 minutes" = "30 Minuten"; +"35-Pass Erase" = "35-fach überschreiben"; +"3rd generation" = "3. Generation"; +"4 - Warning" = "4 – Warnung"; +"4 hours" = "4 Stunden"; +"4th generation" = "4. Generation"; +"5 - Notice" = "5 – Hinweis"; +"5th generation" = "5. Generation"; +"6 - Informational" = "6 – Information"; +"6th generation" = "6. Generation"; +"7 - Debug" = "7 – Debug"; +"7-Pass Erase" = "7-fach überschreiben"; +"8 hours" = "8 Stunden"; +"About AirPort Utility" = "Über AirPort-Dienstprogramm"; +"Access Control" = "Zugriffssteuerung"; +"Access Control mode is not supported." = "Der Zugriffssteuerungsmodus wird nicht unterstützt."; +"Access Control:" = "Zugriffssteuerung:"; +"Access-control descriptions may contain at most 34 UTF-8 bytes." = "Beschreibungen der Zugriffssteuerung dürfen höchstens 34 UTF-8-Bytes umfassen."; +"Account Name" = "Accountname"; +"Account Name:" = "Accountname:"; +"Account Password cannot be empty." = "Das Account-Passwort darf nicht leer sein."; +"Account name" = "Accountname"; +"Account password" = "Account-Passwort"; +"Account passwords do not match." = "Die Account-Passwörter stimmen nicht überein."; +"Accounts:" = "Accounts:"; +"Add Client" = "Client hinzufügen"; +"Add WPS Printer…" = "WPS-Drucker hinzufügen…"; +"Add to an existing network" = "Zu einem bestehenden Netzwerk hinzufügen"; +"Admin Password" = "Administratorpasswort"; +"Admin passwords do not match." = "Die Administratorpasswörter stimmen nicht überein."; +"Advanced" = "Erweitert"; +"Advanced ACP JSON is not valid JSON." = "Das erweiterte ACP-JSON ist kein gültiges JSON."; +"Advanced ACP JSON must be an object keyed by setting name." = "Das erweiterte ACP-JSON muss ein nach Einstellungsnamen indiziertes Objekt sein."; +"Advanced ACP JSON must be valid UTF-8." = "Das erweiterte ACP-JSON muss gültiges UTF-8 sein."; +"Advanced ACP setting names must be four characters." = "Erweiterte ACP-Einstellungsnamen müssen vier Zeichen lang sein."; +"AirPlay" = "AirPlay"; +"AirPlay Speaker Name cannot be empty." = "Der Name des AirPlay-Lautsprechers darf nicht leer sein."; +"AirPlay Speaker Name:" = "Name des AirPlay-Lautsprechers:"; +"AirPlay Speaker Password:" = "Passwort des AirPlay-Lautsprechers:"; +"AirPlay passwords do not match." = "Die AirPlay-Passwörter stimmen nicht überein."; +"AirPort Base Station" = "AirPort-Basisstation"; +"AirPort Configuration" = "AirPort-Konfiguration"; +"AirPort Express" = "AirPort Express"; +"AirPort Extreme" = "AirPort Extreme"; +"AirPort ID" = "AirPort-ID"; +"AirPort ID:" = "AirPort-ID:"; +"AirPort Time Capsule Disk" = "AirPort Time Capsule-Volume"; +"AirPort Utility" = "AirPort-Dienstprogramm"; +"AirPort Utility Help" = "AirPort-Dienstprogramm-Hilfe"; +"All users will be disconnected from this disk." = "Alle Benutzer werden von diesem Volume getrennt."; +"All wireless clients are allowed to join this network." = "Alle WLAN-Clients dürfen diesem Netzwerk beitreten."; +"Allow SNMP" = "SNMP erlauben"; +"Allow SNMP over WAN" = "SNMP über WAN erlauben"; +"Allow only the wireless clients listed below." = "Nur die unten aufgeführten WLAN-Clients erlauben."; +"Allow setup over Ethernet WAN port" = "Konfiguration über WAN zulassen"; +"Allow this network to be extended" = "Erweitern dieses Netzwerks erlauben"; +"Alternate" = "Alternativ"; +"Alternate Number:" = "Alternative Nummer:"; +"Always On" = "Immer aktiv"; +"Another router appears to be providing NAT upstream of this base station." = "Ein anderer Router scheint NAT oberhalb dieser Basisstation bereitzustellen."; +"Answer on ring must be between 1 and 255." = "Antworten nach Klingeln muss zwischen 1 und 255 liegen."; +"Answer on ring:" = "Antworten nach Klingeln:"; +"Applying Archive Disk" = "Volume-Archivierung wird angewendet"; +"Archive" = "Archivieren"; +"Archive Disk" = "Volume archivieren"; +"Archive Disk complete." = "Volume-Archivierung abgeschlossen."; +"Archive Disk in progress." = "Volume-Archivierung läuft."; +"Archive Disk started. Waiting for archive to complete." = "Volume-Archivierung gestartet. Warten auf Abschluss."; +"Archive Disk status check timed out." = "Zeitüberschreitung bei der Statusprüfung der Volume-Archivierung."; +"Archive Disk…" = "Volume archivieren…"; +"Archive the AirPort Time Capsule disk to back up your data." = "Archivieren Sie das AirPort Time Capsule-Volume, um Ihre Daten zu sichern."; +"Archiving disk" = "Volume wird archiviert"; +"Are you sure you want to archive the AirPort Time Capsule disk to a disk connected using USB?" = "Möchten Sie das AirPort Time Capsule-Volume wirklich auf ein per USB angeschlossenes Volume archivieren?"; +"Are you sure you want to erase the AirPort Time Capsule disk?" = "Möchten Sie das AirPort Time Capsule-Volume wirklich löschen?"; +"Automatic" = "Automatisch"; +"Automatically" = "Automatisch"; +"Automatically Dial" = "Automatisch wählen"; +"Available Firmware:" = "Verfügbare Firmware:"; +"Back" = "Zurück"; +"Base Station" = "Basisstation"; +"Base Station Name" = "Name der Basisstation"; +"Base Station Name cannot be empty." = "Der Name der Basisstation darf nicht leer sein."; +"Base Station Name:" = "Name der Basisstation:"; +"Base Station Options" = "Optionen der Basisstation"; +"Base Station Password" = "Passwort der Basisstation"; +"Base Station Password:" = "Passwort der Basisstation:"; +"Base Station to Replace:" = "Zu ersetzende Basisstation:"; +"Block incoming IPv6 connections" = "Eingehende IPv6-Verbindungen blockieren"; +"Bring All to Front" = "Alle nach vorne bringen"; +"Cancel" = "Abbrechen"; +"Check for Updates" = "Nach Updates suchen"; +"Choose a base station" = "Basisstation auswählen"; +"Choose..." = "Auswählen…"; +"Chosen Firmware" = "Gewählte Firmware"; +"Close" = "Schließen"; +"Command Log" = "Befehlsprotokoll"; +"Configuration problem" = "Konfigurationsproblem"; +"Configure IPv6" = "IPv6 konfigurieren"; +"Configure IPv6 must be link-local, automatic, or manual." = "IPv6 konfigurieren muss Link-local, automatisch oder manuell sein."; +"Configure IPv6:" = "IPv6 konfigurieren:"; +"Configure Other" = "Andere konfigurieren"; +"Configure Other..." = "Andere konfigurieren…"; +"Configure automatically" = "Automatisch konfigurieren"; +"Connect" = "Verbinden"; +"Connect Using:" = "Verbinden über:"; +"Connect to Base Station" = "Mit Basisstation verbinden"; +"Connect to Base Station..." = "Mit Basisstation verbinden…"; +"Connected" = "Verbunden"; +"Connected to %@" = "Verbunden mit %@"; +"Connected to %@. Mock mode." = "Verbunden mit %@. Mock-Modus."; +"Connecting to Base Station" = "Verbindung zur Basisstation wird hergestellt"; +"Connection:" = "Verbindung:"; +"Contact:" = "Kontakt:"; +"Continue" = "Fortfahren"; +"Copy" = "Kopieren"; +"Copy compatible settings from another AirPort base station." = "Kompatible Einstellungen von einer anderen AirPort-Basisstation kopieren."; +"Could not combine legacy settings into one update." = "Die älteren Einstellungen konnten nicht zu einer Aktualisierung zusammengefasst werden."; +"Could not encode disk account settings." = "Die Volume-Account-Einstellungen konnten nicht codiert werden."; +"Could not encode local access-control settings." = "Die lokalen Zugriffssteuerungseinstellungen konnten nicht codiert werden."; +"Could not encode the combined legacy settings update." = "Die zusammengefasste Aktualisierung der älteren Einstellungen konnte nicht codiert werden."; +"Could not find the Time Capsule. Check that it is on this network, or enter its IP address instead of the .local name." = "Time Capsule nicht gefunden. Prüfen Sie, ob sie sich in diesem Netzwerk befindet, oder geben Sie ihre IP-Adresse statt des .local-Namens ein."; +"Could not read the base station setup profile." = "Das Konfigurationsprofil der Basisstation konnte nicht gelesen werden."; +"Country Code:" = "Ländercode:"; +"Create a new network" = "Neues Netzwerk erstellen"; +"Create a separate Wi-Fi network using this base station." = "Mit dieser Basisstation ein eigenes Wi-Fi-Netzwerk erstellen."; +"Create a wireless network" = "WLAN-Netzwerk erstellen"; +"Create hidden network" = "Verborgenes Netzwerk erstellen"; +"Cut" = "Ausschneiden"; +"DHCP Lease cannot be empty." = "Der DHCP-Lease darf nicht leer sein."; +"DHCP Lease duration must be between 1 second and 10 years." = "Die DHCP-Lease-Dauer muss zwischen 1 Sekunde und 10 Jahren liegen."; +"DHCP Lease must be a positive number." = "Der DHCP-Lease muss eine positive Zahl sein."; +"DHCP Lease unit is not supported." = "Die DHCP-Lease-Einheit wird nicht unterstützt."; +"DHCP Lease:" = "DHCP-Lease:"; +"DHCP Message:" = "DHCP-Nachricht:"; +"DHCP Only" = "Nur DHCP"; +"DHCP Range Beginning and Ending must use the same supported private subnet, with Ending not before Beginning." = "Anfang und Ende des DHCP-Bereichs müssen dasselbe unterstützte private Teilnetz verwenden, wobei das Ende nicht vor dem Anfang liegen darf."; +"DHCP Range Beginning cannot be empty." = "Der DHCP-Bereichsanfang darf nicht leer sein."; +"DHCP Range Beginning must be an IPv4 address." = "Der DHCP-Bereichsanfang muss eine IPv4-Adresse sein."; +"DHCP Range Ending cannot be empty." = "Das DHCP-Bereichsende darf nicht leer sein."; +"DHCP Range Ending must be an IPv4 address." = "Das DHCP-Bereichsende muss eine IPv4-Adresse sein."; +"DHCP Range:" = "DHCP-Bereich:"; +"DHCP Reservations:" = "DHCP-Reservierungen:"; +"DHCP and NAT" = "DHCP und NAT"; +"DNS Server must be an IPv4 address." = "Der DNS-Server muss eine IPv4-Adresse sein."; +"DNS Servers accepts at most two IPv4 DNS servers." = "DNS-Server akzeptiert höchstens zwei IPv4-DNS-Server."; +"DNS Servers contains an empty value." = "DNS-Server enthält einen leeren Wert."; +"DNS Servers:" = "DNS-Server:"; +"DNS servers" = "DNS-Server"; +"Default" = "Standard"; +"Default Host must be an IPv4 address." = "Der Standard-Host muss eine IPv4-Adresse sein."; +"Default Route:" = "Standardroute:"; +"Default password" = "Standardpasswort"; +"Delete" = "Löschen"; +"Description" = "Beschreibung"; +"Description:" = "Beschreibung:"; +"Destination" = "Zielvolume"; +"Destination:" = "Zielvolume:"; +"Dialing:" = "Wählverfahren:"; +"Disconnect if Idle:" = "Trennen bei Inaktivität:"; +"Disk Password cannot be empty." = "Das Volume-Passwort darf nicht leer sein."; +"Disk Password:" = "Volume-Passwort:"; +"Disk Sharing" = "Volume-Freigabe"; +"Disk information is not available yet." = "Volume-Informationen sind noch nicht verfügbar."; +"Disk needs repair" = "Volume muss repariert werden"; +"Disk password" = "Volume-Passwort"; +"Disk passwords do not match." = "Die Volume-Passwörter stimmen nicht überein."; +"Disk space is low" = "Wenig Speicherplatz"; +"Disks" = "Volumes"; +"Domain Name:" = "Domain-Name:"; +"Done" = "Fertig"; +"Double NAT" = "Doppeltes NAT"; +"Downloading from Apple" = "Download von Apple"; +"Dry-run completed without output." = "Testlauf ohne Ausgabe abgeschlossen."; +"Each local access-control entry must contain a valid MAC address." = "Jeder lokale Zugriffssteuerungseintrag muss eine gültige MAC-Adresse enthalten."; +"Edit" = "Bearbeiten"; +"Enable AirPlay" = "AirPlay aktivieren"; +"Enable AirPlay over WAN" = "AirPlay über WAN aktivieren"; +"Enable NAT Port Mapping Protocol" = "NAT-Port-Mapping-Protokoll aktivieren"; +"Enable default host at:" = "Standard-Host aktivieren bei:"; +"Enable file sharing" = "Dateifreigabe aktivieren"; +"Enter base station password to load settings." = "Passwort der Basisstation eingeben, um die Einstellungen zu laden."; +"Enter names and matching passwords of at least 8 characters." = "Geben Sie Namen und übereinstimmende Passwörter mit mindestens 8 Zeichen ein."; +"Erase" = "Löschen"; +"Erase Disk" = "Volume löschen"; +"Erase Disk…" = "Volume löschen…"; +"Erases directory information so that data is no longer accessible. The data is left unchanged on disk until its disk space is required and it is written over. Data is potentially recoverable until then. This option is the quickest, but least secure." = "Löscht die Verzeichnisinformationen, sodass auf die Daten nicht mehr zugegriffen werden kann. Die Daten bleiben auf dem Volume unverändert, bis der Speicherplatz benötigt und überschrieben wird. Bis dahin sind sie potenziell wiederherstellbar. Diese Option ist die schnellste, aber am wenigsten sichere."; +"Erasing disk" = "Volume wird gelöscht"; +"Erasing the AirPort Time Capsule disk deletes all files from the disk." = "Beim Löschen des AirPort Time Capsule-Volumes werden alle Dateien vom Volume entfernt."; +"Examining the base station…" = "Basisstation wird geprüft…"; +"Export Configuration File" = "Konfigurationsdatei exportieren"; +"Export Configuration File..." = "Konfigurationsdatei exportieren…"; +"Extend a wireless network" = "WLAN-Netzwerk erweitern"; +"Failing" = "Fehlerhaft"; +"File" = "Ablage"; +"File Sharing Access must be read-write, read-only, or not-allowed." = "Der Zugriff auf die Dateifreigabe muss Lesen-Schreiben, Nur-Lesen oder Nicht-erlaubt sein."; +"File Sharing Access:" = "Zugriff auf Dateifreigabe:"; +"File sharing account" = "Dateifreigabe-Account"; +"Finish Factory Restore" = "Werksreset abschließen"; +"Finish editing before refreshing settings." = "Beenden Sie die Bearbeitung, bevor Sie die Einstellungen aktualisieren."; +"Firmware" = "Firmware"; +"Firmware list loaded." = "Firmware-Liste geladen."; +"Firmware list loaded. Mock mode." = "Firmware-Liste geladen. Mock-Modus."; +"Firmware update available" = "Firmware-Update verfügbar"; +"Firmware upload accepted. Waiting for restart." = "Firmware-Upload angenommen. Warten auf Neustart."; +"Firmware upload completed, but the base station reboot command was not sent." = "Der Firmware-Upload wurde abgeschlossen, aber der Neustartbefehl für die Basisstation wurde nicht gesendet."; +"Firmware uploaded. Restart requested." = "Firmware hochgeladen. Neustart angefordert."; +"Gathering information about your network…" = "Informationen über Ihr Netzwerk werden erfasst…"; +"Generation:" = "Generation:"; +"Global Hostname cannot be empty." = "Der globale Hostname darf nicht leer sein."; +"Guest Disk Access is not supported." = "Der Gastzugriff auf das Volume wird nicht unterstützt."; +"Help" = "Hilfe"; +"Hide AirPort Utility" = "AirPort-Dienstprogramm ausblenden"; +"Hide Others" = "Andere ausblenden"; +"Host" = "Host"; +"Host:" = "Host:"; +"Hostname:" = "Hostname:"; +"IP Address" = "IP-Adresse"; +"IP address" = "IP-Adresse"; +"IPv4 Address cannot be empty." = "Die IPv4-Adresse darf nicht leer sein."; +"IPv4 Address must be an IPv4 address." = "Die IPv4-Adresse muss eine IPv4-Adresse sein."; +"IPv4 Address:" = "IPv4-Adresse:"; +"IPv4 DHCP Range:" = "IPv4-DHCP-Bereich:"; +"IPv6 Address must be an IPv6 address." = "Die IPv6-Adresse muss eine IPv6-Adresse sein."; +"IPv6 Address:" = "IPv6-Adresse:"; +"IPv6 DNS Server must be an IPv6 address." = "Der IPv6-DNS-Server muss eine IPv6-Adresse sein."; +"IPv6 DNS Servers accepts at most two IPv6 DNS servers." = "IPv6-DNS-Server akzeptiert höchstens zwei IPv6-DNS-Server."; +"IPv6 DNS Servers contains an empty value." = "IPv6-DNS-Server enthält einen leeren Wert."; +"IPv6 DNS Servers:" = "IPv6-DNS-Server:"; +"IPv6 Mode" = "IPv6-Modus"; +"IPv6 Mode must be host, tunnel, or router." = "Der IPv6-Modus muss Host, Tunnel oder Router sein."; +"IPv6 Mode:" = "IPv6-Modus:"; +"Identify Base Station" = "Basisstation identifizieren"; +"Idle Disconnect After has an unsupported value." = "Trennen nach Inaktivität hat einen nicht unterstützten Wert."; +"Idle Disconnect After:" = "Trennen nach Inaktivität:"; +"Ignore Dial Tone" = "Freizeichen ignorieren"; +"Ignored identity refresh while editing." = "Identitätsaktualisierung während der Bearbeitung ignoriert."; +"Ignored settings refresh while editing." = "Einstellungsaktualisierung während der Bearbeitung ignoriert."; +"Import Configuration File" = "Konfigurationsdatei importieren"; +"Import Configuration File..." = "Konfigurationsdatei importieren…"; +"Initial setup has not been marked complete." = "Die Erstkonfiguration wurde nicht als abgeschlossen markiert."; +"Install" = "Installieren"; +"Internet" = "Internet"; +"Internet Options" = "Internet-Optionen"; +"Internet Options..." = "Internet-Optionen…"; +"Internet inactive" = "Internet inaktiv"; +"Internet working normally" = "Internet funktioniert normal"; +"Join a wireless network" = "WLAN-Netzwerk beitreten"; +"Join or extend a Wi-Fi network that is already available." = "Einem bereits verfügbaren Wi-Fi-Netzwerk beitreten oder es erweitern."; +"LAN IP Address must be an IPv4 address." = "Die LAN-IP-Adresse muss eine IPv4-Adresse sein."; +"LAN IP Address:" = "LAN-IP-Adresse:"; +"LAN IP address" = "LAN-IP"; +"LDAP Server:" = "LDAP-Server:"; +"Link-local only" = "Nur Link-local"; +"Loading Internet Settings" = "Internet-Einstellungen werden geladen"; +"Loading Wireless Clients" = "WLAN-Clients werden geladen"; +"Loading disk information..." = "Volume-Informationen werden geladen…"; +"Loading firmware list" = "Firmware-Liste wird geladen"; +"Local" = "Lokal"; +"Location:" = "Ort:"; +"Logging & Statistics" = "Protokolle"; +"Make sure the storage device has enough space for the archive before connecting it to the base station’s USB Port." = "Stellen Sie sicher, dass das Speichergerät genügend Platz für das Archiv bietet, bevor Sie es an den USB-Anschluss der Basisstation anschließen."; +"Manually" = "Manuell"; +"Maximum Connect Time has an unsupported value." = "Die maximale Verbindungsdauer hat einen nicht unterstützten Wert."; +"Maximum Connect Time:" = "Maximale Verbindungsdauer:"; +"Minimize" = "Im Dock ablegen"; +"Mock backend enabled with fixture Time Capsule settings." = "Mock-Backend mit Time Capsule-Testeinstellungen aktiviert."; +"Mock firmware upload accepted. Restart requested." = "Simulierter Firmware-Upload angenommen. Neustart angefordert."; +"Mock firmware uploaded." = "Simulierte Firmware hochgeladen."; +"Mock network scan completed." = "Simulierter Netzwerk-Scan abgeschlossen."; +"Mock refresh completed." = "Simulierte Aktualisierung abgeschlossen."; +"Modem Options" = "Modem-Optionen"; +"Modem Options..." = "Modem-Optionen…"; +"Modem passwords do not match." = "Die Modem-Passwörter stimmen nicht überein."; +"Multicast Rate" = "Multicast-Rate"; +"Multicast Rate is not supported." = "Die Multicast-Rate wird nicht unterstützt."; +"Multicast Rate:" = "Multicast-Rate:"; +"NAT Only" = "Nur NAT"; +"Name" = "Name"; +"Name:" = "Name:"; +"Network" = "Netzwerk"; +"Network Interfaces" = "Netzwerkschnittstellen"; +"Network Mode:" = "Netzwerkmodus:"; +"Network Name:" = "Netzwerkname:"; +"Network Options" = "Netzwerkoptionen"; +"Network Options..." = "Netzwerkoptionen…"; +"Network Setup" = "Netzwerkkonfiguration"; +"Network name" = "Netzwerkname"; +"Never Disconnect" = "Nie trennen"; +"New AirPort base station" = "Neue AirPort-Basisstation"; +"New password" = "Neues Passwort"; +"New wireless password" = "Neues WLAN-Passwort"; +"Next" = "Weiter"; +"No AirPort base stations discovered" = "Keine AirPort-Basisstationen gefunden"; +"No AirPort disks available" = "Keine AirPort-Volumes verfügbar"; +"No Apple firmware images are listed for this base station." = "Für diese Basisstation sind keine Apple-Firmware-Images aufgeführt."; +"No DNS servers configured" = "Keine DNS-Server konfiguriert"; +"No disk information loaded." = "Keine Volume-Informationen geladen."; +"No disk partitions found." = "Keine Volume-Partitionen gefunden."; +"No firmware image is selected." = "Es ist kein Firmware-Image ausgewählt."; +"No firmware images loaded" = "Keine Firmware-Images geladen"; +"No new Wi-Fi devices discovered" = "Keine neuen Wi-Fi-Geräte gefunden"; +"No pending Advanced changes to apply." = "Keine ausstehenden erweiterten Änderungen zum Anwenden."; +"No pending Advanced changes to preview." = "Keine ausstehenden erweiterten Änderungen für die Vorschau."; +"No pending AirPlay changes to apply." = "Keine ausstehenden AirPlay-Änderungen zum Anwenden."; +"No pending AirPlay changes to preview." = "Keine ausstehenden AirPlay-Änderungen für die Vorschau."; +"No pending Base Station changes to apply." = "Keine ausstehenden Basisstation-Änderungen zum Anwenden."; +"No pending Base Station changes to preview." = "Keine ausstehenden Basisstation-Änderungen für die Vorschau."; +"No pending Disk Sharing changes to apply." = "Keine ausstehenden Änderungen der Volume-Freigabe zum Anwenden."; +"No pending Disk Sharing changes to preview." = "Keine ausstehenden Änderungen der Volume-Freigabe für die Vorschau."; +"No pending Internet changes to apply." = "Keine ausstehenden Internet-Änderungen zum Anwenden."; +"No pending Internet changes to preview." = "Keine ausstehenden Internet-Änderungen für die Vorschau."; +"No pending Network changes to apply." = "Keine ausstehenden Netzwerk-Änderungen zum Anwenden."; +"No pending Network changes to preview." = "Keine ausstehenden Netzwerk-Änderungen für die Vorschau."; +"No pending Wireless changes to apply." = "Keine ausstehenden WLAN-Änderungen zum Anwenden."; +"No pending Wireless changes to preview." = "Keine ausstehenden WLAN-Änderungen für die Vorschau."; +"No pending changes to apply." = "Keine ausstehenden Änderungen zum Anwenden."; +"Not Allowed" = "Nicht erlaubt"; +"Not available" = "Nicht verfügbar"; +"Not connected" = "Nicht verbunden"; +"Not enabled" = "Nicht aktiviert"; +"Off" = "Aus"; +"Open wireless network" = "Offenes WLAN-Netzwerk"; +"Other Options" = "Weitere Optionen"; +"Other Wi-Fi Devices" = "Andere Wi-Fi-Geräte"; +"PPP Dial-in" = "PPP-Einwahl"; +"PPP Dial-in is not allowed when configured to connect to the Internet via the Modem or AOL." = "PPP-Einwahl ist nicht zulässig, wenn die Internetverbindung über Modem oder AOL erfolgt."; +"PPP Dial-in is not allowed when configured to share a range of addresses." = "PPP-Einwahl ist nicht zulässig, wenn ein Adressbereich freigegeben wird."; +"PPP Dial-in passwords do not match." = "Die PPP-Einwahl-Passwörter stimmen nicht überein."; +"PPPoE Account Name cannot be empty." = "Der PPPoE-Accountname darf nicht leer sein."; +"PPPoE Connection must be always-on, automatic, or manual." = "Die PPPoE-Verbindung muss immer aktiv, automatisch oder manuell sein."; +"Participate in a WDS network" = "An einem WDS-Netzwerk teilnehmen"; +"Partitions:" = "Partitionen:"; +"Password" = "Passwort"; +"Password must be at least 8 characters." = "Das Passwort muss mindestens 8 Zeichen lang sein."; +"Password:" = "Passwort:"; +"Passwords" = "Passwörter"; +"Paste" = "Einsetzen"; +"Phone Number:" = "Telefonnummer:"; +"Port Settings:" = "Anschlusseinstellungen:"; +"Preferences" = "Einstellungen"; +"Preferences..." = "Einstellungen…"; +"Primary Port:" = "Primärer Anschluss:"; +"Primary RADIUS Server must be an IPv4 address." = "Der primäre RADIUS-Server muss eine IPv4-Adresse sein."; +"Primary RADIUS Shared Secret cannot be empty." = "Der primäre gemeinsame RADIUS-Schlüssel darf nicht leer sein."; +"Primary RADIUS port must be between 1 and 65535." = "Der primäre RADIUS-Anschluss muss zwischen 1 und 65535 liegen."; +"Primary RADIUS shared secrets do not match." = "Die primären gemeinsamen RADIUS-Schlüssel stimmen nicht überein."; +"Primary Server:" = "Primärer Server:"; +"Progress:" = "Fortschritt:"; +"Protocol:" = "Protokoll:"; +"Pulse" = "Impulswahl"; +"Quick Erase (non-secure)" = "Schnelles Löschen (nicht sicher)"; +"Quit AirPort Utility" = "AirPort-Dienstprogramm beenden"; +"RADIUS Type:" = "RADIUS-Typ:"; +"RADIUS type is not supported." = "Der RADIUS-Typ wird nicht unterstützt."; +"Radio Channel" = "Funkkanal"; +"Radio Channel:" = "Funkkanal:"; +"Radio Mode" = "Funkmodus"; +"Radio Mode is not supported." = "Der Funkmodus wird nicht unterstützt."; +"Radio Mode:" = "Funkmodus:"; +"Radio channel must be 'automatic' or a channel number." = "Der Funkkanal muss „automatisch“ oder eine Kanalnummer sein."; +"Read Only" = "Nur Lesen"; +"Read and Write" = "Lesen und Schreiben"; +"Ready to connect to %@" = "Bereit zur Verbindung mit %@"; +"Redo" = "Wiederholen"; +"Refresh" = "Aktualisieren"; +"Refresh completed." = "Aktualisierung abgeschlossen."; +"Refreshing settings" = "Einstellungen werden aktualisiert"; +"Region" = "Region"; +"Region code must be between 0 and 255." = "Der Regionscode muss zwischen 0 und 255 liegen."; +"Region:" = "Region:"; +"Reinstall" = "Neu installieren"; +"Remember this password in my keychain" = "Passwort im Schlüsselbund sichern"; +"Remove access-control entry" = "Zugriffssteuerungseintrag entfernen"; +"Renew DHCP Lease" = "DHCP-Lease erneuern"; +"Replace an existing device" = "Vorhandenes Gerät ersetzen"; +"Repo" = "Repo"; +"Reports Double NAT despite Bridge Mode." = "Meldet doppeltes NAT trotz Bridge-Modus."; +"Repository" = "Repository"; +"Repository:" = "Repository:"; +"Rescanning the network for AirPort base stations." = "Netzwerk wird erneut nach AirPort-Basisstationen durchsucht."; +"Restart Base Station" = "Basisstation neu starten"; +"Restart Base Station?" = "Basisstation neu starten?"; +"Restart command sent." = "Neustartbefehl gesendet."; +"Restart with Default Settings" = "Mit Standardeinstellungen neu starten"; +"Restarting" = "Neustart"; +"Restart…" = "Neustart…"; +"Restore Default Settings" = "Standardeinstellungen wiederherstellen"; +"Restore Default Settings..." = "Standardeinstellungen wiederherstellen…"; +"Restore Default Settings?" = "Standardeinstellungen wiederherstellen?"; +"Restore Factory Defaults" = "Werkseinstellungen wiederherstellen"; +"Restore failed: %@" = "Wiederherstellung fehlgeschlagen: %@"; +"Restoring" = "Wiederherstellung"; +"Restoring Base Station…" = "Basisstation wird wiederhergestellt…"; +"Restoring this Base Station to factory defaults erases its settings." = "Beim Wiederherstellen der Werkseinstellungen werden die Einstellungen dieser Basisstation gelöscht."; +"Router" = "Router"; +"Router Address cannot be empty." = "Die Router-Adresse darf nicht leer sein."; +"Router Address must be an IPv4 address." = "Die Router-Adresse muss eine IPv4-Adresse sein."; +"Router Address:" = "Router-Adresse:"; +"Router Mode:" = "Router-Modus:"; +"SMART: %@" = "SMART: %@"; +"Save" = "Sichern"; +"Scanning for AirPort base stations…" = "Suche nach AirPort-Basisstationen…"; +"Secondary Port:" = "Sekundärer Anschluss:"; +"Secondary RADIUS Server must be an IPv4 address." = "Der sekundäre RADIUS-Server muss eine IPv4-Adresse sein."; +"Secondary RADIUS Shared Secret cannot be empty." = "Der sekundäre gemeinsame RADIUS-Schlüssel darf nicht leer sein."; +"Secondary RADIUS port must be between 1 and 65535." = "Der sekundäre RADIUS-Anschluss muss zwischen 1 und 65535 liegen."; +"Secondary RADIUS shared secrets do not match." = "Die sekundären gemeinsamen RADIUS-Schlüssel stimmen nicht überein."; +"Secondary Server:" = "Sekundärer Server:"; +"Secure Shared Disks mode is not supported." = "Der Modus für freigegebene Volumes wird nicht unterstützt."; +"Secure Shared Disks:" = "Freigegebene Volumes schützen:"; +"Security Method" = "Sicherheitsmethode"; +"Security Method:" = "Sicherheitsmethode:"; +"Select All" = "Alles auswählen"; +"Service Name:" = "Dienstname:"; +"Services" = "Dienste"; +"Set time automatically" = "Uhrzeit automatisch einstellen"; +"Set up this %@ to create a new Wi-Fi network." = "%@ für ein neues Wi-Fi-Netzwerk konfigurieren."; +"Setting up this %@…" = "%@ wird konfiguriert…"; +"Settings" = "Einstellungen"; +"Setup" = "Konfiguration"; +"Setup Complete" = "Konfiguration abgeschlossen"; +"Setup Over Ethernet WAN" = "Konfiguration über Ethernet-WAN"; +"Setup failed" = "Konfiguration fehlgeschlagen"; +"Setup over the Ethernet WAN port is enabled." = "Die Konfiguration über den Ethernet-WAN-Anschluss ist aktiviert."; +"Shared Secret:" = "Gemeinsamer Schlüssel:"; +"Show All" = "Alle einblenden"; +"Show Passwords…" = "Passwörter anzeigen…"; +"Show connection details in the Other Wi-Fi Devices menu" = "Verbindungsdetails im Menü „Andere Wi-Fi-Geräte“ anzeigen"; +"Simple Network Management Protocol (SNMP) allows you to query this device for statistics, including the number of wireless clients." = "Das Simple Network Management Protocol (SNMP) ermöglicht es, dieses Gerät nach Statistiken abzufragen, einschließlich der Anzahl der WLAN-Clients."; +"Speaker name" = "Lautsprechername"; +"Speaker password" = "Lautsprecher-Passwort"; +"Starting download from Apple." = "Download von Apple wird gestartet."; +"Starting upload to AirPort." = "Upload zur AirPort wird gestartet."; +"Subnet Mask cannot be empty." = "Die Teilnetzmaske darf nicht leer sein."; +"Subnet Mask:" = "Teilnetzmaske:"; +"Syslog Destination Address must be an IPv4 address." = "Die Syslog-Zieladresse muss eine IPv4-Adresse sein."; +"Syslog Destination Address:" = "Syslog-Zieladresse:"; +"Syslog Level must be between 0 and 7." = "Der Syslog-Level muss zwischen 0 und 7 liegen."; +"Syslog Level:" = "Syslog-Level:"; +"The base station is still using the default admin password." = "Die Basisstation verwendet noch das Standard-Administratorpasswort."; +"The base station product ID is not available." = "Die Produkt-ID der Basisstation ist nicht verfügbar."; +"The base station setup profile has not loaded yet." = "Das Konfigurationsprofil der Basisstation wurde noch nicht geladen."; +"The base station setup profile has not loaded. Go Back and try again." = "Das Konfigurationsprofil der Basisstation wurde nicht geladen. Gehen Sie zurück und versuchen Sie es erneut."; +"The base station setup profile is missing Wi-Fi or timezone settings." = "Im Konfigurationsprofil der Basisstation fehlen Wi-Fi- oder Zeitzoneneinstellungen."; +"The device and its network services will be temporarily unavailable. Are you sure you want to continue?" = "Das Gerät und seine Netzwerkdienste sind vorübergehend nicht verfügbar. Möchten Sie wirklich fortfahren?"; +"The wireless network is open and does not require a Wi-Fi password." = "Das WLAN-Netzwerk ist offen und erfordert kein Wi-Fi-Passwort."; +"This %@ will create a network." = "%@ erstellt ein Netzwerk."; +"This AirPort wireless device supports log messages that may help diagnose a problem." = "Dieses AirPort-WLAN-Gerät unterstützt Protokollmeldungen, die bei der Diagnose eines Problems helfen können."; +"This base station does not support AirPlay." = "Diese Basisstation unterstützt AirPlay nicht."; +"This base station does not support a modem connection." = "Diese Basisstation unterstützt Modemverbindungen nicht."; +"This base station does not support advanced settings." = "Diese Basisstation unterstützt erweiterte Einstellungen nicht."; +"This base station does not support firmware updates." = "Diese Basisstation unterstützt Firmware-Updates nicht."; +"This base station does not support modem options." = "Diese Basisstation unterstützt keine Modem-Optionen."; +"Time Capsule" = "Time Capsule"; +"Time Server cannot be empty when automatic time is enabled." = "Der Zeitserver darf nicht leer sein, wenn die automatische Zeit aktiviert ist."; +"Time Server:" = "Zeitserver:"; +"Tone" = "Tonwahl"; +"Transmit Power" = "Sendeleistung"; +"Transmit Power is not supported." = "Die Sendeleistung wird nicht unterstützt."; +"Transmit Power:" = "Sendeleistung:"; +"Tunnel" = "Tunnel"; +"Type" = "Typ"; +"Undo" = "Widerrufen"; +"Unknown" = "Unbekannt"; +"Update" = "Aktualisieren"; +"Use AOL" = "AOL verwenden"; +"Use a single password" = "Ein einziges Passwort verwenden"; +"Use dynamic global hostname" = "Dynamischen globalen Hostnamen verwenden"; +"Use interference robustness" = "Störungsrobustheit verwenden"; +"User:" = "Benutzer:"; +"Using bundled mock firmware." = "Mitgelieferte simulierte Firmware wird verwendet."; +"Using selected firmware file." = "Ausgewählte Firmware-Datei wird verwendet."; +"Verified" = "Überprüft"; +"Verify Password:" = "Passwort wiederholen:"; +"Verify Secret:" = "Schlüssel bestätigen:"; +"Verify account password" = "Account-Passwort bestätigen"; +"Verify disk password" = "Volume-Passwort bestätigen"; +"Verify password" = "Passwort wiederholen"; +"Verify speaker password" = "Lautsprecher-Passwort bestätigen"; +"Verify wireless password" = "WLAN-Passwort bestätigen"; +"Version:" = "Version:"; +"WAN setup over Ethernet" = "WAN-Konfiguration über Ethernet"; +"WDS Mode must be main, relay, remote, or off." = "Der WDS-Modus muss Haupt, Relais, Fern oder Aus sein."; +"WDS Mode:" = "WDS-Modus:"; +"WDS Peers:" = "WDS-Gegenstellen:"; +"WDS main" = "WDS-Haupt"; +"WDS peer AirPort IDs must be one or two MAC addresses." = "WDS-Gegenstellen-AirPort-IDs müssen eine oder zwei MAC-Adressen sein."; +"WDS relay" = "WDS-Relais"; +"WDS remote" = "WDS-Fern"; +"WINS Server must be an IPv4 address." = "Der WINS-Server muss eine IPv4-Adresse sein."; +"WPA Group Key Timeout" = "Timeout des WPA-Gruppenschlüssels"; +"WPA Group Key Timeout must be between 60 seconds and 24 hours." = "Das Timeout des WPA-Gruppenschlüssels muss zwischen 60 Sekunden und 24 Stunden liegen."; +"WPA Group Key Timeout:" = "WPA-Schlüssel-Timeout:"; +"Wait for the current base station operation to finish, then try again." = "Warten Sie, bis der aktuelle Vorgang der Basisstation abgeschlossen ist, und versuchen Sie es erneut."; +"Waiting for this base station to apply its settings and restart…" = "Warten, bis diese Basisstation ihre Einstellungen anwendet und neu startet…"; +"Waiting for this base station to restart with default settings." = "Warten, bis diese Basisstation mit Standardeinstellungen neu startet."; +"Waiting for this base station to restore its default settings and restart…" = "Warten, bis diese Basisstation ihre Standardeinstellungen wiederherstellt und neu startet…"; +"Waiting to restore default settings" = "Warten auf Wiederherstellung der Standardeinstellungen"; +"What do you want to do with this %@?" = "Was möchten Sie mit %@ tun?"; +"Window" = "Fenster"; +"Wireless" = "WLAN"; +"Wireless Network Name" = "WLAN-Name"; +"Wireless Network Name cannot be empty." = "Der WLAN-Name darf nicht leer sein."; +"Wireless Network Name:" = "WLAN-Name:"; +"Wireless Options" = "WLAN-Optionen"; +"Wireless Options..." = "WLAN-Optionen…"; +"Wireless Password cannot be empty." = "Das WLAN-Passwort darf nicht leer sein."; +"Wireless Password:" = "WLAN-Passwort:"; +"Wireless Security is not supported." = "Die WLAN-Sicherheit wird nicht unterstützt."; +"Wireless Security:" = "WLAN-Sicherheit:"; +"Wireless extension problem" = "Problem bei der WLAN-Erweiterung"; +"Wireless passwords do not match." = "Die WLAN-Passwörter stimmen nicht überein."; +"With a disk password" = "Mit einem Volume-Passwort"; +"With accounts" = "Mit Accounts"; +"With device password" = "Mit dem Gerätekennwort"; +"Working" = "Vorgang läuft…"; +"Working normally" = "Funktioniert normal"; +"Writes over disk data seven times. This option is more secure and takes significantly longer." = "Überschreibt die Volume-Daten sieben Mal. Diese Option ist sicherer und dauert deutlich länger."; +"Writes over disk data thirty-five times. This option is the most secure and takes the longest." = "Überschreibt die Volume-Daten 35 Mal. Diese Option ist die sicherste und dauert am längsten."; +"Writes zeros over all data on the disk. This option provides better security than a quick erase, but takes longer." = "Überschreibt alle Daten auf dem Volume mit Nullen. Diese Option ist sicherer als ein schnelles Löschen, dauert aber länger."; +"Zero Out Data" = "Mit Nullen überschreiben"; +"Zoom" = "Zoomen"; +"day" = "Tag"; +"file sharing" = "Freigaben"; +"firmware update" = "Firmware-Update"; +"hour" = "Stunde"; +"menu.Edit" = "Bearbeiten"; +"minute" = "Minute"; +"network" = "Netzwerk"; +"router address" = "Router-Adresse"; +"second" = "Sekunde"; +"serial number" = "Seriennummer"; +"status" = "Status"; +"verified" = "überprüft"; +"version" = "Version"; +"week" = "Woche"; +"wireless clients" = "WLAN-Clients"; +"“%@” is now available." = "„%@“ ist jetzt verfügbar."; diff --git a/Sources/AirPortUtilityCore/Resources/en.lproj/Localizable.strings b/Sources/AirPortUtilityCore/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..2d31afd --- /dev/null +++ b/Sources/AirPortUtilityCore/Resources/en.lproj/Localizable.strings @@ -0,0 +1,556 @@ +/* AirPort Utility - en + Keys are the English source strings. Untranslated keys fall back to + English automatically, so this table may be incomplete. + Do NOT add ACP keys, backend flags or persisted raw values here. */ + +"%1$@ used / %2$@" = "%1$@ used / %2$@"; +"%@ free" = "%@ free"; +"%@ used" = "%@ used"; +"0 - Emergency" = "0 - Emergency"; +"1 - Alert" = "1 - Alert"; +"1 hour" = "1 hour"; +"15 minutes" = "15 minutes"; +"1st generation" = "1st generation"; +"2 - Critical" = "2 - Critical"; +"2 hours" = "2 hours"; +"24 hours" = "24 hours"; +"2nd generation" = "2nd generation"; +"3 - Error" = "3 - Error"; +"30 minutes" = "30 minutes"; +"35-Pass Erase" = "35-Pass Erase"; +"3rd generation" = "3rd generation"; +"4 - Warning" = "4 - Warning"; +"4 hours" = "4 hours"; +"4th generation" = "4th generation"; +"5 - Notice" = "5 - Notice"; +"5th generation" = "5th generation"; +"6 - Informational" = "6 - Informational"; +"6th generation" = "6th generation"; +"7 - Debug" = "7 - Debug"; +"7-Pass Erase" = "7-Pass Erase"; +"8 hours" = "8 hours"; +"About AirPort Utility" = "About AirPort Utility"; +"Access Control" = "Access Control"; +"Access Control mode is not supported." = "Access Control mode is not supported."; +"Access Control:" = "Access Control:"; +"Access-control descriptions may contain at most 34 UTF-8 bytes." = "Access-control descriptions may contain at most 34 UTF-8 bytes."; +"Account Name" = "Account Name"; +"Account Name:" = "Account Name:"; +"Account Password cannot be empty." = "Account Password cannot be empty."; +"Account name" = "Account name"; +"Account password" = "Account password"; +"Account passwords do not match." = "Account passwords do not match."; +"Accounts:" = "Accounts:"; +"Add Client" = "Add Client"; +"Add WPS Printer…" = "Add WPS Printer…"; +"Add to an existing network" = "Add to an existing network"; +"Admin Password" = "Admin Password"; +"Admin passwords do not match." = "Admin passwords do not match."; +"Advanced" = "Advanced"; +"Advanced ACP JSON is not valid JSON." = "Advanced ACP JSON is not valid JSON."; +"Advanced ACP JSON must be an object keyed by setting name." = "Advanced ACP JSON must be an object keyed by setting name."; +"Advanced ACP JSON must be valid UTF-8." = "Advanced ACP JSON must be valid UTF-8."; +"Advanced ACP setting names must be four characters." = "Advanced ACP setting names must be four characters."; +"AirPlay" = "AirPlay"; +"AirPlay Speaker Name cannot be empty." = "AirPlay Speaker Name cannot be empty."; +"AirPlay Speaker Name:" = "AirPlay Speaker Name:"; +"AirPlay Speaker Password:" = "AirPlay Speaker Password:"; +"AirPlay passwords do not match." = "AirPlay passwords do not match."; +"AirPort Base Station" = "AirPort Base Station"; +"AirPort Configuration" = "AirPort Configuration"; +"AirPort Express" = "AirPort Express"; +"AirPort Extreme" = "AirPort Extreme"; +"AirPort ID" = "AirPort ID"; +"AirPort ID:" = "AirPort ID:"; +"AirPort Time Capsule Disk" = "AirPort Time Capsule Disk"; +"AirPort Utility" = "AirPort Utility"; +"AirPort Utility Help" = "AirPort Utility Help"; +"All users will be disconnected from this disk." = "All users will be disconnected from this disk."; +"All wireless clients are allowed to join this network." = "All wireless clients are allowed to join this network."; +"Allow SNMP" = "Allow SNMP"; +"Allow SNMP over WAN" = "Allow SNMP over WAN"; +"Allow only the wireless clients listed below." = "Allow only the wireless clients listed below."; +"Allow setup over Ethernet WAN port" = "Allow setup over Ethernet WAN port"; +"Allow this network to be extended" = "Allow this network to be extended"; +"Alternate" = "Alternate"; +"Alternate Number:" = "Alternate Number:"; +"Always On" = "Always On"; +"Another router appears to be providing NAT upstream of this base station." = "Another router appears to be providing NAT upstream of this base station."; +"Answer on ring must be between 1 and 255." = "Answer on ring must be between 1 and 255."; +"Answer on ring:" = "Answer on ring:"; +"Applying Archive Disk" = "Applying Archive Disk"; +"Archive" = "Archive"; +"Archive Disk" = "Archive Disk"; +"Archive Disk complete." = "Archive Disk complete."; +"Archive Disk in progress." = "Archive Disk in progress."; +"Archive Disk started. Waiting for archive to complete." = "Archive Disk started. Waiting for archive to complete."; +"Archive Disk status check timed out." = "Archive Disk status check timed out."; +"Archive Disk…" = "Archive Disk…"; +"Archive the AirPort Time Capsule disk to back up your data." = "Archive the AirPort Time Capsule disk to back up your data."; +"Archiving disk" = "Archiving disk"; +"Are you sure you want to archive the AirPort Time Capsule disk to a disk connected using USB?" = "Are you sure you want to archive the AirPort Time Capsule disk to a disk connected using USB?"; +"Are you sure you want to erase the AirPort Time Capsule disk?" = "Are you sure you want to erase the AirPort Time Capsule disk?"; +"Automatic" = "Automatic"; +"Automatically" = "Automatically"; +"Automatically Dial" = "Automatically Dial"; +"Available Firmware:" = "Available Firmware:"; +"Back" = "Back"; +"Base Station" = "Base Station"; +"Base Station Name" = "Base Station Name"; +"Base Station Name cannot be empty." = "Base Station Name cannot be empty."; +"Base Station Name:" = "Base Station Name:"; +"Base Station Options" = "Base Station Options"; +"Base Station Password" = "Base Station Password"; +"Base Station Password:" = "Base Station Password:"; +"Base Station to Replace:" = "Base Station to Replace:"; +"Block incoming IPv6 connections" = "Block incoming IPv6 connections"; +"Bring All to Front" = "Bring All to Front"; +"Cancel" = "Cancel"; +"Check for Updates" = "Check for Updates"; +"Choose a base station" = "Choose a base station"; +"Choose..." = "Choose..."; +"Chosen Firmware" = "Chosen Firmware"; +"Close" = "Close"; +"Command Log" = "Command Log"; +"Configuration problem" = "Configuration problem"; +"Configure IPv6" = "Configure IPv6"; +"Configure IPv6 must be link-local, automatic, or manual." = "Configure IPv6 must be link-local, automatic, or manual."; +"Configure IPv6:" = "Configure IPv6:"; +"Configure Other" = "Configure Other"; +"Configure Other..." = "Configure Other..."; +"Configure automatically" = "Configure automatically"; +"Connect" = "Connect"; +"Connect Using:" = "Connect Using:"; +"Connect to Base Station" = "Connect to Base Station"; +"Connect to Base Station..." = "Connect to Base Station..."; +"Connected" = "Connected"; +"Connected to %@" = "Connected to %@"; +"Connected to %@. Mock mode." = "Connected to %@. Mock mode."; +"Connecting to Base Station" = "Connecting to Base Station"; +"Connection:" = "Connection:"; +"Contact:" = "Contact:"; +"Continue" = "Continue"; +"Copy" = "Copy"; +"Copy compatible settings from another AirPort base station." = "Copy compatible settings from another AirPort base station."; +"Could not combine legacy settings into one update." = "Could not combine legacy settings into one update."; +"Could not encode disk account settings." = "Could not encode disk account settings."; +"Could not encode local access-control settings." = "Could not encode local access-control settings."; +"Could not encode the combined legacy settings update." = "Could not encode the combined legacy settings update."; +"Could not find the Time Capsule. Check that it is on this network, or enter its IP address instead of the .local name." = "Could not find the Time Capsule. Check that it is on this network, or enter its IP address instead of the .local name."; +"Could not read the base station setup profile." = "Could not read the base station setup profile."; +"Country Code:" = "Country Code:"; +"Create a new network" = "Create a new network"; +"Create a separate Wi-Fi network using this base station." = "Create a separate Wi-Fi network using this base station."; +"Create a wireless network" = "Create a wireless network"; +"Create hidden network" = "Create hidden network"; +"Cut" = "Cut"; +"DHCP Lease cannot be empty." = "DHCP Lease cannot be empty."; +"DHCP Lease duration must be between 1 second and 10 years." = "DHCP Lease duration must be between 1 second and 10 years."; +"DHCP Lease must be a positive number." = "DHCP Lease must be a positive number."; +"DHCP Lease unit is not supported." = "DHCP Lease unit is not supported."; +"DHCP Lease:" = "DHCP Lease:"; +"DHCP Message:" = "DHCP Message:"; +"DHCP Only" = "DHCP Only"; +"DHCP Range Beginning and Ending must use the same supported private subnet, with Ending not before Beginning." = "DHCP Range Beginning and Ending must use the same supported private subnet, with Ending not before Beginning."; +"DHCP Range Beginning cannot be empty." = "DHCP Range Beginning cannot be empty."; +"DHCP Range Beginning must be an IPv4 address." = "DHCP Range Beginning must be an IPv4 address."; +"DHCP Range Ending cannot be empty." = "DHCP Range Ending cannot be empty."; +"DHCP Range Ending must be an IPv4 address." = "DHCP Range Ending must be an IPv4 address."; +"DHCP Range:" = "DHCP Range:"; +"DHCP Reservations:" = "DHCP Reservations:"; +"DHCP and NAT" = "DHCP and NAT"; +"DNS Server must be an IPv4 address." = "DNS Server must be an IPv4 address."; +"DNS Servers accepts at most two IPv4 DNS servers." = "DNS Servers accepts at most two IPv4 DNS servers."; +"DNS Servers contains an empty value." = "DNS Servers contains an empty value."; +"DNS Servers:" = "DNS Servers:"; +"DNS servers" = "DNS servers"; +"Default" = "Default"; +"Default Host must be an IPv4 address." = "Default Host must be an IPv4 address."; +"Default Route:" = "Default Route:"; +"Default password" = "Default password"; +"Delete" = "Delete"; +"Description" = "Description"; +"Description:" = "Description:"; +"Destination" = "Destination"; +"Destination:" = "Destination:"; +"Dialing:" = "Dialing:"; +"Disconnect if Idle:" = "Disconnect if Idle:"; +"Disk Password cannot be empty." = "Disk Password cannot be empty."; +"Disk Password:" = "Disk Password:"; +"Disk Sharing" = "Disk Sharing"; +"Disk information is not available yet." = "Disk information is not available yet."; +"Disk needs repair" = "Disk needs repair"; +"Disk password" = "Disk password"; +"Disk passwords do not match." = "Disk passwords do not match."; +"Disk space is low" = "Disk space is low"; +"Disks" = "Disks"; +"Domain Name:" = "Domain Name:"; +"Done" = "Done"; +"Double NAT" = "Double NAT"; +"Downloading from Apple" = "Downloading from Apple"; +"Dry-run completed without output." = "Dry-run completed without output."; +"Each local access-control entry must contain a valid MAC address." = "Each local access-control entry must contain a valid MAC address."; +"Edit" = "Edit"; +"Enable AirPlay" = "Enable AirPlay"; +"Enable AirPlay over WAN" = "Enable AirPlay over WAN"; +"Enable NAT Port Mapping Protocol" = "Enable NAT Port Mapping Protocol"; +"Enable default host at:" = "Enable default host at:"; +"Enable file sharing" = "Enable file sharing"; +"Enter base station password to load settings." = "Enter base station password to load settings."; +"Enter names and matching passwords of at least 8 characters." = "Enter names and matching passwords of at least 8 characters."; +"Erase" = "Erase"; +"Erase Disk" = "Erase Disk"; +"Erase Disk…" = "Erase Disk…"; +"Erases directory information so that data is no longer accessible. The data is left unchanged on disk until its disk space is required and it is written over. Data is potentially recoverable until then. This option is the quickest, but least secure." = "Erases directory information so that data is no longer accessible. The data is left unchanged on disk until its disk space is required and it is written over. Data is potentially recoverable until then. This option is the quickest, but least secure."; +"Erasing disk" = "Erasing disk"; +"Erasing the AirPort Time Capsule disk deletes all files from the disk." = "Erasing the AirPort Time Capsule disk deletes all files from the disk."; +"Examining the base station…" = "Examining the base station…"; +"Export Configuration File" = "Export Configuration File"; +"Export Configuration File..." = "Export Configuration File..."; +"Extend a wireless network" = "Extend a wireless network"; +"Failing" = "Failing"; +"File" = "File"; +"File Sharing Access must be read-write, read-only, or not-allowed." = "File Sharing Access must be read-write, read-only, or not-allowed."; +"File Sharing Access:" = "File Sharing Access:"; +"File sharing account" = "File sharing account"; +"Finish Factory Restore" = "Finish Factory Restore"; +"Finish editing before refreshing settings." = "Finish editing before refreshing settings."; +"Firmware" = "Firmware"; +"Firmware list loaded." = "Firmware list loaded."; +"Firmware list loaded. Mock mode." = "Firmware list loaded. Mock mode."; +"Firmware update available" = "Firmware update available"; +"Firmware upload accepted. Waiting for restart." = "Firmware upload accepted. Waiting for restart."; +"Firmware upload completed, but the base station reboot command was not sent." = "Firmware upload completed, but the base station reboot command was not sent."; +"Firmware uploaded. Restart requested." = "Firmware uploaded. Restart requested."; +"Gathering information about your network…" = "Gathering information about your network…"; +"Generation:" = "Generation:"; +"Global Hostname cannot be empty." = "Global Hostname cannot be empty."; +"Guest Disk Access is not supported." = "Guest Disk Access is not supported."; +"Help" = "Help"; +"Hide AirPort Utility" = "Hide AirPort Utility"; +"Hide Others" = "Hide Others"; +"Host" = "Host"; +"Host:" = "Host:"; +"Hostname:" = "Hostname:"; +"IP Address" = "IP Address"; +"IP address" = "IP address"; +"IPv4 Address cannot be empty." = "IPv4 Address cannot be empty."; +"IPv4 Address must be an IPv4 address." = "IPv4 Address must be an IPv4 address."; +"IPv4 Address:" = "IPv4 Address:"; +"IPv4 DHCP Range:" = "IPv4 DHCP Range:"; +"IPv6 Address must be an IPv6 address." = "IPv6 Address must be an IPv6 address."; +"IPv6 Address:" = "IPv6 Address:"; +"IPv6 DNS Server must be an IPv6 address." = "IPv6 DNS Server must be an IPv6 address."; +"IPv6 DNS Servers accepts at most two IPv6 DNS servers." = "IPv6 DNS Servers accepts at most two IPv6 DNS servers."; +"IPv6 DNS Servers contains an empty value." = "IPv6 DNS Servers contains an empty value."; +"IPv6 DNS Servers:" = "IPv6 DNS Servers:"; +"IPv6 Mode" = "IPv6 Mode"; +"IPv6 Mode must be host, tunnel, or router." = "IPv6 Mode must be host, tunnel, or router."; +"IPv6 Mode:" = "IPv6 Mode:"; +"Identify Base Station" = "Identify Base Station"; +"Idle Disconnect After has an unsupported value." = "Idle Disconnect After has an unsupported value."; +"Idle Disconnect After:" = "Idle Disconnect After:"; +"Ignore Dial Tone" = "Ignore Dial Tone"; +"Ignored identity refresh while editing." = "Ignored identity refresh while editing."; +"Ignored settings refresh while editing." = "Ignored settings refresh while editing."; +"Import Configuration File" = "Import Configuration File"; +"Import Configuration File..." = "Import Configuration File..."; +"Initial setup has not been marked complete." = "Initial setup has not been marked complete."; +"Install" = "Install"; +"Internet" = "Internet"; +"Internet Options" = "Internet Options"; +"Internet Options..." = "Internet Options..."; +"Internet inactive" = "Internet inactive"; +"Internet working normally" = "Internet working normally"; +"Join a wireless network" = "Join a wireless network"; +"Join or extend a Wi-Fi network that is already available." = "Join or extend a Wi-Fi network that is already available."; +"LAN IP Address must be an IPv4 address." = "LAN IP Address must be an IPv4 address."; +"LAN IP Address:" = "LAN IP Address:"; +"LAN IP address" = "LAN IP address"; +"LDAP Server:" = "LDAP Server:"; +"Link-local only" = "Link-local only"; +"Loading Internet Settings" = "Loading Internet Settings"; +"Loading Wireless Clients" = "Loading Wireless Clients"; +"Loading disk information..." = "Loading disk information..."; +"Loading firmware list" = "Loading firmware list"; +"Local" = "Local"; +"Location:" = "Location:"; +"Logging & Statistics" = "Logging & Statistics"; +"Make sure the storage device has enough space for the archive before connecting it to the base station’s USB Port." = "Make sure the storage device has enough space for the archive before connecting it to the base station’s USB Port."; +"Manually" = "Manually"; +"Maximum Connect Time has an unsupported value." = "Maximum Connect Time has an unsupported value."; +"Maximum Connect Time:" = "Maximum Connect Time:"; +"Minimize" = "Minimize"; +"Mock backend enabled with fixture Time Capsule settings." = "Mock backend enabled with fixture Time Capsule settings."; +"Mock firmware upload accepted. Restart requested." = "Mock firmware upload accepted. Restart requested."; +"Mock firmware uploaded." = "Mock firmware uploaded."; +"Mock network scan completed." = "Mock network scan completed."; +"Mock refresh completed." = "Mock refresh completed."; +"Modem Options" = "Modem Options"; +"Modem Options..." = "Modem Options..."; +"Modem passwords do not match." = "Modem passwords do not match."; +"Multicast Rate" = "Multicast Rate"; +"Multicast Rate is not supported." = "Multicast Rate is not supported."; +"Multicast Rate:" = "Multicast Rate:"; +"NAT Only" = "NAT Only"; +"Name" = "Name"; +"Name:" = "Name:"; +"Network" = "Network"; +"Network Interfaces" = "Network Interfaces"; +"Network Mode:" = "Network Mode:"; +"Network Name:" = "Network Name:"; +"Network Options" = "Network Options"; +"Network Options..." = "Network Options..."; +"Network Setup" = "Network Setup"; +"Network name" = "Network name"; +"Never Disconnect" = "Never Disconnect"; +"New AirPort base station" = "New AirPort base station"; +"New password" = "New password"; +"New wireless password" = "New wireless password"; +"Next" = "Next"; +"No AirPort base stations discovered" = "No AirPort base stations discovered"; +"No AirPort disks available" = "No AirPort disks available"; +"No Apple firmware images are listed for this base station." = "No Apple firmware images are listed for this base station."; +"No DNS servers configured" = "No DNS servers configured"; +"No disk information loaded." = "No disk information loaded."; +"No disk partitions found." = "No disk partitions found."; +"No firmware image is selected." = "No firmware image is selected."; +"No firmware images loaded" = "No firmware images loaded"; +"No new Wi-Fi devices discovered" = "No new Wi-Fi devices discovered"; +"No pending Advanced changes to apply." = "No pending Advanced changes to apply."; +"No pending Advanced changes to preview." = "No pending Advanced changes to preview."; +"No pending AirPlay changes to apply." = "No pending AirPlay changes to apply."; +"No pending AirPlay changes to preview." = "No pending AirPlay changes to preview."; +"No pending Base Station changes to apply." = "No pending Base Station changes to apply."; +"No pending Base Station changes to preview." = "No pending Base Station changes to preview."; +"No pending Disk Sharing changes to apply." = "No pending Disk Sharing changes to apply."; +"No pending Disk Sharing changes to preview." = "No pending Disk Sharing changes to preview."; +"No pending Internet changes to apply." = "No pending Internet changes to apply."; +"No pending Internet changes to preview." = "No pending Internet changes to preview."; +"No pending Network changes to apply." = "No pending Network changes to apply."; +"No pending Network changes to preview." = "No pending Network changes to preview."; +"No pending Wireless changes to apply." = "No pending Wireless changes to apply."; +"No pending Wireless changes to preview." = "No pending Wireless changes to preview."; +"No pending changes to apply." = "No pending changes to apply."; +"Not Allowed" = "Not Allowed"; +"Not available" = "Not available"; +"Not connected" = "Not connected"; +"Not enabled" = "Not enabled"; +"Off" = "Off"; +"Open wireless network" = "Open wireless network"; +"Other Options" = "Other Options"; +"Other Wi-Fi Devices" = "Other Wi-Fi Devices"; +"PPP Dial-in" = "PPP Dial-in"; +"PPP Dial-in is not allowed when configured to connect to the Internet via the Modem or AOL." = "PPP Dial-in is not allowed when configured to connect to the Internet via the Modem or AOL."; +"PPP Dial-in is not allowed when configured to share a range of addresses." = "PPP Dial-in is not allowed when configured to share a range of addresses."; +"PPP Dial-in passwords do not match." = "PPP Dial-in passwords do not match."; +"PPPoE Account Name cannot be empty." = "PPPoE Account Name cannot be empty."; +"PPPoE Connection must be always-on, automatic, or manual." = "PPPoE Connection must be always-on, automatic, or manual."; +"Participate in a WDS network" = "Participate in a WDS network"; +"Partitions:" = "Partitions:"; +"Password" = "Password"; +"Password must be at least 8 characters." = "Password must be at least 8 characters."; +"Password:" = "Password:"; +"Passwords" = "Passwords"; +"Paste" = "Paste"; +"Phone Number:" = "Phone Number:"; +"Port Settings:" = "Port Settings:"; +"Preferences" = "Preferences"; +"Preferences..." = "Preferences..."; +"Primary Port:" = "Primary Port:"; +"Primary RADIUS Server must be an IPv4 address." = "Primary RADIUS Server must be an IPv4 address."; +"Primary RADIUS Shared Secret cannot be empty." = "Primary RADIUS Shared Secret cannot be empty."; +"Primary RADIUS port must be between 1 and 65535." = "Primary RADIUS port must be between 1 and 65535."; +"Primary RADIUS shared secrets do not match." = "Primary RADIUS shared secrets do not match."; +"Primary Server:" = "Primary Server:"; +"Progress:" = "Progress:"; +"Protocol:" = "Protocol:"; +"Pulse" = "Pulse"; +"Quick Erase (non-secure)" = "Quick Erase (non-secure)"; +"Quit AirPort Utility" = "Quit AirPort Utility"; +"RADIUS Type:" = "RADIUS Type:"; +"RADIUS type is not supported." = "RADIUS type is not supported."; +"Radio Channel" = "Radio Channel"; +"Radio Channel:" = "Radio Channel:"; +"Radio Mode" = "Radio Mode"; +"Radio Mode is not supported." = "Radio Mode is not supported."; +"Radio Mode:" = "Radio Mode:"; +"Radio channel must be 'automatic' or a channel number." = "Radio channel must be 'automatic' or a channel number."; +"Read Only" = "Read Only"; +"Read and Write" = "Read and Write"; +"Ready to connect to %@" = "Ready to connect to %@"; +"Redo" = "Redo"; +"Refresh" = "Refresh"; +"Refresh completed." = "Refresh completed."; +"Refreshing settings" = "Refreshing settings"; +"Region" = "Region"; +"Region code must be between 0 and 255." = "Region code must be between 0 and 255."; +"Region:" = "Region:"; +"Reinstall" = "Reinstall"; +"Remember this password in my keychain" = "Remember this password in my keychain"; +"Remove access-control entry" = "Remove access-control entry"; +"Renew DHCP Lease" = "Renew DHCP Lease"; +"Replace an existing device" = "Replace an existing device"; +"Repo" = "Repo"; +"Reports Double NAT despite Bridge Mode." = "Reports Double NAT despite Bridge Mode."; +"Repository" = "Repository"; +"Repository:" = "Repository:"; +"Rescanning the network for AirPort base stations." = "Rescanning the network for AirPort base stations."; +"Restart Base Station" = "Restart Base Station"; +"Restart Base Station?" = "Restart Base Station?"; +"Restart command sent." = "Restart command sent."; +"Restart with Default Settings" = "Restart with Default Settings"; +"Restarting" = "Restarting"; +"Restart…" = "Restart…"; +"Restore Default Settings" = "Restore Default Settings"; +"Restore Default Settings..." = "Restore Default Settings..."; +"Restore Default Settings?" = "Restore Default Settings?"; +"Restore Factory Defaults" = "Restore Factory Defaults"; +"Restore failed: %@" = "Restore failed: %@"; +"Restoring" = "Restoring"; +"Restoring Base Station…" = "Restoring Base Station…"; +"Restoring this Base Station to factory defaults erases its settings." = "Restoring this Base Station to factory defaults erases its settings."; +"Router" = "Router"; +"Router Address cannot be empty." = "Router Address cannot be empty."; +"Router Address must be an IPv4 address." = "Router Address must be an IPv4 address."; +"Router Address:" = "Router Address:"; +"Router Mode:" = "Router Mode:"; +"SMART: %@" = "SMART: %@"; +"Save" = "Save"; +"Scanning for AirPort base stations…" = "Scanning for AirPort base stations…"; +"Secondary Port:" = "Secondary Port:"; +"Secondary RADIUS Server must be an IPv4 address." = "Secondary RADIUS Server must be an IPv4 address."; +"Secondary RADIUS Shared Secret cannot be empty." = "Secondary RADIUS Shared Secret cannot be empty."; +"Secondary RADIUS port must be between 1 and 65535." = "Secondary RADIUS port must be between 1 and 65535."; +"Secondary RADIUS shared secrets do not match." = "Secondary RADIUS shared secrets do not match."; +"Secondary Server:" = "Secondary Server:"; +"Secure Shared Disks mode is not supported." = "Secure Shared Disks mode is not supported."; +"Secure Shared Disks:" = "Secure Shared Disks:"; +"Security Method" = "Security Method"; +"Security Method:" = "Security Method:"; +"Select All" = "Select All"; +"Service Name:" = "Service Name:"; +"Services" = "Services"; +"Set time automatically" = "Set time automatically"; +"Set up this %@ to create a new Wi-Fi network." = "Set up this %@ to create a new Wi-Fi network."; +"Setting up this %@…" = "Setting up this %@…"; +"Settings" = "Settings"; +"Setup" = "Setup"; +"Setup Complete" = "Setup Complete"; +"Setup Over Ethernet WAN" = "Setup Over Ethernet WAN"; +"Setup failed" = "Setup failed"; +"Setup over the Ethernet WAN port is enabled." = "Setup over the Ethernet WAN port is enabled."; +"Shared Secret:" = "Shared Secret:"; +"Show All" = "Show All"; +"Show Passwords…" = "Show Passwords…"; +"Show connection details in the Other Wi-Fi Devices menu" = "Show connection details in the Other Wi-Fi Devices menu"; +"Simple Network Management Protocol (SNMP) allows you to query this device for statistics, including the number of wireless clients." = "Simple Network Management Protocol (SNMP) allows you to query this device for statistics, including the number of wireless clients."; +"Speaker name" = "Speaker name"; +"Speaker password" = "Speaker password"; +"Starting download from Apple." = "Starting download from Apple."; +"Starting upload to AirPort." = "Starting upload to AirPort."; +"Subnet Mask cannot be empty." = "Subnet Mask cannot be empty."; +"Subnet Mask:" = "Subnet Mask:"; +"Syslog Destination Address must be an IPv4 address." = "Syslog Destination Address must be an IPv4 address."; +"Syslog Destination Address:" = "Syslog Destination Address:"; +"Syslog Level must be between 0 and 7." = "Syslog Level must be between 0 and 7."; +"Syslog Level:" = "Syslog Level:"; +"The base station is still using the default admin password." = "The base station is still using the default admin password."; +"The base station product ID is not available." = "The base station product ID is not available."; +"The base station setup profile has not loaded yet." = "The base station setup profile has not loaded yet."; +"The base station setup profile has not loaded. Go Back and try again." = "The base station setup profile has not loaded. Go Back and try again."; +"The base station setup profile is missing Wi-Fi or timezone settings." = "The base station setup profile is missing Wi-Fi or timezone settings."; +"The device and its network services will be temporarily unavailable. Are you sure you want to continue?" = "The device and its network services will be temporarily unavailable. Are you sure you want to continue?"; +"The wireless network is open and does not require a Wi-Fi password." = "The wireless network is open and does not require a Wi-Fi password."; +"This %@ will create a network." = "This %@ will create a network."; +"This AirPort wireless device supports log messages that may help diagnose a problem." = "This AirPort wireless device supports log messages that may help diagnose a problem."; +"This base station does not support AirPlay." = "This base station does not support AirPlay."; +"This base station does not support a modem connection." = "This base station does not support a modem connection."; +"This base station does not support advanced settings." = "This base station does not support advanced settings."; +"This base station does not support firmware updates." = "This base station does not support firmware updates."; +"This base station does not support modem options." = "This base station does not support modem options."; +"Time Capsule" = "Time Capsule"; +"Time Server cannot be empty when automatic time is enabled." = "Time Server cannot be empty when automatic time is enabled."; +"Time Server:" = "Time Server:"; +"Tone" = "Tone"; +"Transmit Power" = "Transmit Power"; +"Transmit Power is not supported." = "Transmit Power is not supported."; +"Transmit Power:" = "Transmit Power:"; +"Tunnel" = "Tunnel"; +"Type" = "Type"; +"Undo" = "Undo"; +"Unknown" = "Unknown"; +"Update" = "Update"; +"Use AOL" = "Use AOL"; +"Use a single password" = "Use a single password"; +"Use dynamic global hostname" = "Use dynamic global hostname"; +"Use interference robustness" = "Use interference robustness"; +"User:" = "User:"; +"Using bundled mock firmware." = "Using bundled mock firmware."; +"Using selected firmware file." = "Using selected firmware file."; +"Verified" = "Verified"; +"Verify Password:" = "Verify Password:"; +"Verify Secret:" = "Verify Secret:"; +"Verify account password" = "Verify account password"; +"Verify disk password" = "Verify disk password"; +"Verify password" = "Verify password"; +"Verify speaker password" = "Verify speaker password"; +"Verify wireless password" = "Verify wireless password"; +"Version:" = "Version:"; +"WAN setup over Ethernet" = "WAN setup over Ethernet"; +"WDS Mode must be main, relay, remote, or off." = "WDS Mode must be main, relay, remote, or off."; +"WDS Mode:" = "WDS Mode:"; +"WDS Peers:" = "WDS Peers:"; +"WDS main" = "WDS main"; +"WDS peer AirPort IDs must be one or two MAC addresses." = "WDS peer AirPort IDs must be one or two MAC addresses."; +"WDS relay" = "WDS relay"; +"WDS remote" = "WDS remote"; +"WINS Server must be an IPv4 address." = "WINS Server must be an IPv4 address."; +"WPA Group Key Timeout" = "WPA Group Key Timeout"; +"WPA Group Key Timeout must be between 60 seconds and 24 hours." = "WPA Group Key Timeout must be between 60 seconds and 24 hours."; +"WPA Group Key Timeout:" = "WPA Group Key Timeout:"; +"Wait for the current base station operation to finish, then try again." = "Wait for the current base station operation to finish, then try again."; +"Waiting for this base station to apply its settings and restart…" = "Waiting for this base station to apply its settings and restart…"; +"Waiting for this base station to restart with default settings." = "Waiting for this base station to restart with default settings."; +"Waiting for this base station to restore its default settings and restart…" = "Waiting for this base station to restore its default settings and restart…"; +"Waiting to restore default settings" = "Waiting to restore default settings"; +"What do you want to do with this %@?" = "What do you want to do with this %@?"; +"Window" = "Window"; +"Wireless" = "Wireless"; +"Wireless Network Name" = "Wireless Network Name"; +"Wireless Network Name cannot be empty." = "Wireless Network Name cannot be empty."; +"Wireless Network Name:" = "Wireless Network Name:"; +"Wireless Options" = "Wireless Options"; +"Wireless Options..." = "Wireless Options..."; +"Wireless Password cannot be empty." = "Wireless Password cannot be empty."; +"Wireless Password:" = "Wireless Password:"; +"Wireless Security is not supported." = "Wireless Security is not supported."; +"Wireless Security:" = "Wireless Security:"; +"Wireless extension problem" = "Wireless extension problem"; +"Wireless passwords do not match." = "Wireless passwords do not match."; +"With a disk password" = "With a disk password"; +"With accounts" = "With accounts"; +"With device password" = "With device password"; +"Working" = "Working"; +"Working normally" = "Working normally"; +"Writes over disk data seven times. This option is more secure and takes significantly longer." = "Writes over disk data seven times. This option is more secure and takes significantly longer."; +"Writes over disk data thirty-five times. This option is the most secure and takes the longest." = "Writes over disk data thirty-five times. This option is the most secure and takes the longest."; +"Writes zeros over all data on the disk. This option provides better security than a quick erase, but takes longer." = "Writes zeros over all data on the disk. This option provides better security than a quick erase, but takes longer."; +"Zero Out Data" = "Zero Out Data"; +"Zoom" = "Zoom"; +"day" = "day"; +"file sharing" = "file sharing"; +"firmware update" = "firmware update"; +"hour" = "hour"; +"menu.Edit" = "menu.Edit"; +"minute" = "minute"; +"network" = "network"; +"router address" = "router address"; +"second" = "second"; +"serial number" = "serial number"; +"status" = "status"; +"verified" = "verified"; +"version" = "version"; +"week" = "week"; +"wireless clients" = "wireless clients"; +"“%@” is now available." = "“%@” is now available."; diff --git a/Sources/AirPortUtilityCore/Resources/es.lproj/Localizable.strings b/Sources/AirPortUtilityCore/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..bd274c6 --- /dev/null +++ b/Sources/AirPortUtilityCore/Resources/es.lproj/Localizable.strings @@ -0,0 +1,556 @@ +/* AirPort Utility - es + Keys are the English source strings. Untranslated keys fall back to + English automatically, so this table may be incomplete. + Do NOT add ACP keys, backend flags or persisted raw values here. */ + +"%1$@ used / %2$@" = "%1$@ usados / %2$@"; +"%@ free" = "%@ libres"; +"%@ used" = "%@ usados"; +"0 - Emergency" = "0 – Emergencia"; +"1 - Alert" = "1 – Alerta"; +"1 hour" = "1 hora"; +"15 minutes" = "15 minutos"; +"1st generation" = "1.ª generación"; +"2 - Critical" = "2 – Crítico"; +"2 hours" = "2 horas"; +"24 hours" = "24 horas"; +"2nd generation" = "2.ª generación"; +"3 - Error" = "3 – Error"; +"30 minutes" = "30 minutos"; +"35-Pass Erase" = "Borrado de 35 pasadas"; +"3rd generation" = "3.ª generación"; +"4 - Warning" = "4 – Advertencia"; +"4 hours" = "4 horas"; +"4th generation" = "4.ª generación"; +"5 - Notice" = "5 – Aviso"; +"5th generation" = "5.ª generación"; +"6 - Informational" = "6 – Información"; +"6th generation" = "6.ª generación"; +"7 - Debug" = "7 – Depuración"; +"7-Pass Erase" = "Borrado de 7 pasadas"; +"8 hours" = "8 horas"; +"About AirPort Utility" = "Acerca de Utilidad AirPort"; +"Access Control" = "Control de acceso"; +"Access Control mode is not supported." = "El modo de control de acceso no es compatible."; +"Access Control:" = "Control de acceso:"; +"Access-control descriptions may contain at most 34 UTF-8 bytes." = "Las descripciones de control de acceso no pueden superar los 34 bytes UTF-8."; +"Account Name" = "Nombre de la cuenta"; +"Account Name:" = "Nombre de la cuenta:"; +"Account Password cannot be empty." = "La contraseña de la cuenta no puede estar vacía."; +"Account name" = "Nombre de la cuenta"; +"Account password" = "Contraseña de la cuenta"; +"Account passwords do not match." = "Las contraseñas de la cuenta no coinciden."; +"Accounts:" = "Cuentas:"; +"Add Client" = "Añadir cliente"; +"Add WPS Printer…" = "Añadir impresora WPS…"; +"Add to an existing network" = "Añadir a una red existente"; +"Admin Password" = "Contraseña de administrador"; +"Admin passwords do not match." = "Las contraseñas de administrador no coinciden."; +"Advanced" = "Avanzado"; +"Advanced ACP JSON is not valid JSON." = "El JSON de ACP avanzado no es JSON válido."; +"Advanced ACP JSON must be an object keyed by setting name." = "El JSON de ACP avanzado debe ser un objeto indexado por nombre de ajuste."; +"Advanced ACP JSON must be valid UTF-8." = "El JSON de ACP avanzado debe ser UTF-8 válido."; +"Advanced ACP setting names must be four characters." = "Los nombres de ajuste de ACP avanzado deben tener cuatro caracteres."; +"AirPlay" = "AirPlay"; +"AirPlay Speaker Name cannot be empty." = "El nombre del altavoz AirPlay no puede estar vacío."; +"AirPlay Speaker Name:" = "Nombre del altavoz AirPlay:"; +"AirPlay Speaker Password:" = "Contraseña del altavoz AirPlay:"; +"AirPlay passwords do not match." = "Las contraseñas de AirPlay no coinciden."; +"AirPort Base Station" = "Estación base AirPort"; +"AirPort Configuration" = "Configuración de AirPort"; +"AirPort Express" = "AirPort Express"; +"AirPort Extreme" = "AirPort Extreme"; +"AirPort ID" = "ID de AirPort"; +"AirPort ID:" = "ID de AirPort:"; +"AirPort Time Capsule Disk" = "Disco AirPort Time Capsule"; +"AirPort Utility" = "Utilidad AirPort"; +"AirPort Utility Help" = "Ayuda de Utilidad AirPort"; +"All users will be disconnected from this disk." = "Todos los usuarios se desconectarán de este disco."; +"All wireless clients are allowed to join this network." = "Todos los clientes inalámbricos pueden acceder a esta red."; +"Allow SNMP" = "Permitir SNMP"; +"Allow SNMP over WAN" = "Permitir SNMP a través de WAN"; +"Allow only the wireless clients listed below." = "Permitir solo los clientes inalámbricos indicados a continuación."; +"Allow setup over Ethernet WAN port" = "Permitir configuración a través de WAN"; +"Allow this network to be extended" = "Permitir extender esta red"; +"Alternate" = "Alternativo"; +"Alternate Number:" = "Número alternativo:"; +"Always On" = "Siempre activa"; +"Another router appears to be providing NAT upstream of this base station." = "Parece que otro router está proporcionando NAT antes de esta estación base."; +"Answer on ring must be between 1 and 255." = "Responder al timbre debe estar entre 1 y 255."; +"Answer on ring:" = "Responder al timbre:"; +"Applying Archive Disk" = "Aplicando el archivado del disco"; +"Archive" = "Archivar"; +"Archive Disk" = "Archivar disco"; +"Archive Disk complete." = "Archivado del disco completado."; +"Archive Disk in progress." = "Archivado del disco en curso."; +"Archive Disk started. Waiting for archive to complete." = "Archivado del disco iniciado. Esperando a que finalice."; +"Archive Disk status check timed out." = "Se ha agotado el tiempo de espera al comprobar el estado del archivado."; +"Archive Disk…" = "Archivar disco…"; +"Archive the AirPort Time Capsule disk to back up your data." = "Archiva el disco AirPort Time Capsule para respaldar tus datos."; +"Archiving disk" = "Archivando disco"; +"Are you sure you want to archive the AirPort Time Capsule disk to a disk connected using USB?" = "¿Seguro que quieres archivar el disco AirPort Time Capsule en un disco conectado por USB?"; +"Are you sure you want to erase the AirPort Time Capsule disk?" = "¿Seguro que quieres borrar el disco AirPort Time Capsule?"; +"Automatic" = "Automático"; +"Automatically" = "Automáticamente"; +"Automatically Dial" = "Marcar automáticamente"; +"Available Firmware:" = "Firmware disponible:"; +"Back" = "Atrás"; +"Base Station" = "Estación base"; +"Base Station Name" = "Nombre de estación base"; +"Base Station Name cannot be empty." = "El nombre de la estación base no puede estar vacío."; +"Base Station Name:" = "Nombre de estación base:"; +"Base Station Options" = "Opciones de la estación base"; +"Base Station Password" = "Contraseña de estación base"; +"Base Station Password:" = "Contraseña de estación base:"; +"Base Station to Replace:" = "Estación base que sustituir:"; +"Block incoming IPv6 connections" = "Bloquear conexiones IPv6 entrantes"; +"Bring All to Front" = "Traer todo al frente"; +"Cancel" = "Cancelar"; +"Check for Updates" = "Buscar actualizaciones"; +"Choose a base station" = "Selecciona una estación base"; +"Choose..." = "Seleccionar…"; +"Chosen Firmware" = "Firmware seleccionado"; +"Close" = "Cerrar"; +"Command Log" = "Registro de comandos"; +"Configuration problem" = "Problema de configuración"; +"Configure IPv6" = "Configurar IPv6"; +"Configure IPv6 must be link-local, automatic, or manual." = "Configurar IPv6 debe ser enlace local, automático o manual."; +"Configure IPv6:" = "Configurar IPv6:"; +"Configure Other" = "Configurar otra"; +"Configure Other..." = "Configurar otra…"; +"Configure automatically" = "Configurar automáticamente"; +"Connect" = "Conectar"; +"Connect Using:" = "Conectar mediante:"; +"Connect to Base Station" = "Conectar a la estación base"; +"Connect to Base Station..." = "Conectar a la estación base…"; +"Connected" = "Conectado"; +"Connected to %@" = "Conectado a %@"; +"Connected to %@. Mock mode." = "Conectado a %@. Modo simulado."; +"Connecting to Base Station" = "Conectando a la estación base"; +"Connection:" = "Conexión:"; +"Contact:" = "Contacto:"; +"Continue" = "Continuar"; +"Copy" = "Copiar"; +"Copy compatible settings from another AirPort base station." = "Copiar los ajustes compatibles de otra estación base AirPort."; +"Could not combine legacy settings into one update." = "No se han podido combinar los ajustes heredados en una sola actualización."; +"Could not encode disk account settings." = "No se han podido codificar los ajustes de cuentas del disco."; +"Could not encode local access-control settings." = "No se han podido codificar los ajustes locales de control de acceso."; +"Could not encode the combined legacy settings update." = "No se ha podido codificar la actualización combinada de los ajustes heredados."; +"Could not find the Time Capsule. Check that it is on this network, or enter its IP address instead of the .local name." = "No se ha encontrado la Time Capsule. Comprueba que está en esta red o introduce su dirección IP en lugar del nombre .local."; +"Could not read the base station setup profile." = "No se ha podido leer el perfil de configuración de la estación base."; +"Country Code:" = "Código de país:"; +"Create a new network" = "Crear una red"; +"Create a separate Wi-Fi network using this base station." = "Crear una red Wi-Fi independiente con esta estación base."; +"Create a wireless network" = "Crear una red inalámbrica"; +"Create hidden network" = "Crear una red oculta"; +"Cut" = "Cortar"; +"DHCP Lease cannot be empty." = "La asignación DHCP no puede estar vacía."; +"DHCP Lease duration must be between 1 second and 10 years." = "La duración de la asignación DHCP debe estar entre 1 segundo y 10 años."; +"DHCP Lease must be a positive number." = "La asignación DHCP debe ser un número positivo."; +"DHCP Lease unit is not supported." = "La unidad de asignación DHCP no es compatible."; +"DHCP Lease:" = "Asignación DHCP:"; +"DHCP Message:" = "Mensaje DHCP:"; +"DHCP Only" = "Solo DHCP"; +"DHCP Range Beginning and Ending must use the same supported private subnet, with Ending not before Beginning." = "El inicio y el fin del rango DHCP deben usar la misma subred privada admitida, y el fin no puede ser anterior al inicio."; +"DHCP Range Beginning cannot be empty." = "El inicio del rango DHCP no puede estar vacío."; +"DHCP Range Beginning must be an IPv4 address." = "El inicio del rango DHCP debe ser una dirección IPv4."; +"DHCP Range Ending cannot be empty." = "El fin del rango DHCP no puede estar vacío."; +"DHCP Range Ending must be an IPv4 address." = "El fin del rango DHCP debe ser una dirección IPv4."; +"DHCP Range:" = "Rango DHCP:"; +"DHCP Reservations:" = "Reservas DHCP:"; +"DHCP and NAT" = "DHCP y NAT"; +"DNS Server must be an IPv4 address." = "El servidor DNS debe ser una dirección IPv4."; +"DNS Servers accepts at most two IPv4 DNS servers." = "Servidores DNS admite como máximo dos servidores DNS IPv4."; +"DNS Servers contains an empty value." = "Servidores DNS contiene un valor vacío."; +"DNS Servers:" = "Servidores DNS:"; +"DNS servers" = "servidores DNS"; +"Default" = "Por omisión"; +"Default Host must be an IPv4 address." = "El host por omisión debe ser una dirección IPv4."; +"Default Route:" = "Ruta por omisión:"; +"Default password" = "Contraseña por omisión"; +"Delete" = "Eliminar"; +"Description" = "Descripción"; +"Description:" = "Descripción:"; +"Destination" = "Destino"; +"Destination:" = "Destino:"; +"Dialing:" = "Marcación:"; +"Disconnect if Idle:" = "Desconectar si está inactivo:"; +"Disk Password cannot be empty." = "La contraseña del disco no puede estar vacía."; +"Disk Password:" = "Contraseña del disco:"; +"Disk Sharing" = "Compartición de discos"; +"Disk information is not available yet." = "La información del disco aún no está disponible."; +"Disk needs repair" = "El disco necesita reparación"; +"Disk password" = "Contraseña del disco"; +"Disk passwords do not match." = "Las contraseñas del disco no coinciden."; +"Disk space is low" = "Queda poco espacio en el disco"; +"Disks" = "Discos"; +"Domain Name:" = "Nombre de dominio:"; +"Done" = "OK"; +"Double NAT" = "NAT doble"; +"Downloading from Apple" = "Descargando desde Apple"; +"Dry-run completed without output." = "Simulación completada sin salida."; +"Each local access-control entry must contain a valid MAC address." = "Cada entrada local de control de acceso debe contener una dirección MAC válida."; +"Edit" = "Editar"; +"Enable AirPlay" = "Activar AirPlay"; +"Enable AirPlay over WAN" = "Activar AirPlay a través de WAN"; +"Enable NAT Port Mapping Protocol" = "Activar el protocolo de asignación de puertos NAT"; +"Enable default host at:" = "Activar host por omisión en:"; +"Enable file sharing" = "Activar la compartición de archivos"; +"Enter base station password to load settings." = "Introduce la contraseña de la estación base para cargar los ajustes."; +"Enter names and matching passwords of at least 8 characters." = "Introduce nombres y contraseñas coincidentes de al menos 8 caracteres."; +"Erase" = "Borrar"; +"Erase Disk" = "Borrar disco"; +"Erase Disk…" = "Borrar disco…"; +"Erases directory information so that data is no longer accessible. The data is left unchanged on disk until its disk space is required and it is written over. Data is potentially recoverable until then. This option is the quickest, but least secure." = "Borra la información del directorio para que los datos dejen de ser accesibles. Los datos permanecen intactos en el disco hasta que se necesite su espacio y se sobrescriban. Hasta entonces son potencialmente recuperables. Esta opción es la más rápida, pero la menos segura."; +"Erasing disk" = "Borrando disco"; +"Erasing the AirPort Time Capsule disk deletes all files from the disk." = "Al borrar el disco AirPort Time Capsule se eliminan todos los archivos del disco."; +"Examining the base station…" = "Examinando la estación base…"; +"Export Configuration File" = "Exportar archivo de configuración"; +"Export Configuration File..." = "Exportar archivo de configuración…"; +"Extend a wireless network" = "Extender una red inalámbrica"; +"Failing" = "Fallando"; +"File" = "Archivo"; +"File Sharing Access must be read-write, read-only, or not-allowed." = "El acceso a la compartición de archivos debe ser lectura-escritura, solo lectura o no permitido."; +"File Sharing Access:" = "Acceso a la compartición de archivos:"; +"File sharing account" = "Cuenta de compartición de archivos"; +"Finish Factory Restore" = "Finalizar la restauración de fábrica"; +"Finish editing before refreshing settings." = "Finaliza la edición antes de actualizar los ajustes."; +"Firmware" = "Firmware"; +"Firmware list loaded." = "Lista de firmware cargada."; +"Firmware list loaded. Mock mode." = "Lista de firmware cargada. Modo simulado."; +"Firmware update available" = "Actualización de firmware disponible"; +"Firmware upload accepted. Waiting for restart." = "Carga de firmware aceptada. Esperando el reinicio."; +"Firmware upload completed, but the base station reboot command was not sent." = "La carga del firmware ha finalizado, pero no se ha enviado el comando de reinicio de la estación base."; +"Firmware uploaded. Restart requested." = "Firmware cargado. Reinicio solicitado."; +"Gathering information about your network…" = "Recopilando información sobre tu red…"; +"Generation:" = "Generación:"; +"Global Hostname cannot be empty." = "El nombre de host global no puede estar vacío."; +"Guest Disk Access is not supported." = "El acceso de invitados al disco no es compatible."; +"Help" = "Ayuda"; +"Hide AirPort Utility" = "Ocultar Utilidad AirPort"; +"Hide Others" = "Ocultar otros"; +"Host" = "Host"; +"Host:" = "Host:"; +"Hostname:" = "Nombre de host:"; +"IP Address" = "Dirección IP"; +"IP address" = "dirección IP"; +"IPv4 Address cannot be empty." = "La dirección IPv4 no puede estar vacía."; +"IPv4 Address must be an IPv4 address." = "La dirección IPv4 debe ser una dirección IPv4."; +"IPv4 Address:" = "Dirección IPv4:"; +"IPv4 DHCP Range:" = "Rango DHCP IPv4:"; +"IPv6 Address must be an IPv6 address." = "La dirección IPv6 debe ser una dirección IPv6."; +"IPv6 Address:" = "Dirección IPv6:"; +"IPv6 DNS Server must be an IPv6 address." = "El servidor DNS IPv6 debe ser una dirección IPv6."; +"IPv6 DNS Servers accepts at most two IPv6 DNS servers." = "Servidores DNS IPv6 admite como máximo dos servidores DNS IPv6."; +"IPv6 DNS Servers contains an empty value." = "Servidores DNS IPv6 contiene un valor vacío."; +"IPv6 DNS Servers:" = "Servidores DNS IPv6:"; +"IPv6 Mode" = "Modo IPv6"; +"IPv6 Mode must be host, tunnel, or router." = "El modo IPv6 debe ser host, túnel o router."; +"IPv6 Mode:" = "Modo IPv6:"; +"Identify Base Station" = "Identificar estación base"; +"Idle Disconnect After has an unsupported value." = "Desconectar tras inactividad tiene un valor no admitido."; +"Idle Disconnect After:" = "Desconectar tras inactividad:"; +"Ignore Dial Tone" = "Ignorar el tono de marcado"; +"Ignored identity refresh while editing." = "Se ha ignorado la actualización de identidad durante la edición."; +"Ignored settings refresh while editing." = "Se ha ignorado la actualización de ajustes durante la edición."; +"Import Configuration File" = "Importar archivo de configuración"; +"Import Configuration File..." = "Importar archivo de configuración…"; +"Initial setup has not been marked complete." = "La configuración inicial no se ha marcado como completada."; +"Install" = "Instalar"; +"Internet" = "Internet"; +"Internet Options" = "Opciones de Internet"; +"Internet Options..." = "Opciones de Internet…"; +"Internet inactive" = "Internet inactivo"; +"Internet working normally" = "Internet funciona con normalidad"; +"Join a wireless network" = "Acceder a una red inalámbrica"; +"Join or extend a Wi-Fi network that is already available." = "Acceder a una red Wi-Fi ya disponible o extenderla."; +"LAN IP Address must be an IPv4 address." = "La dirección IP de la LAN debe ser una dirección IPv4."; +"LAN IP Address:" = "Dirección IP de la LAN:"; +"LAN IP address" = "IP de la LAN"; +"LDAP Server:" = "Servidor LDAP:"; +"Link-local only" = "Solo enlace local"; +"Loading Internet Settings" = "Cargando ajustes de Internet"; +"Loading Wireless Clients" = "Cargando clientes inalámbricos"; +"Loading disk information..." = "Cargando información del disco…"; +"Loading firmware list" = "Cargando la lista de firmware"; +"Local" = "Local"; +"Location:" = "Ubicación:"; +"Logging & Statistics" = "Registro"; +"Make sure the storage device has enough space for the archive before connecting it to the base station’s USB Port." = "Asegúrate de que el dispositivo de almacenamiento tenga espacio suficiente para el archivo antes de conectarlo al puerto USB de la estación base."; +"Manually" = "Manualmente"; +"Maximum Connect Time has an unsupported value." = "El tiempo máximo de conexión tiene un valor no admitido."; +"Maximum Connect Time:" = "Tiempo máximo de conexión:"; +"Minimize" = "Minimizar"; +"Mock backend enabled with fixture Time Capsule settings." = "Backend simulado activado con ajustes de prueba de Time Capsule."; +"Mock firmware upload accepted. Restart requested." = "Carga simulada de firmware aceptada. Reinicio solicitado."; +"Mock firmware uploaded." = "Firmware simulado cargado."; +"Mock network scan completed." = "Análisis de red simulado completado."; +"Mock refresh completed." = "Actualización simulada completada."; +"Modem Options" = "Opciones del módem"; +"Modem Options..." = "Opciones del módem…"; +"Modem passwords do not match." = "Las contraseñas del módem no coinciden."; +"Multicast Rate" = "Velocidad de multidifusión"; +"Multicast Rate is not supported." = "La velocidad de multidifusión no es compatible."; +"Multicast Rate:" = "Velocidad de multidifusión:"; +"NAT Only" = "Solo NAT"; +"Name" = "Nombre"; +"Name:" = "Nombre:"; +"Network" = "Red"; +"Network Interfaces" = "Interfaces de red"; +"Network Mode:" = "Modo de red:"; +"Network Name:" = "Nombre de red:"; +"Network Options" = "Opciones de red"; +"Network Options..." = "Opciones de red…"; +"Network Setup" = "Configuración de red"; +"Network name" = "Nombre de red"; +"Never Disconnect" = "No desconectar nunca"; +"New AirPort base station" = "Nueva estación base AirPort"; +"New password" = "Nueva contraseña"; +"New wireless password" = "Nueva contraseña inalámbrica"; +"Next" = "Siguiente"; +"No AirPort base stations discovered" = "No se han detectado estaciones base AirPort"; +"No AirPort disks available" = "No hay discos AirPort disponibles"; +"No Apple firmware images are listed for this base station." = "No hay imágenes de firmware de Apple para esta estación base."; +"No DNS servers configured" = "No hay servidores DNS configurados"; +"No disk information loaded." = "No se ha cargado información del disco."; +"No disk partitions found." = "No se han encontrado particiones en el disco."; +"No firmware image is selected." = "No hay ninguna imagen de firmware seleccionada."; +"No firmware images loaded" = "No se han cargado imágenes de firmware"; +"No new Wi-Fi devices discovered" = "No se han detectado nuevos dispositivos Wi-Fi"; +"No pending Advanced changes to apply." = "No hay cambios avanzados pendientes que aplicar."; +"No pending Advanced changes to preview." = "No hay cambios avanzados pendientes que previsualizar."; +"No pending AirPlay changes to apply." = "No hay cambios de AirPlay pendientes que aplicar."; +"No pending AirPlay changes to preview." = "No hay cambios de AirPlay pendientes que previsualizar."; +"No pending Base Station changes to apply." = "No hay cambios de estación base pendientes que aplicar."; +"No pending Base Station changes to preview." = "No hay cambios de estación base pendientes que previsualizar."; +"No pending Disk Sharing changes to apply." = "No hay cambios de compartición de discos pendientes que aplicar."; +"No pending Disk Sharing changes to preview." = "No hay cambios de compartición de discos pendientes que previsualizar."; +"No pending Internet changes to apply." = "No hay cambios de Internet pendientes que aplicar."; +"No pending Internet changes to preview." = "No hay cambios de Internet pendientes que previsualizar."; +"No pending Network changes to apply." = "No hay cambios de red pendientes que aplicar."; +"No pending Network changes to preview." = "No hay cambios de red pendientes que previsualizar."; +"No pending Wireless changes to apply." = "No hay cambios de inalámbricos pendientes que aplicar."; +"No pending Wireless changes to preview." = "No hay cambios de inalámbricos pendientes que previsualizar."; +"No pending changes to apply." = "No hay cambios pendientes que aplicar."; +"Not Allowed" = "No permitido"; +"Not available" = "No disponible"; +"Not connected" = "Sin conexión"; +"Not enabled" = "No activado"; +"Off" = "Desactivado"; +"Open wireless network" = "Red inalámbrica abierta"; +"Other Options" = "Otras opciones"; +"Other Wi-Fi Devices" = "Otros dispositivos Wi-Fi"; +"PPP Dial-in" = "Acceso PPP"; +"PPP Dial-in is not allowed when configured to connect to the Internet via the Modem or AOL." = "El acceso telefónico PPP no está permitido cuando la conexión a Internet es por módem o AOL."; +"PPP Dial-in is not allowed when configured to share a range of addresses." = "El acceso telefónico PPP no está permitido cuando se comparte un rango de direcciones."; +"PPP Dial-in passwords do not match." = "Las contraseñas de acceso telefónico PPP no coinciden."; +"PPPoE Account Name cannot be empty." = "El nombre de la cuenta PPPoE no puede estar vacío."; +"PPPoE Connection must be always-on, automatic, or manual." = "La conexión PPPoE debe ser siempre activa, automática o manual."; +"Participate in a WDS network" = "Participar en una red WDS"; +"Partitions:" = "Particiones:"; +"Password" = "Contraseña"; +"Password must be at least 8 characters." = "La contraseña debe tener al menos 8 caracteres."; +"Password:" = "Contraseña:"; +"Passwords" = "Contraseñas"; +"Paste" = "Pegar"; +"Phone Number:" = "Número de teléfono:"; +"Port Settings:" = "Ajustes de puertos:"; +"Preferences" = "Preferencias"; +"Preferences..." = "Preferencias…"; +"Primary Port:" = "Puerto principal:"; +"Primary RADIUS Server must be an IPv4 address." = "El servidor RADIUS principal debe ser una dirección IPv4."; +"Primary RADIUS Shared Secret cannot be empty." = "El secreto compartido RADIUS principal no puede estar vacío."; +"Primary RADIUS port must be between 1 and 65535." = "El puerto RADIUS principal debe estar entre 1 y 65535."; +"Primary RADIUS shared secrets do not match." = "Las contraseñas RADIUS principales no coinciden."; +"Primary Server:" = "Servidor principal:"; +"Progress:" = "Progreso:"; +"Protocol:" = "Protocolo:"; +"Pulse" = "Pulsos"; +"Quick Erase (non-secure)" = "Borrado rápido (no seguro)"; +"Quit AirPort Utility" = "Salir de Utilidad AirPort"; +"RADIUS Type:" = "Tipo de RADIUS:"; +"RADIUS type is not supported." = "El tipo de RADIUS no es compatible."; +"Radio Channel" = "Canal de radio"; +"Radio Channel:" = "Canal de radio:"; +"Radio Mode" = "Modo de radio"; +"Radio Mode is not supported." = "El modo de radio no es compatible."; +"Radio Mode:" = "Modo de radio:"; +"Radio channel must be 'automatic' or a channel number." = "El canal de radio debe ser «automático» o un número de canal."; +"Read Only" = "Solo lectura"; +"Read and Write" = "Lectura y escritura"; +"Ready to connect to %@" = "Listo para conectar a %@"; +"Redo" = "Rehacer"; +"Refresh" = "Actualizar"; +"Refresh completed." = "Actualización completada."; +"Refreshing settings" = "Actualizando ajustes"; +"Region" = "Región"; +"Region code must be between 0 and 255." = "El código de región debe estar entre 0 y 255."; +"Region:" = "Región:"; +"Reinstall" = "Reinstalar"; +"Remember this password in my keychain" = "Guardar esta contraseña en mi llavero"; +"Remove access-control entry" = "Eliminar entrada de control de acceso"; +"Renew DHCP Lease" = "Nueva concesión DHCP"; +"Replace an existing device" = "Sustituir un dispositivo existente"; +"Repo" = "Repo"; +"Reports Double NAT despite Bridge Mode." = "Informa de NAT doble a pesar del modo puente."; +"Repository" = "Repositorio"; +"Repository:" = "Repositorio:"; +"Rescanning the network for AirPort base stations." = "Volviendo a analizar la red en busca de estaciones base AirPort."; +"Restart Base Station" = "Reiniciar estación base"; +"Restart Base Station?" = "¿Reiniciar la estación base?"; +"Restart command sent." = "Comando de reinicio enviado."; +"Restart with Default Settings" = "Reiniciar con los ajustes por omisión"; +"Restarting" = "Reiniciando"; +"Restart…" = "Reiniciar…"; +"Restore Default Settings" = "Restaurar los ajustes por omisión"; +"Restore Default Settings..." = "Restaurar los ajustes por omisión…"; +"Restore Default Settings?" = "¿Restaurar los ajustes por omisión?"; +"Restore Factory Defaults" = "Restaurar los valores de fábrica"; +"Restore failed: %@" = "Error al restaurar: %@"; +"Restoring" = "Restaurando"; +"Restoring Base Station…" = "Restaurando la estación base…"; +"Restoring this Base Station to factory defaults erases its settings." = "Restaurar los valores de fábrica de esta estación base borra sus ajustes."; +"Router" = "Router"; +"Router Address cannot be empty." = "La dirección del router no puede estar vacía."; +"Router Address must be an IPv4 address." = "La dirección del router debe ser una dirección IPv4."; +"Router Address:" = "Dirección del router:"; +"Router Mode:" = "Modo del router:"; +"SMART: %@" = "SMART: %@"; +"Save" = "Guardar"; +"Scanning for AirPort base stations…" = "Buscando estaciones base AirPort…"; +"Secondary Port:" = "Puerto secundario:"; +"Secondary RADIUS Server must be an IPv4 address." = "El servidor RADIUS secundario debe ser una dirección IPv4."; +"Secondary RADIUS Shared Secret cannot be empty." = "El secreto compartido RADIUS secundario no puede estar vacío."; +"Secondary RADIUS port must be between 1 and 65535." = "El puerto RADIUS secundario debe estar entre 1 y 65535."; +"Secondary RADIUS shared secrets do not match." = "Las contraseñas RADIUS secundarios no coinciden."; +"Secondary Server:" = "Servidor secundario:"; +"Secure Shared Disks mode is not supported." = "El modo de discos compartidos seguros no es compatible."; +"Secure Shared Disks:" = "Proteger discos compartidos:"; +"Security Method" = "Método de seguridad"; +"Security Method:" = "Método de seguridad:"; +"Select All" = "Seleccionar todo"; +"Service Name:" = "Nombre del servicio:"; +"Services" = "Servicios"; +"Set time automatically" = "Ajustar la hora automáticamente"; +"Set up this %@ to create a new Wi-Fi network." = "Configurar %@ para crear una red Wi-Fi."; +"Setting up this %@…" = "Configurando %@…"; +"Settings" = "Ajustes"; +"Setup" = "Configuración"; +"Setup Complete" = "Configuración completada"; +"Setup Over Ethernet WAN" = "Configuración a través de WAN Ethernet"; +"Setup failed" = "Error de configuración"; +"Setup over the Ethernet WAN port is enabled." = "La configuración a través del puerto WAN Ethernet está activada."; +"Shared Secret:" = "Secreto compartido:"; +"Show All" = "Mostrar todo"; +"Show Passwords…" = "Mostrar contraseñas…"; +"Show connection details in the Other Wi-Fi Devices menu" = "Mostrar los detalles de conexión en el menú Otros dispositivos Wi-Fi"; +"Simple Network Management Protocol (SNMP) allows you to query this device for statistics, including the number of wireless clients." = "El protocolo SNMP (Simple Network Management Protocol) permite consultar estadísticas de este dispositivo, incluido el número de clientes inalámbricos."; +"Speaker name" = "Nombre del altavoz"; +"Speaker password" = "Contraseña del altavoz"; +"Starting download from Apple." = "Iniciando la descarga desde Apple."; +"Starting upload to AirPort." = "Iniciando la carga a AirPort."; +"Subnet Mask cannot be empty." = "La máscara de subred no puede estar vacía."; +"Subnet Mask:" = "Máscara de subred:"; +"Syslog Destination Address must be an IPv4 address." = "La dirección de destino de syslog debe ser una dirección IPv4."; +"Syslog Destination Address:" = "Dirección de destino de syslog:"; +"Syslog Level must be between 0 and 7." = "El nivel de syslog debe estar entre 0 y 7."; +"Syslog Level:" = "Nivel de syslog:"; +"The base station is still using the default admin password." = "La estación base sigue usando la contraseña de administrador por omisión."; +"The base station product ID is not available." = "El ID de producto de la estación base no está disponible."; +"The base station setup profile has not loaded yet." = "El perfil de configuración de la estación base aún no se ha cargado."; +"The base station setup profile has not loaded. Go Back and try again." = "El perfil de configuración de la estación base no se ha cargado. Vuelve atrás e inténtalo de nuevo."; +"The base station setup profile is missing Wi-Fi or timezone settings." = "Al perfil de configuración de la estación base le faltan los ajustes de Wi-Fi o de zona horaria."; +"The device and its network services will be temporarily unavailable. Are you sure you want to continue?" = "El dispositivo y sus servicios de red no estarán disponibles temporalmente. ¿Seguro que quieres continuar?"; +"The wireless network is open and does not require a Wi-Fi password." = "La red inalámbrica está abierta y no requiere contraseña Wi-Fi."; +"This %@ will create a network." = "%@ creará una red."; +"This AirPort wireless device supports log messages that may help diagnose a problem." = "Este dispositivo inalámbrico AirPort admite mensajes de registro que pueden ayudar a diagnosticar un problema."; +"This base station does not support AirPlay." = "Esta estación base no admite AirPlay."; +"This base station does not support a modem connection." = "Esta estación base no admite las conexiones por módem."; +"This base station does not support advanced settings." = "Esta estación base no admite los ajustes avanzados."; +"This base station does not support firmware updates." = "Esta estación base no admite las actualizaciones de firmware."; +"This base station does not support modem options." = "Esta estación base no admite opciones de módem."; +"Time Capsule" = "Time Capsule"; +"Time Server cannot be empty when automatic time is enabled." = "El servidor de hora no puede estar vacío cuando la hora automática está activada."; +"Time Server:" = "Servidor de hora:"; +"Tone" = "Tonos"; +"Transmit Power" = "Potencia de transmisión"; +"Transmit Power is not supported." = "La potencia de transmisión no es compatible."; +"Transmit Power:" = "Potencia de transmisión:"; +"Tunnel" = "Túnel"; +"Type" = "Tipo"; +"Undo" = "Deshacer"; +"Unknown" = "Desconocido"; +"Update" = "Actualizar"; +"Use AOL" = "Usar AOL"; +"Use a single password" = "Usar una sola contraseña"; +"Use dynamic global hostname" = "Usar nombre de host global dinámico"; +"Use interference robustness" = "Usar robustez frente a interferencias"; +"User:" = "Usuario:"; +"Using bundled mock firmware." = "Usando el firmware simulado incluido."; +"Using selected firmware file." = "Usando el archivo de firmware seleccionado."; +"Verified" = "Verificado"; +"Verify Password:" = "Verificar contraseña:"; +"Verify Secret:" = "Verificar secreto:"; +"Verify account password" = "Verificar contraseña de la cuenta"; +"Verify disk password" = "Verificar contraseña del disco"; +"Verify password" = "Verificar contraseña"; +"Verify speaker password" = "Verificar contraseña del altavoz"; +"Verify wireless password" = "Verificar contraseña inalámbrica"; +"Version:" = "Versión:"; +"WAN setup over Ethernet" = "Configuración WAN a través de Ethernet"; +"WDS Mode must be main, relay, remote, or off." = "El modo WDS debe ser principal, retransmisión, remoto o desactivado."; +"WDS Mode:" = "Modo WDS:"; +"WDS Peers:" = "Pares WDS:"; +"WDS main" = "WDS principal"; +"WDS peer AirPort IDs must be one or two MAC addresses." = "Los ID de AirPort de los pares WDS deben ser una o dos direcciones MAC."; +"WDS relay" = "Retransmisión WDS"; +"WDS remote" = "WDS remoto"; +"WINS Server must be an IPv4 address." = "El servidor WINS debe ser una dirección IPv4."; +"WPA Group Key Timeout" = "Tiempo de espera de la clave de grupo WPA"; +"WPA Group Key Timeout must be between 60 seconds and 24 hours." = "El tiempo de espera de la clave de grupo WPA debe estar entre 60 segundos y 24 horas."; +"WPA Group Key Timeout:" = "Caducidad clave grupo WPA:"; +"Wait for the current base station operation to finish, then try again." = "Espera a que finalice la operación actual de la estación base e inténtalo de nuevo."; +"Waiting for this base station to apply its settings and restart…" = "Esperando a que esta estación base aplique sus ajustes y se reinicie…"; +"Waiting for this base station to restart with default settings." = "Esperando a que esta estación base se reinicie con los ajustes por omisión."; +"Waiting for this base station to restore its default settings and restart…" = "Esperando a que esta estación base restaure sus ajustes por omisión y se reinicie…"; +"Waiting to restore default settings" = "Esperando para restaurar los ajustes por omisión"; +"What do you want to do with this %@?" = "¿Qué quieres hacer con %@?"; +"Window" = "Ventana"; +"Wireless" = "Inalámbrico"; +"Wireless Network Name" = "Nombre de la red inalámbrica"; +"Wireless Network Name cannot be empty." = "El nombre de la red inalámbrica no puede estar vacío."; +"Wireless Network Name:" = "Nombre de la red inalámbrica:"; +"Wireless Options" = "Opciones inalámbricas"; +"Wireless Options..." = "Opciones inalámbricas…"; +"Wireless Password cannot be empty." = "La contraseña inalámbrica no puede estar vacía."; +"Wireless Password:" = "Contraseña inalámbrica:"; +"Wireless Security is not supported." = "La seguridad inalámbrica no es compatible."; +"Wireless Security:" = "Seguridad inalámbrica:"; +"Wireless extension problem" = "Problema de extensión inalámbrica"; +"Wireless passwords do not match." = "Las contraseñas inalámbricas no coinciden."; +"With a disk password" = "Con una contraseña de disco"; +"With accounts" = "Con cuentas"; +"With device password" = "Con la contraseña del dispositivo"; +"Working" = "Procesando…"; +"Working normally" = "Funciona con normalidad"; +"Writes over disk data seven times. This option is more secure and takes significantly longer." = "Sobrescribe los datos del disco siete veces. Esta opción es más segura y tarda bastante más."; +"Writes over disk data thirty-five times. This option is the most secure and takes the longest." = "Sobrescribe los datos del disco treinta y cinco veces. Esta opción es la más segura y la que más tarda."; +"Writes zeros over all data on the disk. This option provides better security than a quick erase, but takes longer." = "Escribe ceros sobre todos los datos del disco. Esta opción es más segura que un borrado rápido, pero tarda más."; +"Zero Out Data" = "Rellenar con ceros"; +"Zoom" = "Zoom"; +"day" = "día"; +"file sharing" = "compartición"; +"firmware update" = "actualización de firmware"; +"hour" = "hora"; +"menu.Edit" = "Edición"; +"minute" = "minuto"; +"network" = "red"; +"router address" = "dirección del router"; +"second" = "segundo"; +"serial number" = "número de serie"; +"status" = "estado"; +"verified" = "verificado"; +"version" = "versión"; +"week" = "semana"; +"wireless clients" = "clientes inalámbricos"; +"“%@” is now available." = "«%@» ya está disponible."; diff --git a/Sources/AirPortUtilityCore/Resources/fr.lproj/Localizable.strings b/Sources/AirPortUtilityCore/Resources/fr.lproj/Localizable.strings new file mode 100644 index 0000000..ea19edb --- /dev/null +++ b/Sources/AirPortUtilityCore/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,556 @@ +/* AirPort Utility - fr + Keys are the English source strings. Untranslated keys fall back to + English automatically, so this table may be incomplete. + Do NOT add ACP keys, backend flags or persisted raw values here. */ + +"%1$@ used / %2$@" = "%1$@ utilisés / %2$@"; +"%@ free" = "%@ libres"; +"%@ used" = "%@ utilisés"; +"0 - Emergency" = "0 – Urgence"; +"1 - Alert" = "1 – Alerte"; +"1 hour" = "1 heure"; +"15 minutes" = "15 minutes"; +"1st generation" = "1re génération"; +"2 - Critical" = "2 – Critique"; +"2 hours" = "2 heures"; +"24 hours" = "24 heures"; +"2nd generation" = "2e génération"; +"3 - Error" = "3 – Erreur"; +"30 minutes" = "30 minutes"; +"35-Pass Erase" = "Effacement en 35 passes"; +"3rd generation" = "3e génération"; +"4 - Warning" = "4 – Avertissement"; +"4 hours" = "4 heures"; +"4th generation" = "4e génération"; +"5 - Notice" = "5 – Remarque"; +"5th generation" = "5e génération"; +"6 - Informational" = "6 – Information"; +"6th generation" = "6e génération"; +"7 - Debug" = "7 – Débogage"; +"7-Pass Erase" = "Effacement en 7 passes"; +"8 hours" = "8 heures"; +"About AirPort Utility" = "À propos d’Utilitaire AirPort"; +"Access Control" = "Contrôle d’accès"; +"Access Control mode is not supported." = "Le mode de contrôle d’accès n’est pas pris en charge."; +"Access Control:" = "Contrôle d’accès :"; +"Access-control descriptions may contain at most 34 UTF-8 bytes." = "Les descriptions de contrôle d’accès ne peuvent pas dépasser 34 octets UTF-8."; +"Account Name" = "Nom du compte"; +"Account Name:" = "Nom du compte :"; +"Account Password cannot be empty." = "Le mot de passe du compte ne peut pas être vide."; +"Account name" = "Nom du compte"; +"Account password" = "Mot de passe du compte"; +"Account passwords do not match." = "Les mots de passe du compte ne correspondent pas."; +"Accounts:" = "Comptes :"; +"Add Client" = "Ajouter un client"; +"Add WPS Printer…" = "Ajouter une imprimante WPS…"; +"Add to an existing network" = "Ajouter à un réseau existant"; +"Admin Password" = "Mot de passe administrateur"; +"Admin passwords do not match." = "Les mots de passe administrateur ne correspondent pas."; +"Advanced" = "Avancé"; +"Advanced ACP JSON is not valid JSON." = "Le JSON ACP avancé n’est pas un JSON valide."; +"Advanced ACP JSON must be an object keyed by setting name." = "Le JSON ACP avancé doit être un objet indexé par nom de réglage."; +"Advanced ACP JSON must be valid UTF-8." = "Le JSON ACP avancé doit être en UTF-8 valide."; +"Advanced ACP setting names must be four characters." = "Les noms de réglages ACP avancés doivent comporter quatre caractères."; +"AirPlay" = "AirPlay"; +"AirPlay Speaker Name cannot be empty." = "Le nom du haut-parleur AirPlay ne peut pas être vide."; +"AirPlay Speaker Name:" = "Nom du haut-parleur AirPlay :"; +"AirPlay Speaker Password:" = "Mot de passe du haut-parleur AirPlay :"; +"AirPlay passwords do not match." = "Les mots de passe AirPlay ne correspondent pas."; +"AirPort Base Station" = "Borne d’accès AirPort"; +"AirPort Configuration" = "Configuration AirPort"; +"AirPort Express" = "AirPort Express"; +"AirPort Extreme" = "AirPort Extreme"; +"AirPort ID" = "Identifiant AirPort"; +"AirPort ID:" = "Identifiant AirPort :"; +"AirPort Time Capsule Disk" = "Disque AirPort Time Capsule"; +"AirPort Utility" = "Utilitaire AirPort"; +"AirPort Utility Help" = "Aide Utilitaire AirPort"; +"All users will be disconnected from this disk." = "Tous les utilisateurs seront déconnectés de ce disque."; +"All wireless clients are allowed to join this network." = "Tous les clients sans fil sont autorisés à rejoindre ce réseau."; +"Allow SNMP" = "Autoriser SNMP"; +"Allow SNMP over WAN" = "Autoriser SNMP via le WAN"; +"Allow only the wireless clients listed below." = "N’autoriser que les clients sans fil listés ci-dessous."; +"Allow setup over Ethernet WAN port" = "Autoriser la configuration via WAN"; +"Allow this network to be extended" = "Autoriser l’extension de ce réseau"; +"Alternate" = "Secondaire"; +"Alternate Number:" = "Numéro secondaire :"; +"Always On" = "Toujours active"; +"Another router appears to be providing NAT upstream of this base station." = "Un autre routeur semble fournir le NAT en amont de cette borne d’accès."; +"Answer on ring must be between 1 and 255." = "La réponse à la sonnerie doit être comprise entre 1 et 255."; +"Answer on ring:" = "Répondre à la sonnerie :"; +"Applying Archive Disk" = "Application de l’archivage du disque"; +"Archive" = "Archiver"; +"Archive Disk" = "Archiver le disque"; +"Archive Disk complete." = "Archivage du disque terminé."; +"Archive Disk in progress." = "Archivage du disque en cours."; +"Archive Disk started. Waiting for archive to complete." = "Archivage du disque démarré. En attente de la fin de l’archivage."; +"Archive Disk status check timed out." = "Délai dépassé lors de la vérification de l’état de l’archivage."; +"Archive Disk…" = "Archiver le disque…"; +"Archive the AirPort Time Capsule disk to back up your data." = "Archivez le disque AirPort Time Capsule pour sauvegarder vos données."; +"Archiving disk" = "Archivage du disque"; +"Are you sure you want to archive the AirPort Time Capsule disk to a disk connected using USB?" = "Voulez-vous vraiment archiver le disque AirPort Time Capsule sur un disque connecté en USB ?"; +"Are you sure you want to erase the AirPort Time Capsule disk?" = "Voulez-vous vraiment effacer le disque AirPort Time Capsule ?"; +"Automatic" = "Automatique"; +"Automatically" = "Automatiquement"; +"Automatically Dial" = "Composition automatique"; +"Available Firmware:" = "Micrologiciel disponible :"; +"Back" = "Retour"; +"Base Station" = "Borne d’accès"; +"Base Station Name" = "Nom de la borne d’accès"; +"Base Station Name cannot be empty." = "Le nom de la borne d’accès ne peut pas être vide."; +"Base Station Name:" = "Nom de la borne d’accès :"; +"Base Station Options" = "Options de la borne d’accès"; +"Base Station Password" = "Mot de passe de la borne d’accès"; +"Base Station Password:" = "Mot de passe de la borne :"; +"Base Station to Replace:" = "Borne d’accès à remplacer :"; +"Block incoming IPv6 connections" = "Bloquer les connexions IPv6 entrantes"; +"Bring All to Front" = "Tout ramener au premier plan"; +"Cancel" = "Annuler"; +"Check for Updates" = "Rechercher des mises à jour"; +"Choose a base station" = "Choisir une borne d’accès"; +"Choose..." = "Choisir…"; +"Chosen Firmware" = "Micrologiciel choisi"; +"Close" = "Fermer"; +"Command Log" = "Journal des commandes"; +"Configuration problem" = "Problème de configuration"; +"Configure IPv6" = "Configurer IPv6"; +"Configure IPv6 must be link-local, automatic, or manual." = "Configurer IPv6 doit être lien-local, automatique ou manuel."; +"Configure IPv6:" = "Configurer IPv6 :"; +"Configure Other" = "Configurer un autre appareil"; +"Configure Other..." = "Configurer un autre appareil…"; +"Configure automatically" = "Configurer automatiquement"; +"Connect" = "Se connecter"; +"Connect Using:" = "Connexion via :"; +"Connect to Base Station" = "Se connecter à la borne d’accès"; +"Connect to Base Station..." = "Se connecter à la borne d’accès…"; +"Connected" = "Connecté"; +"Connected to %@" = "Connecté à %@"; +"Connected to %@. Mock mode." = "Connecté à %@. Mode simulé."; +"Connecting to Base Station" = "Connexion à la borne d’accès"; +"Connection:" = "Connexion :"; +"Contact:" = "Contact :"; +"Continue" = "Continuer"; +"Copy" = "Copier"; +"Copy compatible settings from another AirPort base station." = "Copier les réglages compatibles depuis une autre borne d’accès AirPort."; +"Could not combine legacy settings into one update." = "Impossible de combiner les réglages hérités en une seule mise à jour."; +"Could not encode disk account settings." = "Impossible d’encoder les réglages des comptes de disque."; +"Could not encode local access-control settings." = "Impossible d’encoder les réglages locaux de contrôle d’accès."; +"Could not encode the combined legacy settings update." = "Impossible d’encoder la mise à jour combinée des réglages hérités."; +"Could not find the Time Capsule. Check that it is on this network, or enter its IP address instead of the .local name." = "Time Capsule introuvable. Vérifiez qu’elle est sur ce réseau, ou saisissez son adresse IP au lieu du nom .local."; +"Could not read the base station setup profile." = "Impossible de lire le profil de configuration de la borne d’accès."; +"Country Code:" = "Indicatif du pays :"; +"Create a new network" = "Créer un réseau"; +"Create a separate Wi-Fi network using this base station." = "Créer un réseau Wi-Fi distinct avec cette borne d’accès."; +"Create a wireless network" = "Créer un réseau sans fil"; +"Create hidden network" = "Créer un réseau masqué"; +"Cut" = "Couper"; +"DHCP Lease cannot be empty." = "Le bail DHCP ne peut pas être vide."; +"DHCP Lease duration must be between 1 second and 10 years." = "La durée du bail DHCP doit être comprise entre 1 seconde et 10 ans."; +"DHCP Lease must be a positive number." = "Le bail DHCP doit être un nombre positif."; +"DHCP Lease unit is not supported." = "L’unité du bail DHCP n’est pas pris en charge."; +"DHCP Lease:" = "Bail DHCP :"; +"DHCP Message:" = "Message DHCP :"; +"DHCP Only" = "DHCP uniquement"; +"DHCP Range Beginning and Ending must use the same supported private subnet, with Ending not before Beginning." = "Le début et la fin de la plage DHCP doivent utiliser le même sous-réseau privé pris en charge, la fin ne pouvant précéder le début."; +"DHCP Range Beginning cannot be empty." = "Le début de la plage DHCP ne peut pas être vide."; +"DHCP Range Beginning must be an IPv4 address." = "Le début de la plage DHCP doit être une adresse IPv4."; +"DHCP Range Ending cannot be empty." = "La fin de la plage DHCP ne peut pas être vide."; +"DHCP Range Ending must be an IPv4 address." = "La fin de la plage DHCP doit être une adresse IPv4."; +"DHCP Range:" = "Plage DHCP :"; +"DHCP Reservations:" = "Réservations DHCP :"; +"DHCP and NAT" = "DHCP et NAT"; +"DNS Server must be an IPv4 address." = "Le serveur DNS doit être une adresse IPv4."; +"DNS Servers accepts at most two IPv4 DNS servers." = "Serveurs DNS accepte au maximum deux serveurs DNS IPv4."; +"DNS Servers contains an empty value." = "Serveurs DNS contient une valeur vide."; +"DNS Servers:" = "Serveurs DNS :"; +"DNS servers" = "serveurs DNS"; +"Default" = "Par défaut"; +"Default Host must be an IPv4 address." = "L’hôte par défaut doit être une adresse IPv4."; +"Default Route:" = "Route par défaut :"; +"Default password" = "Mot de passe par défaut"; +"Delete" = "Supprimer"; +"Description" = "Description"; +"Description:" = "Description :"; +"Destination" = "Destination"; +"Destination:" = "Destination :"; +"Dialing:" = "Numérotation :"; +"Disconnect if Idle:" = "Déconnecter si inactif :"; +"Disk Password cannot be empty." = "Le mot de passe du disque ne peut pas être vide."; +"Disk Password:" = "Mot de passe du disque :"; +"Disk Sharing" = "Partage de disque"; +"Disk information is not available yet." = "Les informations du disque ne sont pas encore disponibles."; +"Disk needs repair" = "Le disque doit être réparé"; +"Disk password" = "Mot de passe du disque"; +"Disk passwords do not match." = "Les mots de passe du disque ne correspondent pas."; +"Disk space is low" = "Espace disque faible"; +"Disks" = "Disques"; +"Domain Name:" = "Nom de domaine :"; +"Done" = "Terminé"; +"Double NAT" = "Double NAT"; +"Downloading from Apple" = "Téléchargement depuis Apple"; +"Dry-run completed without output." = "Simulation terminée sans sortie."; +"Each local access-control entry must contain a valid MAC address." = "Chaque entrée locale de contrôle d’accès doit contenir une adresse MAC valide."; +"Edit" = "Modifier"; +"Enable AirPlay" = "Activer AirPlay"; +"Enable AirPlay over WAN" = "Activer AirPlay via le WAN"; +"Enable NAT Port Mapping Protocol" = "Activer le protocole NAT-PMP"; +"Enable default host at:" = "Activer l’hôte par défaut à :"; +"Enable file sharing" = "Activer le partage de fichiers"; +"Enter base station password to load settings." = "Saisissez le mot de passe de la borne d’accès pour charger les réglages."; +"Enter names and matching passwords of at least 8 characters." = "Saisissez des noms et des mots de passe correspondants d’au moins 8 caractères."; +"Erase" = "Effacer"; +"Erase Disk" = "Effacer le disque"; +"Erase Disk…" = "Effacer le disque…"; +"Erases directory information so that data is no longer accessible. The data is left unchanged on disk until its disk space is required and it is written over. Data is potentially recoverable until then. This option is the quickest, but least secure." = "Efface les informations de répertoire afin que les données ne soient plus accessibles. Les données restent inchangées sur le disque jusqu’à ce que leur espace soit requis et réécrit. Elles restent potentiellement récupérables jusque-là. Cette option est la plus rapide, mais la moins sûre."; +"Erasing disk" = "Effacement du disque"; +"Erasing the AirPort Time Capsule disk deletes all files from the disk." = "L’effacement du disque AirPort Time Capsule supprime tous les fichiers qu’il contient."; +"Examining the base station…" = "Examen de la borne d’accès…"; +"Export Configuration File" = "Exporter le fichier de configuration"; +"Export Configuration File..." = "Exporter le fichier de configuration…"; +"Extend a wireless network" = "Étendre un réseau sans fil"; +"Failing" = "Défaillant"; +"File" = "Fichier"; +"File Sharing Access must be read-write, read-only, or not-allowed." = "L’accès au partage de fichiers doit être lecture-écriture, lecture seule ou non autorisé."; +"File Sharing Access:" = "Accès au partage de fichiers :"; +"File sharing account" = "Compte de partage de fichiers"; +"Finish Factory Restore" = "Terminer la restauration d’usine"; +"Finish editing before refreshing settings." = "Terminez la modification avant d’actualiser les réglages."; +"Firmware" = "Micrologiciel"; +"Firmware list loaded." = "Liste des micrologiciels chargée."; +"Firmware list loaded. Mock mode." = "Liste des micrologiciels chargée. Mode simulé."; +"Firmware update available" = "Mise à jour du micrologiciel disponible"; +"Firmware upload accepted. Waiting for restart." = "Envoi du micrologiciel accepté. En attente du redémarrage."; +"Firmware upload completed, but the base station reboot command was not sent." = "L’envoi du micrologiciel est terminé, mais la commande de redémarrage de la borne d’accès n’a pas été envoyée."; +"Firmware uploaded. Restart requested." = "Micrologiciel envoyé. Redémarrage demandé."; +"Gathering information about your network…" = "Collecte d’informations sur votre réseau…"; +"Generation:" = "Génération :"; +"Global Hostname cannot be empty." = "Le nom d’hôte global ne peut pas être vide."; +"Guest Disk Access is not supported." = "L’accès invité au disque n’est pas pris en charge."; +"Help" = "Aide"; +"Hide AirPort Utility" = "Masquer Utilitaire AirPort"; +"Hide Others" = "Masquer les autres"; +"Host" = "Hôte"; +"Host:" = "Hôte :"; +"Hostname:" = "Nom d’hôte :"; +"IP Address" = "Adresse IP"; +"IP address" = "adresse IP"; +"IPv4 Address cannot be empty." = "L’adresse IPv4 ne peut pas être vide."; +"IPv4 Address must be an IPv4 address." = "L’adresse IPv4 doit être une adresse IPv4."; +"IPv4 Address:" = "Adresse IPv4 :"; +"IPv4 DHCP Range:" = "Plage DHCP IPv4 :"; +"IPv6 Address must be an IPv6 address." = "L’adresse IPv6 doit être une adresse IPv6."; +"IPv6 Address:" = "Adresse IPv6 :"; +"IPv6 DNS Server must be an IPv6 address." = "Le serveur DNS IPv6 doit être une adresse IPv6."; +"IPv6 DNS Servers accepts at most two IPv6 DNS servers." = "Serveurs DNS IPv6 accepte au maximum deux serveurs DNS IPv6."; +"IPv6 DNS Servers contains an empty value." = "Serveurs DNS IPv6 contient une valeur vide."; +"IPv6 DNS Servers:" = "Serveurs DNS IPv6 :"; +"IPv6 Mode" = "Mode IPv6"; +"IPv6 Mode must be host, tunnel, or router." = "Le mode IPv6 doit être hôte, tunnel ou routeur."; +"IPv6 Mode:" = "Mode IPv6 :"; +"Identify Base Station" = "Identifier la borne d’accès"; +"Idle Disconnect After has an unsupported value." = "Déconnexion après inactivité a une valeur non prise en charge."; +"Idle Disconnect After:" = "Déconnexion après inactivité :"; +"Ignore Dial Tone" = "Ignorer la tonalité"; +"Ignored identity refresh while editing." = "Actualisation de l’identité ignorée pendant la modification."; +"Ignored settings refresh while editing." = "Actualisation des réglages ignorée pendant la modification."; +"Import Configuration File" = "Importer un fichier de configuration"; +"Import Configuration File..." = "Importer un fichier de configuration…"; +"Initial setup has not been marked complete." = "La configuration initiale n’a pas été marquée comme terminée."; +"Install" = "Installer"; +"Internet" = "Internet"; +"Internet Options" = "Options Internet"; +"Internet Options..." = "Options Internet…"; +"Internet inactive" = "Internet inactif"; +"Internet working normally" = "Internet fonctionne normalement"; +"Join a wireless network" = "Rejoindre un réseau sans fil"; +"Join or extend a Wi-Fi network that is already available." = "Rejoindre ou étendre un réseau Wi-Fi déjà disponible."; +"LAN IP Address must be an IPv4 address." = "L’adresse IP du réseau local doit être une adresse IPv4."; +"LAN IP Address:" = "Adresse IP du réseau local :"; +"LAN IP address" = "IP réseau local"; +"LDAP Server:" = "Serveur LDAP :"; +"Link-local only" = "Lien-local uniquement"; +"Loading Internet Settings" = "Chargement des réglages Internet"; +"Loading Wireless Clients" = "Chargement des clients sans fil"; +"Loading disk information..." = "Chargement des informations du disque…"; +"Loading firmware list" = "Chargement de la liste des micrologiciels"; +"Local" = "Local"; +"Location:" = "Emplacement :"; +"Logging & Statistics" = "Journalisation"; +"Make sure the storage device has enough space for the archive before connecting it to the base station’s USB Port." = "Assurez-vous que le périphérique de stockage dispose de suffisamment d’espace pour l’archive avant de le connecter au port USB de la borne d’accès."; +"Manually" = "Manuellement"; +"Maximum Connect Time has an unsupported value." = "La durée de connexion maximale a une valeur non prise en charge."; +"Maximum Connect Time:" = "Durée de connexion maximale :"; +"Minimize" = "Réduire"; +"Mock backend enabled with fixture Time Capsule settings." = "Backend simulé activé avec des réglages Time Capsule de test."; +"Mock firmware upload accepted. Restart requested." = "Envoi simulé du micrologiciel accepté. Redémarrage demandé."; +"Mock firmware uploaded." = "Micrologiciel simulé envoyé."; +"Mock network scan completed." = "Analyse réseau simulée terminée."; +"Mock refresh completed." = "Actualisation simulée terminée."; +"Modem Options" = "Options du modem"; +"Modem Options..." = "Options du modem…"; +"Modem passwords do not match." = "Les mots de passe du modem ne correspondent pas."; +"Multicast Rate" = "Débit multicast"; +"Multicast Rate is not supported." = "Le débit multicast n’est pas pris en charge."; +"Multicast Rate:" = "Débit multicast :"; +"NAT Only" = "NAT uniquement"; +"Name" = "Nom"; +"Name:" = "Nom :"; +"Network" = "Réseau"; +"Network Interfaces" = "Interfaces réseau"; +"Network Mode:" = "Mode réseau :"; +"Network Name:" = "Nom du réseau :"; +"Network Options" = "Options réseau"; +"Network Options..." = "Options réseau…"; +"Network Setup" = "Configuration du réseau"; +"Network name" = "Nom du réseau"; +"Never Disconnect" = "Ne jamais déconnecter"; +"New AirPort base station" = "Nouvelle borne d’accès AirPort"; +"New password" = "Nouveau mot de passe"; +"New wireless password" = "Nouveau mot de passe sans fil"; +"Next" = "Suivant"; +"No AirPort base stations discovered" = "Aucune borne d’accès AirPort détectée"; +"No AirPort disks available" = "Aucun disque AirPort disponible"; +"No Apple firmware images are listed for this base station." = "Aucune image de micrologiciel Apple n’est répertoriée pour cette borne d’accès."; +"No DNS servers configured" = "Aucun serveur DNS configuré"; +"No disk information loaded." = "Aucune information de disque chargée."; +"No disk partitions found." = "Aucune partition de disque trouvée."; +"No firmware image is selected." = "Aucune image de micrologiciel sélectionnée."; +"No firmware images loaded" = "Aucune image de micrologiciel chargée"; +"No new Wi-Fi devices discovered" = "Aucun nouvel appareil Wi-Fi détecté"; +"No pending Advanced changes to apply." = "Aucune modification avancée en attente à appliquer."; +"No pending Advanced changes to preview." = "Aucune modification avancée en attente à prévisualiser."; +"No pending AirPlay changes to apply." = "Aucune modification AirPlay en attente à appliquer."; +"No pending AirPlay changes to preview." = "Aucune modification AirPlay en attente à prévisualiser."; +"No pending Base Station changes to apply." = "Aucune modification borne d’accès en attente à appliquer."; +"No pending Base Station changes to preview." = "Aucune modification borne d’accès en attente à prévisualiser."; +"No pending Disk Sharing changes to apply." = "Aucune modification de partage de disque en attente à appliquer."; +"No pending Disk Sharing changes to preview." = "Aucune modification de partage de disque en attente à prévisualiser."; +"No pending Internet changes to apply." = "Aucune modification Internet en attente à appliquer."; +"No pending Internet changes to preview." = "Aucune modification Internet en attente à prévisualiser."; +"No pending Network changes to apply." = "Aucune modification réseau en attente à appliquer."; +"No pending Network changes to preview." = "Aucune modification réseau en attente à prévisualiser."; +"No pending Wireless changes to apply." = "Aucune modification sans fil en attente à appliquer."; +"No pending Wireless changes to preview." = "Aucune modification sans fil en attente à prévisualiser."; +"No pending changes to apply." = "Aucune modification en attente à appliquer."; +"Not Allowed" = "Non autorisé"; +"Not available" = "Non disponible"; +"Not connected" = "Non connecté"; +"Not enabled" = "Non activé"; +"Off" = "Désactivé"; +"Open wireless network" = "Réseau sans fil ouvert"; +"Other Options" = "Autres options"; +"Other Wi-Fi Devices" = "Autres appareils Wi-Fi"; +"PPP Dial-in" = "Connexion PPP"; +"PPP Dial-in is not allowed when configured to connect to the Internet via the Modem or AOL." = "La connexion PPP entrante n’est pas autorisée lorsque la connexion à Internet se fait via le modem ou AOL."; +"PPP Dial-in is not allowed when configured to share a range of addresses." = "La connexion PPP entrante n’est pas autorisée lorsqu’une plage d’adresses est partagée."; +"PPP Dial-in passwords do not match." = "Les mots de passe de connexion PPP entrante ne correspondent pas."; +"PPPoE Account Name cannot be empty." = "Le nom du compte PPPoE ne peut pas être vide."; +"PPPoE Connection must be always-on, automatic, or manual." = "La connexion PPPoE doit être toujours active, automatique ou manuelle."; +"Participate in a WDS network" = "Participer à un réseau WDS"; +"Partitions:" = "Partitions :"; +"Password" = "Mot de passe"; +"Password must be at least 8 characters." = "Le mot de passe doit comporter au moins 8 caractères."; +"Password:" = "Mot de passe :"; +"Passwords" = "Mots de passe"; +"Paste" = "Coller"; +"Phone Number:" = "Numéro de téléphone :"; +"Port Settings:" = "Réglages des ports :"; +"Preferences" = "Préférences"; +"Preferences..." = "Préférences…"; +"Primary Port:" = "Port principal :"; +"Primary RADIUS Server must be an IPv4 address." = "Le serveur RADIUS principal doit être une adresse IPv4."; +"Primary RADIUS Shared Secret cannot be empty." = "Le secret partagé RADIUS principal ne peut pas être vide."; +"Primary RADIUS port must be between 1 and 65535." = "Le port RADIUS principal doit être compris entre 1 et 65535."; +"Primary RADIUS shared secrets do not match." = "Les mots de passe RADIUS principaux ne correspondent pas."; +"Primary Server:" = "Serveur principal :"; +"Progress:" = "Progression :"; +"Protocol:" = "Protocole :"; +"Pulse" = "Impulsions"; +"Quick Erase (non-secure)" = "Effacement rapide (non sécurisé)"; +"Quit AirPort Utility" = "Quitter Utilitaire AirPort"; +"RADIUS Type:" = "Type RADIUS :"; +"RADIUS type is not supported." = "Le type RADIUS n’est pas pris en charge."; +"Radio Channel" = "Canal radio"; +"Radio Channel:" = "Canal radio :"; +"Radio Mode" = "Mode radio"; +"Radio Mode is not supported." = "Le mode radio n’est pas pris en charge."; +"Radio Mode:" = "Mode radio :"; +"Radio channel must be 'automatic' or a channel number." = "Le canal radio doit être « automatique » ou un numéro de canal."; +"Read Only" = "Lecture seule"; +"Read and Write" = "Lecture et écriture"; +"Ready to connect to %@" = "Prêt à se connecter à %@"; +"Redo" = "Rétablir"; +"Refresh" = "Actualiser"; +"Refresh completed." = "Actualisation terminée."; +"Refreshing settings" = "Actualisation des réglages"; +"Region" = "Région"; +"Region code must be between 0 and 255." = "Le code de région doit être compris entre 0 et 255."; +"Region:" = "Région :"; +"Reinstall" = "Réinstaller"; +"Remember this password in my keychain" = "Conserver dans mon trousseau"; +"Remove access-control entry" = "Supprimer l’entrée de contrôle d’accès"; +"Renew DHCP Lease" = "Renouveler le bail DHCP"; +"Replace an existing device" = "Remplacer un appareil existant"; +"Repo" = "Réf."; +"Reports Double NAT despite Bridge Mode." = "Signale un double NAT malgré le mode pont."; +"Repository" = "Référentiel"; +"Repository:" = "Référentiel :"; +"Rescanning the network for AirPort base stations." = "Nouvelle analyse du réseau à la recherche de bornes d’accès AirPort."; +"Restart Base Station" = "Redémarrer la borne d’accès"; +"Restart Base Station?" = "Redémarrer la borne d’accès ?"; +"Restart command sent." = "Commande de redémarrage envoyée."; +"Restart with Default Settings" = "Redémarrer avec les réglages par défaut"; +"Restarting" = "Redémarrage"; +"Restart…" = "Redémarrer…"; +"Restore Default Settings" = "Rétablir les réglages par défaut"; +"Restore Default Settings..." = "Rétablir les réglages par défaut…"; +"Restore Default Settings?" = "Rétablir les réglages par défaut ?"; +"Restore Factory Defaults" = "Rétablir les réglages d’usine"; +"Restore failed: %@" = "Échec de la restauration : %@"; +"Restoring" = "Restauration"; +"Restoring Base Station…" = "Restauration de la borne d’accès…"; +"Restoring this Base Station to factory defaults erases its settings." = "Rétablir les réglages d’usine de cette borne d’accès efface ses réglages."; +"Router" = "Routeur"; +"Router Address cannot be empty." = "L’adresse du routeur ne peut pas être vide."; +"Router Address must be an IPv4 address." = "L’adresse du routeur doit être une adresse IPv4."; +"Router Address:" = "Adresse du routeur :"; +"Router Mode:" = "Mode du routeur :"; +"SMART: %@" = "SMART : %@"; +"Save" = "Enregistrer"; +"Scanning for AirPort base stations…" = "Recherche de bornes d’accès AirPort…"; +"Secondary Port:" = "Port secondaire :"; +"Secondary RADIUS Server must be an IPv4 address." = "Le serveur RADIUS secondaire doit être une adresse IPv4."; +"Secondary RADIUS Shared Secret cannot be empty." = "Le secret partagé RADIUS secondaire ne peut pas être vide."; +"Secondary RADIUS port must be between 1 and 65535." = "Le port RADIUS secondaire doit être compris entre 1 et 65535."; +"Secondary RADIUS shared secrets do not match." = "Les mots de passe RADIUS secondaires ne correspondent pas."; +"Secondary Server:" = "Serveur secondaire :"; +"Secure Shared Disks mode is not supported." = "Le mode de sécurisation des disques partagés n’est pas pris en charge."; +"Secure Shared Disks:" = "Sécuriser les disques partagés :"; +"Security Method" = "Méthode de sécurité"; +"Security Method:" = "Méthode de sécurité :"; +"Select All" = "Tout sélectionner"; +"Service Name:" = "Nom du service :"; +"Services" = "Services"; +"Set time automatically" = "Régler l’heure automatiquement"; +"Set up this %@ to create a new Wi-Fi network." = "Configurer %@ pour créer un réseau Wi-Fi."; +"Setting up this %@…" = "Configuration de %@…"; +"Settings" = "Réglages"; +"Setup" = "Configuration"; +"Setup Complete" = "Configuration terminée"; +"Setup Over Ethernet WAN" = "Configuration via le WAN Ethernet"; +"Setup failed" = "Échec de la configuration"; +"Setup over the Ethernet WAN port is enabled." = "La configuration via le port WAN Ethernet est activée."; +"Shared Secret:" = "Secret partagé :"; +"Show All" = "Tout afficher"; +"Show Passwords…" = "Afficher les mots de passe…"; +"Show connection details in the Other Wi-Fi Devices menu" = "Afficher les détails de connexion dans le menu Autres appareils Wi-Fi"; +"Simple Network Management Protocol (SNMP) allows you to query this device for statistics, including the number of wireless clients." = "Le protocole SNMP (Simple Network Management Protocol) permet d’interroger cet appareil pour obtenir des statistiques, y compris le nombre de clients sans fil."; +"Speaker name" = "Nom du haut-parleur"; +"Speaker password" = "Mot de passe du haut-parleur"; +"Starting download from Apple." = "Démarrage du téléchargement depuis Apple."; +"Starting upload to AirPort." = "Démarrage de l’envoi vers AirPort."; +"Subnet Mask cannot be empty." = "Le masque de sous-réseau ne peut pas être vide."; +"Subnet Mask:" = "Masque de sous-réseau :"; +"Syslog Destination Address must be an IPv4 address." = "L’adresse de destination syslog doit être une adresse IPv4."; +"Syslog Destination Address:" = "Adresse de destination syslog :"; +"Syslog Level must be between 0 and 7." = "Le niveau syslog doit être compris entre 0 et 7."; +"Syslog Level:" = "Niveau syslog :"; +"The base station is still using the default admin password." = "La borne d’accès utilise encore le mot de passe administrateur par défaut."; +"The base station product ID is not available." = "L’identifiant de produit de la borne d’accès n’est pas disponible."; +"The base station setup profile has not loaded yet." = "Le profil de configuration de la borne d’accès n’est pas encore chargé."; +"The base station setup profile has not loaded. Go Back and try again." = "Le profil de configuration de la borne d’accès n’est pas chargé. Revenez en arrière et réessayez."; +"The base station setup profile is missing Wi-Fi or timezone settings." = "Le profil de configuration de la borne d’accès ne contient pas les réglages Wi-Fi ou de fuseau horaire."; +"The device and its network services will be temporarily unavailable. Are you sure you want to continue?" = "L’appareil et ses services réseau seront temporairement indisponibles. Voulez-vous vraiment continuer ?"; +"The wireless network is open and does not require a Wi-Fi password." = "Le réseau sans fil est ouvert et ne requiert aucun mot de passe Wi-Fi."; +"This %@ will create a network." = "%@ va créer un réseau."; +"This AirPort wireless device supports log messages that may help diagnose a problem." = "Cet appareil sans fil AirPort prend en charge des messages de journal pouvant aider à diagnostiquer un problème."; +"This base station does not support AirPlay." = "Cette borne d’accès ne prend pas en charge AirPlay."; +"This base station does not support a modem connection." = "Cette borne d’accès ne prend pas en charge les connexions par modem."; +"This base station does not support advanced settings." = "Cette borne d’accès ne prend pas en charge les réglages avancés."; +"This base station does not support firmware updates." = "Cette borne d’accès ne prend pas en charge les mises à jour de micrologiciel."; +"This base station does not support modem options." = "Cette borne d’accès ne prend pas en charge les options de modem."; +"Time Capsule" = "Time Capsule"; +"Time Server cannot be empty when automatic time is enabled." = "Le serveur de temps ne peut pas être vide lorsque l’heure automatique est activée."; +"Time Server:" = "Serveur de temps :"; +"Tone" = "Tonalité"; +"Transmit Power" = "Puissance d’émission"; +"Transmit Power is not supported." = "La puissance d’émission n’est pas pris en charge."; +"Transmit Power:" = "Puissance d’émission :"; +"Tunnel" = "Tunnel"; +"Type" = "Type"; +"Undo" = "Annuler"; +"Unknown" = "Inconnu"; +"Update" = "Mettre à jour"; +"Use AOL" = "Utiliser AOL"; +"Use a single password" = "Utiliser un seul mot de passe"; +"Use dynamic global hostname" = "Utiliser un nom d’hôte global dynamique"; +"Use interference robustness" = "Utiliser la robustesse aux interférences"; +"User:" = "Utilisateur :"; +"Using bundled mock firmware." = "Utilisation du micrologiciel simulé fourni."; +"Using selected firmware file." = "Utilisation du fichier de micrologiciel sélectionné."; +"Verified" = "Vérifié"; +"Verify Password:" = "Confirmer le mot de passe :"; +"Verify Secret:" = "Vérifier le secret :"; +"Verify account password" = "Vérifier le mot de passe du compte"; +"Verify disk password" = "Vérifier le mot de passe du disque"; +"Verify password" = "Confirmer le mot de passe"; +"Verify speaker password" = "Vérifier le mot de passe du haut-parleur"; +"Verify wireless password" = "Vérifier le mot de passe sans fil"; +"Version:" = "Version :"; +"WAN setup over Ethernet" = "Configuration WAN via Ethernet"; +"WDS Mode must be main, relay, remote, or off." = "Le mode WDS doit être principal, relais, distant ou désactivé."; +"WDS Mode:" = "Mode WDS :"; +"WDS Peers:" = "Pairs WDS :"; +"WDS main" = "WDS principal"; +"WDS peer AirPort IDs must be one or two MAC addresses." = "Les identifiants AirPort des pairs WDS doivent être une ou deux adresses MAC."; +"WDS relay" = "Relais WDS"; +"WDS remote" = "WDS distant"; +"WINS Server must be an IPv4 address." = "Le serveur WINS doit être une adresse IPv4."; +"WPA Group Key Timeout" = "Délai de la clé de groupe WPA"; +"WPA Group Key Timeout must be between 60 seconds and 24 hours." = "Le délai de la clé de groupe WPA doit être compris entre 60 secondes et 24 heures."; +"WPA Group Key Timeout:" = "Délai clé de groupe WPA :"; +"Wait for the current base station operation to finish, then try again." = "Attendez la fin de l’opération en cours sur la borne d’accès, puis réessayez."; +"Waiting for this base station to apply its settings and restart…" = "En attente de l’application des réglages et du redémarrage de cette borne d’accès…"; +"Waiting for this base station to restart with default settings." = "En attente du redémarrage de cette borne d’accès avec les réglages par défaut."; +"Waiting for this base station to restore its default settings and restart…" = "En attente du rétablissement des réglages par défaut et du redémarrage de cette borne d’accès…"; +"Waiting to restore default settings" = "En attente du rétablissement des réglages par défaut"; +"What do you want to do with this %@?" = "Que voulez-vous faire avec %@ ?"; +"Window" = "Fenêtre"; +"Wireless" = "Sans fil"; +"Wireless Network Name" = "Nom du réseau sans fil"; +"Wireless Network Name cannot be empty." = "Le nom du réseau sans fil ne peut pas être vide."; +"Wireless Network Name:" = "Nom du réseau sans fil :"; +"Wireless Options" = "Options sans fil"; +"Wireless Options..." = "Options sans fil…"; +"Wireless Password cannot be empty." = "Le mot de passe sans fil ne peut pas être vide."; +"Wireless Password:" = "Mot de passe sans fil :"; +"Wireless Security is not supported." = "La sécurité sans fil n’est pas pris en charge."; +"Wireless Security:" = "Sécurité sans fil :"; +"Wireless extension problem" = "Problème d’extension sans fil"; +"Wireless passwords do not match." = "Les mots de passe sans fil ne correspondent pas."; +"With a disk password" = "Avec un mot de passe de disque"; +"With accounts" = "Avec des comptes"; +"With device password" = "Avec le mot de passe de l’appareil"; +"Working" = "En cours…"; +"Working normally" = "Fonctionne normalement"; +"Writes over disk data seven times. This option is more secure and takes significantly longer." = "Réécrit les données du disque sept fois. Cette option est plus sûre et prend nettement plus de temps."; +"Writes over disk data thirty-five times. This option is the most secure and takes the longest." = "Réécrit les données du disque trente-cinq fois. Cette option est la plus sûre et la plus longue."; +"Writes zeros over all data on the disk. This option provides better security than a quick erase, but takes longer." = "Écrit des zéros sur toutes les données du disque. Cette option est plus sûre qu’un effacement rapide, mais prend plus de temps."; +"Zero Out Data" = "Remplir de zéros"; +"Zoom" = "Zoom"; +"day" = "jour"; +"file sharing" = "partages"; +"firmware update" = "mise à jour du micrologiciel"; +"hour" = "heure"; +"menu.Edit" = "Édition"; +"minute" = "minute"; +"network" = "réseau"; +"router address" = "adresse du routeur"; +"second" = "seconde"; +"serial number" = "numéro de série"; +"status" = "état"; +"verified" = "vérifié"; +"version" = "version"; +"week" = "semaine"; +"wireless clients" = "clients sans fil"; +"“%@” is now available." = "« %@ » est maintenant disponible."; diff --git a/Sources/AirPortUtilityCore/Resources/it.lproj/Localizable.strings b/Sources/AirPortUtilityCore/Resources/it.lproj/Localizable.strings new file mode 100644 index 0000000..54333a2 --- /dev/null +++ b/Sources/AirPortUtilityCore/Resources/it.lproj/Localizable.strings @@ -0,0 +1,556 @@ +/* AirPort Utility - it + Keys are the English source strings. Untranslated keys fall back to + English automatically, so this table may be incomplete. + Do NOT add ACP keys, backend flags or persisted raw values here. */ + +"%1$@ used / %2$@" = "%1$@ usati / %2$@"; +"%@ free" = "%@ liberi"; +"%@ used" = "%@ usati"; +"0 - Emergency" = "0 – Emergenza"; +"1 - Alert" = "1 – Allerta"; +"1 hour" = "1 ora"; +"15 minutes" = "15 minuti"; +"1st generation" = "1ª generazione"; +"2 - Critical" = "2 – Critico"; +"2 hours" = "2 ore"; +"24 hours" = "24 ore"; +"2nd generation" = "2ª generazione"; +"3 - Error" = "3 – Errore"; +"30 minutes" = "30 minuti"; +"35-Pass Erase" = "Inizializzazione a 35 passaggi"; +"3rd generation" = "3ª generazione"; +"4 - Warning" = "4 – Avviso"; +"4 hours" = "4 ore"; +"4th generation" = "4ª generazione"; +"5 - Notice" = "5 – Nota"; +"5th generation" = "5ª generazione"; +"6 - Informational" = "6 – Informazione"; +"6th generation" = "6ª generazione"; +"7 - Debug" = "7 – Debug"; +"7-Pass Erase" = "Inizializzazione a 7 passaggi"; +"8 hours" = "8 ore"; +"About AirPort Utility" = "Informazioni su Utility AirPort"; +"Access Control" = "Controllo accessi"; +"Access Control mode is not supported." = "La modalità di controllo accessi non è supportato."; +"Access Control:" = "Controllo accessi:"; +"Access-control descriptions may contain at most 34 UTF-8 bytes." = "Le descrizioni del controllo accessi non possono superare 34 byte UTF-8."; +"Account Name" = "Nome account"; +"Account Name:" = "Nome account:"; +"Account Password cannot be empty." = "La password dell’account non può essere vuota."; +"Account name" = "Nome account"; +"Account password" = "Password account"; +"Account passwords do not match." = "Le password dell’account non corrispondono."; +"Accounts:" = "Account:"; +"Add Client" = "Aggiungi client"; +"Add WPS Printer…" = "Aggiungi stampante WPS…"; +"Add to an existing network" = "Aggiungi a una rete esistente"; +"Admin Password" = "Password amministratore"; +"Admin passwords do not match." = "Le password amministratore non corrispondono."; +"Advanced" = "Avanzate"; +"Advanced ACP JSON is not valid JSON." = "Il JSON ACP avanzato non è JSON valido."; +"Advanced ACP JSON must be an object keyed by setting name." = "Il JSON ACP avanzato deve essere un oggetto indicizzato per nome impostazione."; +"Advanced ACP JSON must be valid UTF-8." = "Il JSON ACP avanzato deve essere UTF-8 valido."; +"Advanced ACP setting names must be four characters." = "I nomi delle impostazioni ACP avanzate devono avere quattro caratteri."; +"AirPlay" = "AirPlay"; +"AirPlay Speaker Name cannot be empty." = "Il nome dell’altoparlante AirPlay non può essere vuoto."; +"AirPlay Speaker Name:" = "Nome altoparlante AirPlay:"; +"AirPlay Speaker Password:" = "Password altoparlante AirPlay:"; +"AirPlay passwords do not match." = "Le password AirPlay non corrispondono."; +"AirPort Base Station" = "Stazione base AirPort"; +"AirPort Configuration" = "Configurazione AirPort"; +"AirPort Express" = "AirPort Express"; +"AirPort Extreme" = "AirPort Extreme"; +"AirPort ID" = "ID AirPort"; +"AirPort ID:" = "ID AirPort:"; +"AirPort Time Capsule Disk" = "Disco AirPort Time Capsule"; +"AirPort Utility" = "Utility AirPort"; +"AirPort Utility Help" = "Aiuto Utility AirPort"; +"All users will be disconnected from this disk." = "Tutti gli utenti verranno disconnessi da questo disco."; +"All wireless clients are allowed to join this network." = "Tutti i client wireless possono accedere a questa rete."; +"Allow SNMP" = "Consenti SNMP"; +"Allow SNMP over WAN" = "Consenti SNMP tramite WAN"; +"Allow only the wireless clients listed below." = "Consenti solo i client wireless elencati di seguito."; +"Allow setup over Ethernet WAN port" = "Consenti impostazione tramite WAN"; +"Allow this network to be extended" = "Consenti l’estensione di questa rete"; +"Alternate" = "Alternativo"; +"Alternate Number:" = "Numero alternativo:"; +"Always On" = "Sempre attiva"; +"Another router appears to be providing NAT upstream of this base station." = "Sembra che un altro router fornisca il NAT a monte di questa stazione base."; +"Answer on ring must be between 1 and 255." = "Rispondi allo squillo deve essere tra 1 e 255."; +"Answer on ring:" = "Rispondi allo squillo:"; +"Applying Archive Disk" = "Applicazione archiviazione disco"; +"Archive" = "Archivia"; +"Archive Disk" = "Archivia disco"; +"Archive Disk complete." = "Archiviazione disco completata."; +"Archive Disk in progress." = "Archiviazione disco in corso."; +"Archive Disk started. Waiting for archive to complete." = "Archiviazione disco avviata. In attesa del completamento."; +"Archive Disk status check timed out." = "Timeout durante la verifica dello stato dell’archiviazione."; +"Archive Disk…" = "Archivia disco…"; +"Archive the AirPort Time Capsule disk to back up your data." = "Archivia il disco AirPort Time Capsule per eseguire il backup dei dati."; +"Archiving disk" = "Archiviazione disco"; +"Are you sure you want to archive the AirPort Time Capsule disk to a disk connected using USB?" = "Vuoi davvero archiviare il disco AirPort Time Capsule su un disco collegato via USB?"; +"Are you sure you want to erase the AirPort Time Capsule disk?" = "Vuoi davvero inizializzare il disco AirPort Time Capsule?"; +"Automatic" = "Automatico"; +"Automatically" = "Automaticamente"; +"Automatically Dial" = "Composizione automatica"; +"Available Firmware:" = "Firmware disponibile:"; +"Back" = "Indietro"; +"Base Station" = "Stazione base"; +"Base Station Name" = "Nome stazione base"; +"Base Station Name cannot be empty." = "Il nome della stazione base non può essere vuoto."; +"Base Station Name:" = "Nome base:"; +"Base Station Options" = "Opzioni stazione base"; +"Base Station Password" = "Password stazione base"; +"Base Station Password:" = "Password base AirPort:"; +"Base Station to Replace:" = "Stazione base da sostituire:"; +"Block incoming IPv6 connections" = "Blocca connessioni IPv6 in entrata"; +"Bring All to Front" = "Porta tutto in primo piano"; +"Cancel" = "Annulla"; +"Check for Updates" = "Cerca aggiornamenti"; +"Choose a base station" = "Scegli una stazione base"; +"Choose..." = "Scegli…"; +"Chosen Firmware" = "Firmware scelto"; +"Close" = "Chiudi"; +"Command Log" = "Log dei comandi"; +"Configuration problem" = "Problema di configurazione"; +"Configure IPv6" = "Configura IPv6"; +"Configure IPv6 must be link-local, automatic, or manual." = "Configura IPv6 deve essere link-local, automatico o manuale."; +"Configure IPv6:" = "Configura IPv6:"; +"Configure Other" = "Configura altro"; +"Configure Other..." = "Configura altro…"; +"Configure automatically" = "Configura automaticamente"; +"Connect" = "Connetti"; +"Connect Using:" = "Connetti tramite:"; +"Connect to Base Station" = "Connetti alla stazione base"; +"Connect to Base Station..." = "Connetti alla stazione base…"; +"Connected" = "Connesso"; +"Connected to %@" = "Connesso a %@"; +"Connected to %@. Mock mode." = "Connesso a %@. Modalità simulata."; +"Connecting to Base Station" = "Connessione alla stazione base"; +"Connection:" = "Connessione:"; +"Contact:" = "Contatto:"; +"Continue" = "Continua"; +"Copy" = "Copia"; +"Copy compatible settings from another AirPort base station." = "Copia le impostazioni compatibili da un’altra stazione base AirPort."; +"Could not combine legacy settings into one update." = "Impossibile combinare le impostazioni legacy in un unico aggiornamento."; +"Could not encode disk account settings." = "Impossibile codificare le impostazioni degli account disco."; +"Could not encode local access-control settings." = "Impossibile codificare le impostazioni locali di controllo accessi."; +"Could not encode the combined legacy settings update." = "Impossibile codificare l’aggiornamento combinato delle impostazioni legacy."; +"Could not find the Time Capsule. Check that it is on this network, or enter its IP address instead of the .local name." = "Time Capsule non trovata. Verifica che sia su questa rete oppure inserisci il suo indirizzo IP anziché il nome .local."; +"Could not read the base station setup profile." = "Impossibile leggere il profilo di configurazione della stazione base."; +"Country Code:" = "Codice paese:"; +"Create a new network" = "Crea una rete"; +"Create a separate Wi-Fi network using this base station." = "Crea una rete Wi-Fi separata con questa stazione base."; +"Create a wireless network" = "Crea una rete wireless"; +"Create hidden network" = "Crea una rete nascosta"; +"Cut" = "Taglia"; +"DHCP Lease cannot be empty." = "L’assegnazione DHCP non può essere vuota."; +"DHCP Lease duration must be between 1 second and 10 years." = "La durata dell’assegnazione DHCP deve essere tra 1 secondo e 10 anni."; +"DHCP Lease must be a positive number." = "L’assegnazione DHCP deve essere un numero positivo."; +"DHCP Lease unit is not supported." = "L’unità di assegnazione DHCP non è supportato."; +"DHCP Lease:" = "Assegnazione DHCP:"; +"DHCP Message:" = "Messaggio DHCP:"; +"DHCP Only" = "Solo DHCP"; +"DHCP Range Beginning and Ending must use the same supported private subnet, with Ending not before Beginning." = "L’inizio e la fine dell’intervallo DHCP devono usare la stessa sottorete privata supportata e la fine non può precedere l’inizio."; +"DHCP Range Beginning cannot be empty." = "L’inizio dell’intervallo DHCP non può essere vuoto."; +"DHCP Range Beginning must be an IPv4 address." = "L’inizio dell’intervallo DHCP deve essere un indirizzo IPv4."; +"DHCP Range Ending cannot be empty." = "La fine dell’intervallo DHCP non può essere vuota."; +"DHCP Range Ending must be an IPv4 address." = "La fine dell’intervallo DHCP deve essere un indirizzo IPv4."; +"DHCP Range:" = "Intervallo DHCP:"; +"DHCP Reservations:" = "Prenotazioni DHCP:"; +"DHCP and NAT" = "DHCP e NAT"; +"DNS Server must be an IPv4 address." = "Il server DNS deve essere un indirizzo IPv4."; +"DNS Servers accepts at most two IPv4 DNS servers." = "Server DNS accetta al massimo due server DNS IPv4."; +"DNS Servers contains an empty value." = "Server DNS contiene un valore vuoto."; +"DNS Servers:" = "Server DNS:"; +"DNS servers" = "server DNS"; +"Default" = "Default"; +"Default Host must be an IPv4 address." = "L’host di default deve essere un indirizzo IPv4."; +"Default Route:" = "Instradamento di default:"; +"Default password" = "Password di default"; +"Delete" = "Elimina"; +"Description" = "Descrizione"; +"Description:" = "Descrizione:"; +"Destination" = "Destinazione"; +"Destination:" = "Destinazione:"; +"Dialing:" = "Selezione:"; +"Disconnect if Idle:" = "Disconnetti se inattivo:"; +"Disk Password cannot be empty." = "La password del disco non può essere vuota."; +"Disk Password:" = "Password disco:"; +"Disk Sharing" = "Condivisione disco"; +"Disk information is not available yet." = "Le informazioni sul disco non sono ancora disponibili."; +"Disk needs repair" = "Il disco necessita riparazione"; +"Disk password" = "Password disco"; +"Disk passwords do not match." = "Le password del disco non corrispondono."; +"Disk space is low" = "Spazio su disco insufficiente"; +"Disks" = "Dischi"; +"Domain Name:" = "Nome dominio:"; +"Done" = "Fine"; +"Double NAT" = "NAT doppio"; +"Downloading from Apple" = "Download da Apple"; +"Dry-run completed without output." = "Simulazione completata senza output."; +"Each local access-control entry must contain a valid MAC address." = "Ogni voce locale di controllo accessi deve contenere un indirizzo MAC valido."; +"Edit" = "Modifica"; +"Enable AirPlay" = "Attiva AirPlay"; +"Enable AirPlay over WAN" = "Attiva AirPlay tramite WAN"; +"Enable NAT Port Mapping Protocol" = "Attiva il protocollo NAT-PMP"; +"Enable default host at:" = "Attiva host di default su:"; +"Enable file sharing" = "Attiva condivisione file"; +"Enter base station password to load settings." = "Inserisci la password della stazione base per caricare le impostazioni."; +"Enter names and matching passwords of at least 8 characters." = "Inserisci nomi e password corrispondenti di almeno 8 caratteri."; +"Erase" = "Inizializza"; +"Erase Disk" = "Inizializza disco"; +"Erase Disk…" = "Inizializza disco…"; +"Erases directory information so that data is no longer accessible. The data is left unchanged on disk until its disk space is required and it is written over. Data is potentially recoverable until then. This option is the quickest, but least secure." = "Cancella le informazioni di directory in modo che i dati non siano più accessibili. I dati restano invariati sul disco finché lo spazio non viene richiesto e sovrascritto. Fino ad allora sono potenzialmente recuperabili. Questa opzione è la più rapida, ma la meno sicura."; +"Erasing disk" = "Inizializzazione disco"; +"Erasing the AirPort Time Capsule disk deletes all files from the disk." = "L’inizializzazione del disco AirPort Time Capsule elimina tutti i file dal disco."; +"Examining the base station…" = "Analisi della stazione base…"; +"Export Configuration File" = "Esporta file di configurazione"; +"Export Configuration File..." = "Esporta file di configurazione…"; +"Extend a wireless network" = "Estendi una rete wireless"; +"Failing" = "In errore"; +"File" = "Archivio"; +"File Sharing Access must be read-write, read-only, or not-allowed." = "L’accesso alla condivisione file deve essere lettura-scrittura, sola lettura o non consentito."; +"File Sharing Access:" = "Accesso condivisione file:"; +"File sharing account" = "Account condivisione file"; +"Finish Factory Restore" = "Completa il ripristino di fabbrica"; +"Finish editing before refreshing settings." = "Termina la modifica prima di aggiornare le impostazioni."; +"Firmware" = "Firmware"; +"Firmware list loaded." = "Elenco firmware caricato."; +"Firmware list loaded. Mock mode." = "Elenco firmware caricato. Modalità simulata."; +"Firmware update available" = "Aggiornamento firmware disponibile"; +"Firmware upload accepted. Waiting for restart." = "Caricamento firmware accettato. In attesa del riavvio."; +"Firmware upload completed, but the base station reboot command was not sent." = "Il caricamento del firmware è completato, ma il comando di riavvio della stazione base non è stato inviato."; +"Firmware uploaded. Restart requested." = "Firmware caricato. Riavvio richiesto."; +"Gathering information about your network…" = "Raccolta di informazioni sulla rete…"; +"Generation:" = "Generazione:"; +"Global Hostname cannot be empty." = "Il nome host globale non può essere vuoto."; +"Guest Disk Access is not supported." = "L’accesso ospite al disco non è supportato."; +"Help" = "Aiuto"; +"Hide AirPort Utility" = "Nascondi Utility AirPort"; +"Hide Others" = "Nascondi altre"; +"Host" = "Host"; +"Host:" = "Host:"; +"Hostname:" = "Nome host:"; +"IP Address" = "Indirizzo IP"; +"IP address" = "indirizzo IP"; +"IPv4 Address cannot be empty." = "L’indirizzo IPv4 non può essere vuoto."; +"IPv4 Address must be an IPv4 address." = "L’indirizzo IPv4 deve essere un indirizzo IPv4."; +"IPv4 Address:" = "Indirizzo IPv4:"; +"IPv4 DHCP Range:" = "Intervallo DHCP IPv4:"; +"IPv6 Address must be an IPv6 address." = "L’indirizzo IPv6 deve essere un indirizzo IPv6."; +"IPv6 Address:" = "Indirizzo IPv6:"; +"IPv6 DNS Server must be an IPv6 address." = "Il server DNS IPv6 deve essere un indirizzo IPv6."; +"IPv6 DNS Servers accepts at most two IPv6 DNS servers." = "Server DNS IPv6 accetta al massimo due server DNS IPv6."; +"IPv6 DNS Servers contains an empty value." = "Server DNS IPv6 contiene un valore vuoto."; +"IPv6 DNS Servers:" = "Server DNS IPv6:"; +"IPv6 Mode" = "Modalità IPv6"; +"IPv6 Mode must be host, tunnel, or router." = "La modalità IPv6 deve essere host, tunnel o router."; +"IPv6 Mode:" = "Modalità IPv6:"; +"Identify Base Station" = "Identifica stazione base"; +"Idle Disconnect After has an unsupported value." = "Disconnetti dopo inattività ha un valore non supportato."; +"Idle Disconnect After:" = "Disconnetti dopo inattività:"; +"Ignore Dial Tone" = "Ignora segnale di linea"; +"Ignored identity refresh while editing." = "Aggiornamento dell’identità ignorato durante la modifica."; +"Ignored settings refresh while editing." = "Aggiornamento delle impostazioni ignorato durante la modifica."; +"Import Configuration File" = "Importa file di configurazione"; +"Import Configuration File..." = "Importa file di configurazione…"; +"Initial setup has not been marked complete." = "La configurazione iniziale non è stata contrassegnata come completata."; +"Install" = "Installa"; +"Internet" = "Internet"; +"Internet Options" = "Opzioni Internet"; +"Internet Options..." = "Opzioni Internet…"; +"Internet inactive" = "Internet inattivo"; +"Internet working normally" = "Internet funziona normalmente"; +"Join a wireless network" = "Accedi a una rete wireless"; +"Join or extend a Wi-Fi network that is already available." = "Accedi a una rete Wi-Fi già disponibile o estendila."; +"LAN IP Address must be an IPv4 address." = "L’indirizzo IP LAN deve essere un indirizzo IPv4."; +"LAN IP Address:" = "Indirizzo IP LAN:"; +"LAN IP address" = "IP LAN"; +"LDAP Server:" = "Server LDAP:"; +"Link-local only" = "Solo link-local"; +"Loading Internet Settings" = "Caricamento impostazioni Internet"; +"Loading Wireless Clients" = "Caricamento client wireless"; +"Loading disk information..." = "Caricamento informazioni disco…"; +"Loading firmware list" = "Caricamento elenco firmware"; +"Local" = "Locale"; +"Location:" = "Posizione:"; +"Logging & Statistics" = "Log"; +"Make sure the storage device has enough space for the archive before connecting it to the base station’s USB Port." = "Assicurati che il dispositivo di archiviazione abbia spazio sufficiente per l’archivio prima di collegarlo alla porta USB della stazione base."; +"Manually" = "Manualmente"; +"Maximum Connect Time has an unsupported value." = "Il tempo massimo di connessione ha un valore non supportato."; +"Maximum Connect Time:" = "Tempo massimo di connessione:"; +"Minimize" = "Riduci a icona"; +"Mock backend enabled with fixture Time Capsule settings." = "Backend simulato attivo con impostazioni Time Capsule di test."; +"Mock firmware upload accepted. Restart requested." = "Caricamento firmware simulato accettato. Riavvio richiesto."; +"Mock firmware uploaded." = "Firmware simulato caricato."; +"Mock network scan completed." = "Scansione di rete simulata completata."; +"Mock refresh completed." = "Aggiornamento simulato completato."; +"Modem Options" = "Opzioni modem"; +"Modem Options..." = "Opzioni modem…"; +"Modem passwords do not match." = "Le password del modem non corrispondono."; +"Multicast Rate" = "Velocità multicast"; +"Multicast Rate is not supported." = "La velocità multicast non è supportata."; +"Multicast Rate:" = "Velocità multicast:"; +"NAT Only" = "Solo NAT"; +"Name" = "Nome"; +"Name:" = "Nome:"; +"Network" = "Rete"; +"Network Interfaces" = "Interfacce di rete"; +"Network Mode:" = "Modalità rete:"; +"Network Name:" = "Nome rete:"; +"Network Options" = "Opzioni di rete"; +"Network Options..." = "Opzioni di rete…"; +"Network Setup" = "Configurazione di rete"; +"Network name" = "Nome rete"; +"Never Disconnect" = "Non disconnettere mai"; +"New AirPort base station" = "Nuova stazione base AirPort"; +"New password" = "Nuova password"; +"New wireless password" = "Nuova password wireless"; +"Next" = "Avanti"; +"No AirPort base stations discovered" = "Nessuna stazione base AirPort rilevata"; +"No AirPort disks available" = "Nessun disco AirPort disponibile"; +"No Apple firmware images are listed for this base station." = "Nessuna immagine firmware Apple elencata per questa stazione base."; +"No DNS servers configured" = "Nessun server DNS configurato"; +"No disk information loaded." = "Nessuna informazione sul disco caricata."; +"No disk partitions found." = "Nessuna partizione del disco trovata."; +"No firmware image is selected." = "Nessuna immagine firmware selezionata."; +"No firmware images loaded" = "Nessuna immagine firmware caricata"; +"No new Wi-Fi devices discovered" = "Nessun nuovo dispositivo Wi-Fi rilevato"; +"No pending Advanced changes to apply." = "Nessuna modifica avanzata in sospeso da applicare."; +"No pending Advanced changes to preview." = "Nessuna modifica avanzata in sospeso da visualizzare in anteprima."; +"No pending AirPlay changes to apply." = "Nessuna modifica AirPlay in sospeso da applicare."; +"No pending AirPlay changes to preview." = "Nessuna modifica AirPlay in sospeso da visualizzare in anteprima."; +"No pending Base Station changes to apply." = "Nessuna modifica stazione base in sospeso da applicare."; +"No pending Base Station changes to preview." = "Nessuna modifica stazione base in sospeso da visualizzare in anteprima."; +"No pending Disk Sharing changes to apply." = "Nessuna modifica di condivisione disco in sospeso da applicare."; +"No pending Disk Sharing changes to preview." = "Nessuna modifica di condivisione disco in sospeso da visualizzare in anteprima."; +"No pending Internet changes to apply." = "Nessuna modifica Internet in sospeso da applicare."; +"No pending Internet changes to preview." = "Nessuna modifica Internet in sospeso da visualizzare in anteprima."; +"No pending Network changes to apply." = "Nessuna modifica rete in sospeso da applicare."; +"No pending Network changes to preview." = "Nessuna modifica rete in sospeso da visualizzare in anteprima."; +"No pending Wireless changes to apply." = "Nessuna modifica wireless in sospeso da applicare."; +"No pending Wireless changes to preview." = "Nessuna modifica wireless in sospeso da visualizzare in anteprima."; +"No pending changes to apply." = "Nessuna modifica in sospeso da applicare."; +"Not Allowed" = "Non consentito"; +"Not available" = "Non disponibile"; +"Not connected" = "Non connesso"; +"Not enabled" = "Non attivato"; +"Off" = "Disattivato"; +"Open wireless network" = "Rete wireless aperta"; +"Other Options" = "Altre opzioni"; +"Other Wi-Fi Devices" = "Altri dispositivi Wi-Fi"; +"PPP Dial-in" = "Accesso PPP"; +"PPP Dial-in is not allowed when configured to connect to the Internet via the Modem or AOL." = "L’accesso PPP non è consentito quando la connessione a Internet avviene tramite modem o AOL."; +"PPP Dial-in is not allowed when configured to share a range of addresses." = "L’accesso PPP non è consentito quando si condivide un intervallo di indirizzi."; +"PPP Dial-in passwords do not match." = "Le password di accesso PPP non corrispondono."; +"PPPoE Account Name cannot be empty." = "Il nome account PPPoE non può essere vuoto."; +"PPPoE Connection must be always-on, automatic, or manual." = "La connessione PPPoE deve essere sempre attiva, automatica o manuale."; +"Participate in a WDS network" = "Partecipa a una rete WDS"; +"Partitions:" = "Partizioni:"; +"Password" = "Password"; +"Password must be at least 8 characters." = "La password deve contenere almeno 8 caratteri."; +"Password:" = "Password:"; +"Passwords" = "Password"; +"Paste" = "Incolla"; +"Phone Number:" = "Numero di telefono:"; +"Port Settings:" = "Impostazioni porte:"; +"Preferences" = "Preferenze"; +"Preferences..." = "Preferenze…"; +"Primary Port:" = "Porta principale:"; +"Primary RADIUS Server must be an IPv4 address." = "Il server RADIUS principale deve essere un indirizzo IPv4."; +"Primary RADIUS Shared Secret cannot be empty." = "Il segreto condiviso RADIUS principale non può essere vuoto."; +"Primary RADIUS port must be between 1 and 65535." = "La porta RADIUS principale deve essere tra 1 e 65535."; +"Primary RADIUS shared secrets do not match." = "Le password RADIUS principali non corrispondono."; +"Primary Server:" = "Server principale:"; +"Progress:" = "Avanzamento:"; +"Protocol:" = "Protocollo:"; +"Pulse" = "Impulsi"; +"Quick Erase (non-secure)" = "Inizializzazione rapida (non sicura)"; +"Quit AirPort Utility" = "Esci da Utility AirPort"; +"RADIUS Type:" = "Tipo RADIUS:"; +"RADIUS type is not supported." = "Il tipo RADIUS non è supportato."; +"Radio Channel" = "Canale radio"; +"Radio Channel:" = "Canale radio:"; +"Radio Mode" = "Modalità radio"; +"Radio Mode is not supported." = "La modalità radio non è supportato."; +"Radio Mode:" = "Modalità radio:"; +"Radio channel must be 'automatic' or a channel number." = "Il canale radio deve essere «automatico» o un numero di canale."; +"Read Only" = "Sola lettura"; +"Read and Write" = "Lettura e scrittura"; +"Ready to connect to %@" = "Pronto per connettersi a %@"; +"Redo" = "Ripristina"; +"Refresh" = "Aggiorna"; +"Refresh completed." = "Aggiornamento completato."; +"Refreshing settings" = "Aggiornamento impostazioni"; +"Region" = "Regione"; +"Region code must be between 0 and 255." = "Il codice regione deve essere tra 0 e 255."; +"Region:" = "Regione:"; +"Reinstall" = "Reinstalla"; +"Remember this password in my keychain" = "Aggiungi questa password al portachiavi"; +"Remove access-control entry" = "Rimuovi voce di controllo accessi"; +"Renew DHCP Lease" = "Rinnova DHCP assegnato"; +"Replace an existing device" = "Sostituisci un dispositivo esistente"; +"Repo" = "Repo"; +"Reports Double NAT despite Bridge Mode." = "Segnala NAT doppio nonostante la modalità bridge."; +"Repository" = "Repository"; +"Repository:" = "Repository:"; +"Rescanning the network for AirPort base stations." = "Nuova scansione della rete per stazioni base AirPort."; +"Restart Base Station" = "Riavvia stazione base"; +"Restart Base Station?" = "Riavviare la stazione base?"; +"Restart command sent." = "Comando di riavvio inviato."; +"Restart with Default Settings" = "Riavvia con le impostazioni di default"; +"Restarting" = "Riavvio"; +"Restart…" = "Riavvia…"; +"Restore Default Settings" = "Ripristina impostazioni di default"; +"Restore Default Settings..." = "Ripristina impostazioni di default…"; +"Restore Default Settings?" = "Ripristinare le impostazioni di default?"; +"Restore Factory Defaults" = "Ripristina impostazioni di fabbrica"; +"Restore failed: %@" = "Ripristino non riuscito: %@"; +"Restoring" = "Ripristino"; +"Restoring Base Station…" = "Ripristino della stazione base…"; +"Restoring this Base Station to factory defaults erases its settings." = "Il ripristino delle impostazioni di fabbrica cancella le impostazioni di questa stazione base."; +"Router" = "Router"; +"Router Address cannot be empty." = "L’indirizzo del router non può essere vuoto."; +"Router Address must be an IPv4 address." = "L’indirizzo del router deve essere un indirizzo IPv4."; +"Router Address:" = "Indirizzo router:"; +"Router Mode:" = "Modalità router:"; +"SMART: %@" = "SMART: %@"; +"Save" = "Registra"; +"Scanning for AirPort base stations…" = "Ricerca di stazioni base AirPort…"; +"Secondary Port:" = "Porta secondaria:"; +"Secondary RADIUS Server must be an IPv4 address." = "Il server RADIUS secondario deve essere un indirizzo IPv4."; +"Secondary RADIUS Shared Secret cannot be empty." = "Il segreto condiviso RADIUS secondario non può essere vuoto."; +"Secondary RADIUS port must be between 1 and 65535." = "La porta RADIUS secondaria deve essere tra 1 e 65535."; +"Secondary RADIUS shared secrets do not match." = "Le password RADIUS secondari non corrispondono."; +"Secondary Server:" = "Server secondario:"; +"Secure Shared Disks mode is not supported." = "La modalità dischi condivisi protetti non è supportato."; +"Secure Shared Disks:" = "Proteggi dischi condivisi:"; +"Security Method" = "Metodo di sicurezza"; +"Security Method:" = "Metodo di sicurezza:"; +"Select All" = "Seleziona tutto"; +"Service Name:" = "Nome servizio:"; +"Services" = "Servizi"; +"Set time automatically" = "Imposta ora automaticamente"; +"Set up this %@ to create a new Wi-Fi network." = "Configura %@ per creare una rete Wi-Fi."; +"Setting up this %@…" = "Configurazione di %@…"; +"Settings" = "Impostazioni"; +"Setup" = "Configurazione"; +"Setup Complete" = "Configurazione completata"; +"Setup Over Ethernet WAN" = "Configurazione tramite WAN Ethernet"; +"Setup failed" = "Configurazione non riuscita"; +"Setup over the Ethernet WAN port is enabled." = "La configurazione tramite la porta WAN Ethernet è attiva."; +"Shared Secret:" = "Segreto condiviso:"; +"Show All" = "Mostra tutto"; +"Show Passwords…" = "Mostra password…"; +"Show connection details in the Other Wi-Fi Devices menu" = "Mostra i dettagli di connessione nel menu Altri dispositivi Wi-Fi"; +"Simple Network Management Protocol (SNMP) allows you to query this device for statistics, including the number of wireless clients." = "Il protocollo SNMP (Simple Network Management Protocol) consente di interrogare questo dispositivo per ottenere statistiche, incluso il numero di client wireless."; +"Speaker name" = "Nome altoparlante"; +"Speaker password" = "Password altoparlante"; +"Starting download from Apple." = "Avvio del download da Apple."; +"Starting upload to AirPort." = "Avvio del caricamento su AirPort."; +"Subnet Mask cannot be empty." = "La maschera di sottorete non può essere vuota."; +"Subnet Mask:" = "Maschera di sottorete:"; +"Syslog Destination Address must be an IPv4 address." = "L’indirizzo di destinazione syslog deve essere un indirizzo IPv4."; +"Syslog Destination Address:" = "Indirizzo di destinazione syslog:"; +"Syslog Level must be between 0 and 7." = "Il livello syslog deve essere tra 0 e 7."; +"Syslog Level:" = "Livello syslog:"; +"The base station is still using the default admin password." = "La stazione base usa ancora la password amministratore di default."; +"The base station product ID is not available." = "L’ID prodotto della stazione base non è disponibile."; +"The base station setup profile has not loaded yet." = "Il profilo di configurazione della stazione base non è ancora stato caricato."; +"The base station setup profile has not loaded. Go Back and try again." = "Il profilo di configurazione della stazione base non è stato caricato. Torna indietro e riprova."; +"The base station setup profile is missing Wi-Fi or timezone settings." = "Nel profilo di configurazione della stazione base mancano le impostazioni Wi-Fi o del fuso orario."; +"The device and its network services will be temporarily unavailable. Are you sure you want to continue?" = "Il dispositivo e i suoi servizi di rete non saranno temporaneamente disponibili. Vuoi davvero continuare?"; +"The wireless network is open and does not require a Wi-Fi password." = "La rete wireless è aperta e non richiede una password Wi-Fi."; +"This %@ will create a network." = "%@ creerà una rete."; +"This AirPort wireless device supports log messages that may help diagnose a problem." = "Questo dispositivo wireless AirPort supporta messaggi di log che possono aiutare a diagnosticare un problema."; +"This base station does not support AirPlay." = "Questa stazione base non supporta AirPlay."; +"This base station does not support a modem connection." = "Questa stazione base non supporta le connessioni via modem."; +"This base station does not support advanced settings." = "Questa stazione base non supporta le impostazioni avanzate."; +"This base station does not support firmware updates." = "Questa stazione base non supporta gli aggiornamenti firmware."; +"This base station does not support modem options." = "Questa stazione base non supporta le opzioni modem."; +"Time Capsule" = "Time Capsule"; +"Time Server cannot be empty when automatic time is enabled." = "Il server ora non può essere vuoto quando l’ora automatica è attiva."; +"Time Server:" = "Server ora:"; +"Tone" = "Toni"; +"Transmit Power" = "Potenza di trasmissione"; +"Transmit Power is not supported." = "La potenza di trasmissione non è supportato."; +"Transmit Power:" = "Potenza di trasmissione:"; +"Tunnel" = "Tunnel"; +"Type" = "Tipo"; +"Undo" = "Annulla"; +"Unknown" = "Sconosciuto"; +"Update" = "Aggiorna"; +"Use AOL" = "Usa AOL"; +"Use a single password" = "Usa un’unica password"; +"Use dynamic global hostname" = "Usa nome host globale dinamico"; +"Use interference robustness" = "Usa robustezza alle interferenze"; +"User:" = "Utente:"; +"Using bundled mock firmware." = "Uso del firmware simulato incluso."; +"Using selected firmware file." = "Uso del file firmware selezionato."; +"Verified" = "Verificato"; +"Verify Password:" = "Verifica password:"; +"Verify Secret:" = "Verifica segreto:"; +"Verify account password" = "Verifica password account"; +"Verify disk password" = "Verifica password disco"; +"Verify password" = "Verifica password"; +"Verify speaker password" = "Verifica password altoparlante"; +"Verify wireless password" = "Verifica password wireless"; +"Version:" = "Versione:"; +"WAN setup over Ethernet" = "Configurazione WAN tramite Ethernet"; +"WDS Mode must be main, relay, remote, or off." = "La modalità WDS deve essere principale, relay, remoto o disattivato."; +"WDS Mode:" = "Modalità WDS:"; +"WDS Peers:" = "Peer WDS:"; +"WDS main" = "WDS principale"; +"WDS peer AirPort IDs must be one or two MAC addresses." = "Gli ID AirPort dei peer WDS devono essere uno o due indirizzi MAC."; +"WDS relay" = "Relay WDS"; +"WDS remote" = "WDS remoto"; +"WINS Server must be an IPv4 address." = "Il server WINS deve essere un indirizzo IPv4."; +"WPA Group Key Timeout" = "Timeout chiave di gruppo WPA"; +"WPA Group Key Timeout must be between 60 seconds and 24 hours." = "Il timeout della chiave di gruppo WPA deve essere tra 60 secondi e 24 ore."; +"WPA Group Key Timeout:" = "Timeout chiave gruppo WPA:"; +"Wait for the current base station operation to finish, then try again." = "Attendi il completamento dell’operazione in corso sulla stazione base, quindi riprova."; +"Waiting for this base station to apply its settings and restart…" = "In attesa che questa stazione base applichi le impostazioni e si riavvii…"; +"Waiting for this base station to restart with default settings." = "In attesa che questa stazione base si riavvii con le impostazioni di default."; +"Waiting for this base station to restore its default settings and restart…" = "In attesa che questa stazione base ripristini le impostazioni di default e si riavvii…"; +"Waiting to restore default settings" = "In attesa del ripristino delle impostazioni di default"; +"What do you want to do with this %@?" = "Cosa vuoi fare con %@?"; +"Window" = "Finestra"; +"Wireless" = "Wireless"; +"Wireless Network Name" = "Nome rete wireless"; +"Wireless Network Name cannot be empty." = "Il nome della rete wireless non può essere vuoto."; +"Wireless Network Name:" = "Nome rete wireless:"; +"Wireless Options" = "Opzioni wireless"; +"Wireless Options..." = "Opzioni wireless…"; +"Wireless Password cannot be empty." = "La password wireless non può essere vuota."; +"Wireless Password:" = "Password wireless:"; +"Wireless Security is not supported." = "La sicurezza wireless non è supportato."; +"Wireless Security:" = "Sicurezza wireless:"; +"Wireless extension problem" = "Problema di estensione wireless"; +"Wireless passwords do not match." = "Le password wireless non corrispondono."; +"With a disk password" = "Con una password del disco"; +"With accounts" = "Con account"; +"With device password" = "Con la password del dispositivo"; +"Working" = "Elaborazione…"; +"Working normally" = "Funziona normalmente"; +"Writes over disk data seven times. This option is more secure and takes significantly longer." = "Sovrascrive i dati del disco sette volte. Questa opzione è più sicura e richiede molto più tempo."; +"Writes over disk data thirty-five times. This option is the most secure and takes the longest." = "Sovrascrive i dati del disco trentacinque volte. Questa opzione è la più sicura e richiede più tempo."; +"Writes zeros over all data on the disk. This option provides better security than a quick erase, but takes longer." = "Scrive zeri su tutti i dati del disco. Questa opzione è più sicura di un’inizializzazione rapida, ma richiede più tempo."; +"Zero Out Data" = "Azzera dati"; +"Zoom" = "Zoom"; +"day" = "giorno"; +"file sharing" = "condivisioni"; +"firmware update" = "aggiornamento firmware"; +"hour" = "ora"; +"menu.Edit" = "Modifica"; +"minute" = "minuto"; +"network" = "rete"; +"router address" = "indirizzo router"; +"second" = "secondo"; +"serial number" = "numero di serie"; +"status" = "stato"; +"verified" = "verificato"; +"version" = "versione"; +"week" = "settimana"; +"wireless clients" = "client wireless"; +"“%@” is now available." = "«%@» è ora disponibile."; diff --git a/Sources/AirPortUtilityCore/TopologyPopovers.swift b/Sources/AirPortUtilityCore/TopologyPopovers.swift index fdb3259..f070595 100644 --- a/Sources/AirPortUtilityCore/TopologyPopovers.swift +++ b/Sources/AirPortUtilityCore/TopologyPopovers.swift @@ -45,13 +45,13 @@ struct DevicePopover: View { PopoverTitleLabel( text: model.baseStation.name.isEmpty ? "time capsule" : model.baseStation.name ) - .frame(width: 283, height: 19) + .frame(width: 303, height: 19) .padding(.bottom, 6) PopoverDetailsRows( rows: deviceDetailRows, wirelessClients: model.wirelessClients, viewportHeight: deviceDetailsHeight) - .frame(width: 274, height: deviceDetailsHeight) + .frame(width: 294, height: deviceDetailsHeight) HStack { Spacer() PopoverEditButton { @@ -61,27 +61,34 @@ struct DevicePopover: View { model.beginEditing() } } - .frame(width: 47, height: 18) + .fixedSize() + .frame(height: 18) } .padding(.top, 7) } .padding(13) - .frame(width: 300, height: devicePopoverHeight, alignment: .leading) + .frame(width: 320, height: devicePopoverHeight, alignment: .leading) } private var deviceDetailRows: [(String, String)] { var rows = [ - ("status", model.selectedDeviceStatusText()), - ("network", model.wireless.networkName), - ("IP address", model.internet.ipv4Address), - ("LAN IP address", model.network.lanIPAddress), - ("serial number", model.baseStation.serialNumber), - ("version", model.baseStation.version), + (localized("status"), model.selectedDeviceStatusText()), + (localized("network"), model.wireless.networkName), + (localized("IP address"), model.internet.ipv4Address), + (localized("LAN IP address"), model.network.lanIPAddress), + (localized("serial number"), model.baseStation.serialNumber), + (localized("version"), model.baseStation.version), ] + let protocols = model.selectedTopologyDevice()?.publishedProtocols ?? [] + if !protocols.isEmpty { + // Omitted when the device advertises nothing, matching how the popover + // already leaves out values it does not have. + rows.append((localized("file sharing"), protocols.joined(separator: " · "))) + } let firmwareUpdate = model.selectedDeviceFirmwareUpdateDetail .trimmingCharacters(in: .whitespacesAndNewlines) if !firmwareUpdate.isEmpty { - rows.insert(("firmware update", firmwareUpdate), at: 1) + rows.insert((localized("firmware update"), firmwareUpdate), at: 1) } let statusDetails = model.selectedDeviceStatusDetails() .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -110,14 +117,14 @@ struct DeviceLoadingPopover: View { SettingsLoadingPopover( title: model.hasLoadedSettings && !model.hasLoadedWirelessClients - ? "Loading Wireless Clients" - : "Connecting to Base Station") + ? localized("Loading Wireless Clients") + : localized("Connecting to Base Station")) } } private struct InternetLoadingPopover: View { var body: some View { - SettingsLoadingPopover(title: "Loading Internet Settings") + SettingsLoadingPopover(title: localized("Loading Internet Settings")) } } @@ -157,13 +164,13 @@ struct InternetPopover: View { private var internetDetails: some View { VStack(alignment: .leading, spacing: 0) { PopoverTitleLabel(text: "Internet") - .frame(width: 283, height: 19) + .frame(width: 303, height: 19) .padding(.bottom, 6) PopoverDetailsRows( rows: [ ("connection", model.internetPopoverConnectionStatus), - ("router address", model.hostInternet.routerAddress), - ("DNS servers", model.hostInternet.dnsServers), + (localized("router address"), model.hostInternet.routerAddress), + (localized("DNS servers"), model.hostInternet.dnsServers), ]) .frame(width: 274, height: 54) } @@ -201,7 +208,7 @@ private struct PopoverDetailsRows: NSViewRepresentable { func makeNSView(context: Context) -> NSScrollView { let scrollView = PopoverDetailsScrollView( - frame: NSRect(x: 0, y: 0, width: 274, height: viewportHeight)) + frame: NSRect(x: 0, y: 0, width: 294, height: viewportHeight)) scrollView.drawsBackground = false scrollView.borderType = .noBorder scrollView.hasVerticalScroller = false @@ -240,7 +247,7 @@ private final class PopoverDetailsDocumentView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) - self.frame = NSRect(x: 0, y: 0, width: 274, height: 107) + self.frame = NSRect(x: 0, y: 0, width: 294, height: 107) wirelessClientDetailsPanel.presentationDidEnd = { [weak self] clientID in self?.wirelessClientPresentationDidEnd(clientID: clientID) @@ -274,7 +281,7 @@ private final class PopoverDetailsDocumentView: NSView { addSubview( textField( row.1.isEmpty ? "--" : row.1, - frame: NSRect(x: 122, y: y, width: 152, height: 19), + frame: NSRect(x: 122, y: y, width: 172, height: 19), label: false)) } @@ -282,7 +289,7 @@ private final class PopoverDetailsDocumentView: NSView { let y = CGFloat(rows.count) * DevicePopoverLayout.rowHeight addSubview( textField( - "wireless clients", + localized("wireless clients"), frame: NSRect(x: 0, y: y, width: 108, height: 19), label: true)) for (index, client) in wirelessClients.enumerated() { @@ -291,7 +298,7 @@ private final class PopoverDetailsDocumentView: NSView { frame: NSRect( x: 122, y: y + CGFloat(index) * DevicePopoverLayout.rowHeight, - width: 152, + width: 172, height: 19)) clientField.setAccessibilityIdentifier("popover.wirelessClients.client") clientField.presentationChanged = { @@ -408,7 +415,7 @@ private struct PopoverEditButton: NSViewRepresentable { func makeNSView(context: Context) -> NSButton { let button = PopoverEditNSButton( - title: "Edit", target: context.coordinator, action: #selector(Coordinator.press)) + title: localized("Edit"), target: context.coordinator, action: #selector(Coordinator.press)) button.bezelStyle = .rounded button.controlSize = .small button.font = .systemFont(ofSize: 13) @@ -440,8 +447,11 @@ private struct PopoverEditButton: NSViewRepresentable { } private final class PopoverEditNSButton: NSButton { + /// 47pt is the measured English width. A longer translation ("Modifier", + /// "Bearbeiten") needs more, and the button is right-aligned behind a Spacer, + /// so growing widens it leftward without disturbing anything. override var intrinsicContentSize: NSSize { - NSSize(width: 47, height: 18) + NSSize(width: max(47, super.intrinsicContentSize.width), height: 18) } } @@ -456,24 +466,24 @@ struct ConnectionPopover: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Text("Connect to Base Station") + Text(localized("Connect to Base Station")) .font(.system(size: 13, weight: .semibold)) if mode == .full { AirPortTextField( text: $model.connection.host, - placeholder: "Host", + placeholder: localized("Host"), identifier: "connection.popover.host") .frame(width: 220, height: 24) } AirPortSecureField( text: $model.connection.password, - placeholder: "Password", + placeholder: localized("Password"), identifier: "connection.popover.password", onSubmit: submitConnection) .frame(width: 220, height: 24) if mode == .passwordOnly { Toggle( - "Remember this password in my keychain", + localized("Remember this password in my keychain"), isOn: Binding( get: { model.rememberConnectionPassword }, set: { model.updateRememberConnectionPassword($0) })) @@ -484,20 +494,23 @@ struct ConnectionPopover: View { if mode == .full && !model.mockMode { AirPortTextField( text: $model.connection.repoPath, - placeholder: "Repo", + placeholder: localized("Repo"), identifier: "connection.popover.repository") .frame(width: 220, height: 24) } HStack { Spacer() - Button(model.isBusy ? "Working" : "Connect") { + Button(model.isBusy ? localized("Working") : localized("Connect")) { submitConnection() } - .accessibilityLabel("Connect") + .accessibilityLabel(localized("Connect")) .accessibilityIdentifier("connection.popover.connect") .keyboardShortcut(.defaultAction) .disabled(!model.canAttemptConnection) } + // model.status is "Connected to ", which interpolates the host and + // is therefore still English. Do not localize this prefix without also + // localizing the status it is matching. if !model.status.hasPrefix("Connected") { Text(model.status) .font(.caption) diff --git a/Sources/AirPortUtilityCore/TopologyView.swift b/Sources/AirPortUtilityCore/TopologyView.swift index 7b3ac80..bb0f1ab 100644 --- a/Sources/AirPortUtilityCore/TopologyView.swift +++ b/Sources/AirPortUtilityCore/TopologyView.swift @@ -71,7 +71,7 @@ struct TopologyView: View { } if model.visibleTopologyDevices.isEmpty { - Text("No AirPort base stations discovered") + Text(localized("No AirPort base stations discovered")) .font(.system(size: 14)) .foregroundStyle(.white.opacity(0.78)) .shadow(color: AirPortTopologyStyle.labelShadow, radius: 2, x: 0, y: 1) @@ -152,10 +152,10 @@ struct TopologyView: View { private func baseStationStatusColor(for device: AirportDiscoveredDevice) -> Color { let status = model.deviceStatusText(for: device) - if model.isTopologyDeviceUpdating(device) || status == "Restarting" { + if model.isTopologyDeviceUpdating(device) || status == localized("Restarting") { return updatingStatusColor } - return status == "Working normally" ? normalStatusColor : updatingStatusColor + return status == localized("Working normally") ? normalStatusColor : updatingStatusColor } private func presentInternetPopover() { @@ -322,10 +322,10 @@ private struct TopologyTreeView: View { let normal = Color(red: 0.29, green: 0.86, blue: 0.25) let warning = Color(red: 1.0, green: 0.73, blue: 0.2) let status = model.deviceStatusText(for: device) - if model.isTopologyDeviceUpdating(device) || status == "Restarting" { + if model.isTopologyDeviceUpdating(device) || status == localized("Restarting") { return warning } - return status == "Working normally" ? normal : warning + return status == localized("Working normally") ? normal : warning } } @@ -494,10 +494,10 @@ enum DevicePopoverPresentationPolicy { } struct OtherWiFiDevicesMenu: NSViewRepresentable { - static let title = "Other Wi-Fi Devices" - static let connectTitle = "Connect to Base Station..." - static let placeholderTitle = "No new Wi-Fi devices discovered" - static let networkInterfacesTitle = "Network Interfaces" + static let title = localized("Other Wi-Fi Devices") + static let connectTitle = localized("Connect to Base Station...") + static let placeholderTitle = localized("No new Wi-Fi devices discovered") + static let networkInterfacesTitle = localized("Network Interfaces") static let defaultNetworkInterface = "Ethernet 1" static let networkInterfaceTitles = ["Ethernet 1", "Wi-Fi"] @@ -682,7 +682,7 @@ private final class AirPortWiFiDevicesPopUpButton: NSPopUpButton { } override func accessibilityTitle() -> String? { - "Other Wi-Fi Devices" + localized("Other Wi-Fi Devices") } override func accessibilityLabel() -> String? { diff --git a/Sources/AirPortUtilityCore/WirelessOptionsSheet.swift b/Sources/AirPortUtilityCore/WirelessOptionsSheet.swift index 49fca44..40cff61 100644 --- a/Sources/AirPortUtilityCore/WirelessOptionsSheet.swift +++ b/Sources/AirPortUtilityCore/WirelessOptionsSheet.swift @@ -10,7 +10,7 @@ struct WirelessOptionsSheet: View { var body: some View { ZStack(alignment: .topLeading) { - Text("Wireless Options") + Text(localized("Wireless Options")) .font(.system(size: 13, weight: .semibold)) .frame(width: 150, alignment: .leading) .offset(x: 18, y: 15) @@ -20,11 +20,11 @@ struct WirelessOptionsSheet: View { .frame(width: 432, height: 1) .offset(x: 24, y: 61) - optionLabel("Region:", width: 94) - .offset(x: 96, y: 84) - Picker("Region", selection: $draft.regionCode) { + optionLabel(localized("Region:"), width: 172) + .offset(x: 18, y: 84) + Picker(localized("Region"), selection: $draft.regionCode) { ForEach(WirelessRegionOption.allCases) { region in - Text(region.name).tag(region.code) + Text(region.localizedName).tag(region.code) } } .labelsHidden() @@ -34,15 +34,15 @@ struct WirelessOptionsSheet: View { .offset(x: 195, y: 80) WirelessOptionsCheckbox( - "Create hidden network", + localized("Create hidden network"), isOn: $draft.hiddenNetwork, identifier: "wireless.options.hidden.network") .frame(width: 264, height: 18, alignment: .leading) .offset(x: 195, y: 115) - optionLabel("Radio Mode:", width: 94) - .offset(x: 96, y: 140) - Picker("Radio Mode", selection: $draft.radioMode) { + optionLabel(localized("Radio Mode:"), width: 172) + .offset(x: 18, y: 140) + Picker(localized("Radio Mode"), selection: $draft.radioMode) { ForEach(Self.radioModeOptions(for: draft.radioMode)) { option in Text(option.label).tag(option.value) } @@ -53,10 +53,10 @@ struct WirelessOptionsSheet: View { .frame(width: 264, height: 23) .offset(x: 195, y: 136) - optionLabel("Radio Channel:", width: 94) - .offset(x: 96, y: 171) - Picker("Radio Channel", selection: $draft.radioChannel) { - Text("Automatic").tag("automatic") + optionLabel(localized("Radio Channel:"), width: 172) + .offset(x: 18, y: 171) + Picker(localized("Radio Channel"), selection: $draft.radioChannel) { + Text(localized("Automatic")).tag("automatic") ForEach(Self.radioChannels, id: \.self) { channel in Text(channel).tag(channel) } @@ -68,9 +68,9 @@ struct WirelessOptionsSheet: View { .offset(x: 195, y: 167) if model.capabilities.supportsLegacyWirelessOptions { - optionLabel("Multicast Rate:", width: 94) - .offset(x: 96, y: 202) - Picker("Multicast Rate", selection: $legacyDraft.multicastRate) { + optionLabel(localized("Multicast Rate:"), width: 172) + .offset(x: 18, y: 202) + Picker(localized("Multicast Rate"), selection: $legacyDraft.multicastRate) { ForEach(MulticastRateOption.allCases) { option in Text(option.label).tag(option.value) } @@ -81,9 +81,9 @@ struct WirelessOptionsSheet: View { .frame(width: 264, height: 23) .offset(x: 195, y: 198) - optionLabel("Transmit Power:", width: 94) - .offset(x: 96, y: 233) - Picker("Transmit Power", selection: $legacyDraft.transmitPower) { + optionLabel(localized("Transmit Power:"), width: 172) + .offset(x: 18, y: 233) + Picker(localized("Transmit Power"), selection: $legacyDraft.transmitPower) { ForEach(TransmitPowerOption.allCases) { option in Text(option.label).tag(option.percent) } @@ -94,9 +94,9 @@ struct WirelessOptionsSheet: View { .frame(width: 264, height: 23) .offset(x: 195, y: 229) - optionLabel("WPA Group Key Timeout:", width: 160) - .offset(x: 30, y: 264) - Picker("WPA Group Key Timeout", selection: $legacyDraft.groupKeyTimeoutSeconds) { + optionLabel(localized("WPA Group Key Timeout:"), width: 172) + .offset(x: 18, y: 264) + Picker(localized("WPA Group Key Timeout"), selection: $legacyDraft.groupKeyTimeoutSeconds) { ForEach(Self.groupKeyTimeoutOptions, id: \.seconds) { option in Text(option.label).tag(option.seconds) } @@ -108,26 +108,34 @@ struct WirelessOptionsSheet: View { .offset(x: 195, y: 260) WirelessOptionsCheckbox( - "Use interference robustness", + localized("Use interference robustness"), isOn: $legacyDraft.interferenceRobustness, identifier: "wireless.options.interference.robustness") .frame(width: 264, height: 18, alignment: .leading) .offset(x: 195, y: 295) } - WirelessOptionsButton("Cancel", identifier: "wireless.options.cancel") { dismiss() } - .offset(x: 308, y: actionButtonY) - WirelessOptionsButton( - "Save", isDefault: true, isEnabled: hasChanges, - identifier: "wireless.options.save" - ) { - model.wireless = draft - if model.capabilities.supportsLegacyWirelessOptions { - model.legacyDeviceOptions.wireless = legacyDraft + // Right-anchored rather than placed at fixed x offsets: a wider translated + // label ("Abbrechen") would otherwise grow rightward into the Save button. + // The trailing edge and 12pt gap reproduce the previous English layout + // exactly (Cancel 308-378, Save 390-460). + HStack(spacing: 12) { + WirelessOptionsButton(localized("Cancel"), identifier: "wireless.options.cancel") { + dismiss() + } + WirelessOptionsButton( + localized("Save"), isDefault: true, isEnabled: hasChanges, + identifier: "wireless.options.save" + ) { + model.wireless = draft + if model.capabilities.supportsLegacyWirelessOptions { + model.legacyDeviceOptions.wireless = legacyDraft + } + dismiss() } - dismiss() } - .offset(x: 390, y: actionButtonY) + .frame(width: 460, alignment: .trailing) + .offset(x: 0, y: actionButtonY) } .onAppear { if !loaded { @@ -142,13 +150,13 @@ struct WirelessOptionsSheet: View { private static let radioChannels = (1...11).map(String.init) private static let groupKeyTimeoutOptions = [ - (seconds: 900, label: "15 minutes"), - (seconds: 1_800, label: "30 minutes"), - (seconds: 3_600, label: "1 hour"), - (seconds: 7_200, label: "2 hours"), - (seconds: 14_400, label: "4 hours"), - (seconds: 28_800, label: "8 hours"), - (seconds: 86_400, label: "24 hours"), + (seconds: 900, label: localized("15 minutes")), + (seconds: 1_800, label: localized("30 minutes")), + (seconds: 3_600, label: localized("1 hour")), + (seconds: 7_200, label: localized("2 hours")), + (seconds: 14_400, label: localized("4 hours")), + (seconds: 28_800, label: localized("8 hours")), + (seconds: 86_400, label: localized("24 hours")), ] private var actionButtonY: CGFloat { @@ -178,6 +186,9 @@ struct WirelessOptionsSheet: View { ) } + /// Right-aligned label ending at the control column. The width extends + /// leftward to the sheet margin so longer translations have room; English is + /// unaffected because the text is right-aligned. private func optionLabel(_ title: String, width: CGFloat) -> some View { Text(title) .font(.system(size: 13)) @@ -222,7 +233,13 @@ private struct WirelessOptionsButton: NSViewRepresentable { button.setButtonType(.momentaryPushIn) button.alignment = .center button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: 70).isActive = true + // Exact width, but never narrower than the label needs. The constants were + // measured against English and truncate longer translations; a plain + // greaterThanOrEqual constraint instead lets the button expand to fill, + // which changes the English layout. + button.widthAnchor.constraint( + equalToConstant: max(70, button.intrinsicContentSize.width) + ).isActive = true button.heightAnchor.constraint(equalToConstant: 22).isActive = true configure(button) return button diff --git a/Sources/AirPortUtilityCore/WirelessPane.swift b/Sources/AirPortUtilityCore/WirelessPane.swift index d476435..4f1300d 100644 --- a/Sources/AirPortUtilityCore/WirelessPane.swift +++ b/Sources/AirPortUtilityCore/WirelessPane.swift @@ -7,17 +7,17 @@ struct WirelessPane: View { var body: some View { PaneBox { - FormRow(title: "Network Mode:") { + FormRow(title: localized("Network Mode:")) { Picker("", selection: wirelessMode) { - Text("Create a wireless network").tag("create") + Text(localized("Create a wireless network")).tag("create") if model.showsWirelessClientModeControls || model.wireless.mode == "join" { - Text("Join a wireless network").tag("join") + Text(localized("Join a wireless network")).tag("join") } if model.showsClassicWDSWirelessControls || model.wireless.mode == "wds" { - Text("Participate in a WDS network").tag("wds") + Text(localized("Participate in a WDS network")).tag("wds") } - Text("Extend a wireless network").tag("extend") - Text("Off").tag("off") + Text(localized("Extend a wireless network")).tag("extend") + Text(localized("Off")).tag("off") } .pickerStyle(.menu) .labelsHidden() @@ -25,7 +25,7 @@ struct WirelessPane: View { } if model.wireless.mode != "off" { VStack(alignment: .leading, spacing: 12) { - FormRow(title: "Wireless Network Name:") { + FormRow(title: localized("Wireless Network Name:")) { if model.wireless.mode == "extend" || model.wireless.mode == "wds" { WirelessNetworkNameComboBox( text: $model.wireless.networkName, @@ -34,11 +34,11 @@ struct WirelessPane: View { } else { AirPortTextField( text: $model.wireless.networkName, - placeholder: "Network name", + placeholder: localized("Network name"), identifier: "wireless.network.name") } } - FormRow(title: "Wireless Security:") { + FormRow(title: localized("Wireless Security:")) { Picker("", selection: $model.wireless.security) { ForEach(model.wirelessSecurityOptions) { option in Text(option.label).tag(option.rawValue) @@ -50,41 +50,41 @@ struct WirelessPane: View { } if model.wireless.mode == "create" { FormRow(title: "") { - Toggle("Allow this network to be extended", isOn: $model.wireless.allowNetworkExtension) + Toggle(localized("Allow this network to be extended"), isOn: $model.wireless.allowNetworkExtension) .toggleStyle(.checkbox) .accessibilityIdentifier("wireless.allow.network.extension") } } if model.wireless.mode == "wds" { - FormRow(title: "WDS Mode:") { + FormRow(title: localized("WDS Mode:")) { Picker("", selection: $model.wireless.wdsMode) { - Text("WDS main").tag("main") - Text("WDS relay").tag("relay") - Text("WDS remote").tag("remote") + Text(localized("WDS main")).tag("main") + Text(localized("WDS relay")).tag("relay") + Text(localized("WDS remote")).tag("remote") } .pickerStyle(.menu) .labelsHidden() .accessibilityIdentifier("wireless.wds.mode") } - FormRow(title: "WDS Peers:") { + FormRow(title: localized("WDS Peers:")) { AirPortTextField( text: $model.wireless.wdsPeerAirPortIDs, - placeholder: "AirPort ID", + placeholder: localized("AirPort ID"), identifier: "wireless.wds.peers") } } if model.wireless.security != "none" { - FormRow(title: "Wireless Password:") { + FormRow(title: localized("Wireless Password:")) { AirPortSecureField( text: $model.wireless.password, - placeholder: "New wireless password", + placeholder: localized("New wireless password"), identifier: "wireless.password") .frame(height: 24) } FormRow(title: "Verify Password:") { AirPortSecureField( text: $model.wireless.verifyPassword, - placeholder: "Verify wireless password", + placeholder: localized("Verify wireless password"), identifier: "wireless.verify.password") .frame(height: 24) } @@ -93,7 +93,7 @@ struct WirelessPane: View { HStack { Spacer().frame(width: AirPortLayout.formControlLeading) WirelessPaneButton( - "Wireless Options...", identifier: "wireless.options.open" + localized("Wireless Options..."), identifier: "wireless.options.open" ) { showOptions = true } .frame(width: 147, height: 22) } @@ -150,7 +150,7 @@ private struct WirelessNetworkNameComboBox: NSViewRepresentable { comboBox.controlSize = .regular comboBox.focusRingType = .none comboBox.delegate = context.coordinator - comboBox.setAccessibilityTitle("Wireless Network Name") + comboBox.setAccessibilityTitle(localized("Wireless Network Name")) comboBox.setAccessibilityIdentifier("wireless.network.name") updateItems(on: comboBox, items: items) comboBox.stringValue = text diff --git a/Tests/AirPortUtilityAppTests/DiskInventoryParserTests.swift b/Tests/AirPortUtilityAppTests/DiskInventoryParserTests.swift index ee354ba..affa2be 100644 --- a/Tests/AirPortUtilityAppTests/DiskInventoryParserTests.swift +++ b/Tests/AirPortUtilityAppTests/DiskInventoryParserTests.swift @@ -3,6 +3,101 @@ import XCTest @testable import AirPortUtilityCore final class DiskInventoryParserTests: XCTestCase { + + /// The shape a real Time Capsule actually returns for MaSt: a bare array, and + /// every integer wrapped as {"type": "integer", "decimal": "...", "width": n} + /// rather than sent as a JSON number. Decoding only bare numbers and strings + /// silently produced nil sizes, so the pane showed no free space at all. + /// Identifiers here are anonymised; the structure is verbatim. + func testParsesTheWrappedIntegerFormARealDeviceSends() { + let json = """ + [{ + "blockSize": {"decimal": "512", "type": "integer", "width": 2}, + "builtin": true, + "deviceName": "wd0", + "info": "Disk 1", + "partitions": [{ + "deviceName": "dk2", + "format": "hfs", + "name": "Data2000", + "size": {"decimal": "1905681", "type": "integer", "width": 4}, + "sizeFree": {"decimal": "623863", "type": "integer", "width": 4}, + "sizeUsed": {"decimal": "1281818", "type": "integer", "width": 4}, + "uuid": {"hex": "00000000000000000000000000000001", "length": 16, "type": "bytes"} + }], + "size": {"decimal": "1907729", "type": "integer", "width": 4}, + "smartStatus": "verified", + "uuid": {"hex": "00000000000000000000000000000002", "length": 16, "type": "bytes"} + }] + """ + let records = DiskInventoryParser.parse(stdout: json) + XCTAssertEqual(records.count, 1) + let record = records[0] + XCTAssertEqual(record.name, "Data2000") + XCTAssertEqual(record.sizeFree, 623_863 * 1024 * 1024) + XCTAssertEqual(record.size, 1_905_681 * 1024 * 1024) + XCTAssertEqual(record.smartStatus, "verified") + XCTAssertTrue(record.builtIn) + } + + /// Some devices report capacity on the physical disk rather than on each + /// partition. A partition with no size of its own falls back to the disk's, + /// so the pane shows free space instead of nothing. + func testPartitionInheritsCapacityFromItsDisk() { + let json = """ + {"decoded": {"disks": [{"deviceName": "wd0", "size": 1000, "sizeFree": 400, + "partitions": [ + {"deviceName": "dk2", "name": {"type":"bytes","text":"Data"}, + "uuid": {"type":"bytes","hex":"aa"}} + ]}]}} + """ + let record = DiskInventoryParser.parse(stdout: json).first + XCTAssertEqual(record?.sizeFree, 400 * 1024 * 1024) + XCTAssertEqual(record?.size, 1000 * 1024 * 1024) + } + + /// A partition that reports its own capacity keeps it. + func testPartitionCapacityWinsOverTheDisk() { + let json = """ + {"decoded": {"disks": [{"deviceName": "wd0", "size": 1000, "sizeFree": 400, + "partitions": [ + {"deviceName": "dk2", "name": {"type":"bytes","text":"Data"}, + "uuid": {"type":"bytes","hex":"aa"}, "size": 500, "sizeFree": 250} + ]}]}} + """ + XCTAssertEqual( + DiskInventoryParser.parse(stdout: json).first?.sizeFree, 250 * 1024 * 1024) + } + + /// The device reports SMART once per physical disk, so every partition on that + /// disk must inherit it -- otherwise the pane shows a health status for one + /// volume and nothing for its sibling. + func testSMARTStatusIsCarriedFromTheDiskToItsPartitions() { + let json = """ + {"decoded": {"disks": [{"deviceName": "wd0", "builtIn": true, + "smartStatus": "Verified", + "partitions": [ + {"deviceName": "dk2", "name": {"type":"bytes","text":"Data"}, "uuid": {"type":"bytes","hex":"aa"}}, + {"deviceName": "dk3", "name": {"type":"bytes","text":"Backup"}, "uuid": {"type":"bytes","hex":"bb"}} + ]}]}} + """ + let records = DiskInventoryParser.parse(stdout: json) + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records.map(\.smartStatus), ["Verified", "Verified"]) + } + + /// A partition that reports its own status keeps it. + func testPartitionSMARTStatusWinsOverTheDisk() { + let json = """ + {"decoded": {"disks": [{"deviceName": "wd0", "smartStatus": "Verified", + "partitions": [ + {"deviceName": "dk2", "name": {"type":"bytes","text":"Data"}, + "uuid": {"type":"bytes","hex":"aa"}, "smartStatus": "Failing"} + ]}]}} + """ + XCTAssertEqual(DiskInventoryParser.parse(stdout: json).first?.smartStatus, "Failing") + } + func testDiskInventoryEmptyStateDoesNotExposeMaStRefreshInstruction() { XCTAssertEqual( DiskInventoryList.emptyStateText(didLoadInventory: false, isLoading: true), @@ -451,4 +546,13 @@ final class DiskInventoryParserTests: XCTestCase { XCTAssertTrue(result.combinedOutput.contains("no external AirPort disk partition")) XCTAssertTrue(result.redactedArguments.contains("")) } + + func testMockInventoryCarriesDriveDetail() { + let records = DiskInventoryParser.parse(stdout: AirportMockBackend.maStJSON) + let first = records.first + XCTAssertEqual(first?.vendor, "WDC WD20EARX-00PASB0") + XCTAssertEqual(first?.revision, "51.0AB51") + XCTAssertEqual(first?.smartStatus, "verified") + XCTAssertNotNil(first?.sizeUsed) + } } diff --git a/Tests/AirPortUtilityAppTests/DiskInventoryRowTests.swift b/Tests/AirPortUtilityAppTests/DiskInventoryRowTests.swift new file mode 100644 index 0000000..dc8db3b --- /dev/null +++ b/Tests/AirPortUtilityAppTests/DiskInventoryRowTests.swift @@ -0,0 +1,85 @@ +import XCTest + +@testable import AirPortUtilityCore + +/// The two small lines under a volume name in the Disks pane. +/// +/// Numbers are formatted through `ByteCountFormatter`, which follows the +/// machine's locale rather than the app language, so these build their +/// expectations the same way instead of hard-coding "1 TB" / "1 To". +final class DiskInventoryRowTests: XCTestCase { + + private let megabyte: Int64 = 1024 * 1024 + + private func byteCount(_ value: Int64) -> String { + ByteCountFormatter.string(fromByteCount: value, countStyle: .file) + } + + private func record( + size: Int64? = nil, sizeFree: Int64? = nil, sizeUsed: Int64? = nil, + vendor: String = "", revision: String = "", smartStatus: String = "" + ) -> DiskRecord { + DiskRecord( + deviceName: "dk2", name: "Data2000", format: "hfs", uuid: "uuid-1", + size: size, sizeFree: sizeFree, builtIn: true, vendor: vendor, + revision: revision, sizeUsed: sizeUsed, smartStatus: smartStatus) + } + + /// The point of the line: "1.34 TB / 2 TB" never says which number is which. + func testCapacityLineSaysWhichNumberIsTheUsedOne() throws { + let used = 500 * megabyte + let total = 1000 * megabyte + let line = try XCTUnwrap( + DiskInventoryRow.capacityLine(for: record(size: total, sizeUsed: used))) + + let usedRange = try XCTUnwrap(line.range(of: byteCount(used))) + let totalRange = try XCTUnwrap(line.range(of: byteCount(total))) + XCTAssertTrue(usedRange.upperBound <= totalRange.lowerBound, "used comes first: \(line)") + + let between = line[usedRange.upperBound.. [String: String] { + let bundle = AirPortLocalization.resourceBundle + let url = try XCTUnwrap( + bundle.url(forResource: "Localizable", withExtension: "strings", subdirectory: "\(language).lproj"), + "missing \(language).lproj/Localizable.strings") + return try XCTUnwrap(NSDictionary(contentsOf: url) as? [String: String]) + } + + func testAllExpectedLanguagesShip() { + XCTAssertEqual(AirPortLocalization.availableLanguages, Self.expectedLanguages) + } + + /// Every English key must exist in every other table, or that string silently + /// falls back to English for those users. + func testTranslationsCoverEveryEnglishKey() throws { + let english = try table("en") + XCTAssertFalse(english.isEmpty) + + for language in Self.expectedLanguages where language != "en" { + let translated = try table(language) + let missing = Set(english.keys).subtracting(translated.keys).sorted() + XCTAssertTrue( + missing.isEmpty, + "\(language) is missing \(missing.count) key(s): \(missing.prefix(10))") + } + } + + /// A translated value that still equals the English source is usually an + /// untranslated placeholder. The exceptions are per language, not global: a + /// term that is genuinely identical in German may still be a real word in + /// French, and a global allowlist would stop catching a lazy French entry. + /// + /// Every entry below is a brand name or a loanword the target language + /// actually uses. Add to it only after checking the term in that language. + private static let identicalByDesign: [String: Set] = [ + "fr": [ + "15 minutes", "30 minutes", "AirPlay", "AirPort Express", "AirPort Extreme", "Description", "Destination", "Double NAT", "Internet", "Local", "Services", "Time Capsule", "Tunnel", "Type", "Zoom", "minute", "version", + ], + "de": [ + "Accounts:", "AirPlay", "AirPort Express", "AirPort Extreme", "Firmware", "Generation:", "Host", "Host:", "Hostname:", "Internet", "Name", "Name:", "Region", "Region:", "Repo", "Repository", "Repository:", "Router", "SMART: %@", "Time Capsule", "Tunnel", "Version:", + ], + "es": [ + "AirPlay", "AirPort Express", "AirPort Extreme", "Firmware", "Host", "Host:", "Internet", "Local", "Repo", "Router", "SMART: %@", "Time Capsule", "Zoom", + ], + "it": [ + "AirPlay", "AirPort Express", "AirPort Extreme", "Default", "Firmware", "Host", "Host:", "Internet", "Password", "Password:", "Repo", "Repository", "Repository:", "Router", "SMART: %@", "Time Capsule", "Tunnel", "Wireless", "Zoom", + ], + ] + + func testTranslationsDifferFromEnglish() throws { + let english = try table("en") + + for language in Self.expectedLanguages where language != "en" { + let allowed = Self.identicalByDesign[language] ?? [] + let translated = try table(language) + for (key, value) in translated + where !allowed.contains(key) && value == english[key] { + XCTFail("\(language): \"\(key)\" is identical to English") + } + } + } + + /// Guards the allowlist itself: an entry that is no longer identical has been + /// translated since, and should be removed so the check stays meaningful. + func testIdenticalAllowlistHasNoStaleEntries() throws { + let english = try table("en") + + for (language, allowed) in Self.identicalByDesign { + let translated = try table(language) + for key in allowed.sorted() where translated[key] != english[key] { + XCTFail("\(language): \"\(key)\" is translated now; drop it from the allowlist") + } + } + } + + /// Protocol text must never be localized: ACP keys, backend flags and + /// persisted raw values are wire format, not display strings. + func testTablesContainNoProtocolTokens() throws { + for language in Self.expectedLanguages { + for key in try table(language).keys { + XCTAssertFalse(key.hasPrefix("--"), "\(language): backend flag \"\(key)\" localized") + // ACP keys are four characters shaped lowercase-lowercase-UPPER-alnum + // (syNm, raCr, peSC, wdFl). Plain four-letter words like "Help" or + // "Edit" are ordinary UI text and must not trip this. + XCTAssertNil( + key.range(of: "^[a-z]{2}[A-Z0-9][A-Za-z0-9]$", options: .regularExpression), + "\(language): possible ACP key \"\(key)\" localized") + } + } + } + + /// Pane raw values stay English because they are persisted, used for snapshot + /// file names, and used to build accessibility identifiers. + func testPaneRawValuesRemainEnglish() { + XCTAssertEqual(Pane.baseStation.rawValue, "Base Station") + XCTAssertEqual(Pane.disks.rawValue, "Disks") + XCTAssertEqual(Pane.advanced.rawValue, "Advanced") + } + + /// Region names come from Foundation rather than the .strings tables. + /// An entry that maps to no ISO region silently falls back to English in all + /// four languages, so the whole list must map. + func testEveryRegionMapsToAnISORegion() { + let unmapped = WirelessRegionOption.allCases.filter { $0.isoRegionCode == nil } + XCTAssertTrue(unmapped.isEmpty, "unmapped regions: \(unmapped.map(\.name))") + } + + func testRegionNamesTranslate() { + let france = WirelessRegionOption.allCases.first { $0.name == "Germany" } + let code = try? XCTUnwrap(france?.isoRegionCode) + XCTAssertEqual(code, "DE") + XCTAssertEqual(Locale(identifier: "fr").localizedString(forRegionCode: "DE"), "Allemagne") + XCTAssertEqual(Locale(identifier: "es").localizedString(forRegionCode: "DE"), "Alemania") + } + + /// The topology decides a device's status colour by comparing display text. + /// The "no problems" default and the message the status builder produces for + /// an empty problem list must therefore be the same string, in every + /// language -- otherwise every device renders as if it had a problem. + @MainActor + func testDefaultStatusMatchesTheNoProblemStatusMessage() { + XCTAssertEqual(BaseStationState().statusText, DeviceStatusMessage.text(problemCodes: [])) + } + + /// A conflicted merge in a .strings file compiles and ships: Swift never + /// parses these, so the markers are copied into the bundle verbatim and the + /// build succeeds. This is the only thing that would catch it. + func testTablesContainNoMergeConflictMarkers() throws { + for language in Self.expectedLanguages { + let bundle = AirPortLocalization.resourceBundle + let url = try XCTUnwrap( + bundle.url( + forResource: "Localizable", withExtension: "strings", + subdirectory: "\(language).lproj")) + let text = try String(contentsOf: url, encoding: .utf8) + for marker in ["<<<<<<<", "=======", ">>>>>>>"] { + XCTAssertFalse( + text.contains("\n" + marker), "\(language) table contains a \(marker) conflict marker") + } + } + } + + func testLookupFallsBackToTheKeyWhenUntranslated() { + let key = "A string that is deliberately absent from every table" + XCTAssertEqual(AirPortLocalization.text(key), key) + } +} diff --git a/Tests/AirPortUtilityAppTests/PublicAPISurfaceTests.swift b/Tests/AirPortUtilityAppTests/PublicAPISurfaceTests.swift index af0dbf8..ea57381 100644 --- a/Tests/AirPortUtilityAppTests/PublicAPISurfaceTests.swift +++ b/Tests/AirPortUtilityAppTests/PublicAPISurfaceTests.swift @@ -33,6 +33,8 @@ final class PublicAPISurfaceTests: XCTestCase { "Sources/AirPortUtilityCore/ContentView.swift:public static let titleBarHeight: CGFloat = 28", "Sources/AirPortUtilityCore/ContentView.swift:public struct ContentView: View {", "Sources/AirPortUtilityCore/ContentView.swift:public var body: some View {", + "Sources/AirPortUtilityCore/Localization.swift:public enum AirPortLocalization {", + "Sources/AirPortUtilityCore/Localization.swift:public static func text(_ key: String, context: String? = nil) -> String {", "Sources/AirPortUtilityCore/SnapshotRenderer.swift:public enum AirPortSnapshotRenderer {", "Sources/AirPortUtilityCore/SnapshotRenderer.swift:public static func renderAll(model: AirportAppModel, outputDirectory: URL) throws -> [URL] {", ]) diff --git a/make-app.sh b/make-app.sh new file mode 100755 index 0000000..3392af5 --- /dev/null +++ b/make-app.sh @@ -0,0 +1,238 @@ +#!/bin/bash +# +# Assemble "AirPort Utility.app" from the SwiftPM build product. +# +# SwiftPM has no .app product type, so this script wraps the executable it +# produces into a real application bundle: Info.plist, icon, the SwiftPM +# resource bundle, and the Python backend. +# +# ./make-app.sh unsigned local build +# ./make-app.sh --sign + Developer ID signature +# ./make-app.sh --sign --notarize + notarize and staple +# ./make-app.sh --sign --notarize --zip + distributable .zip +# +# Configuration (environment variables): +# BUNDLE_ID overrides Packaging/Local.xcconfig +# MARKETING_VERSION default 0.1.0 +# CURRENT_PROJECT_VERSION default 1 +# SIGN_IDENTITY default "Developer ID Application" (first match) +# NOTARY_PROFILE notarytool keychain profile name, or supply +# NOTARY_APPLE_ID + NOTARY_TEAM_ID + NOTARY_PASSWORD +# OUTPUT_DIR default dist +# ARCHS default "arm64 x86_64" (universal). +# Set ARCHS=arm64 for a faster host-only dev build. +# +set -euo pipefail + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$SCRIPT_DIR" + +APP_NAME="AirPort Utility" +EXECUTABLE_NAME="AirPort Utility" +# Bundle identifier resolution, in precedence order: +# 1. BUNDLE_ID in the environment +# 2. PRODUCT_BUNDLE_IDENTIFIER in Packaging/Local.xcconfig (gitignored, so a +# fork can set its own without modifying a committed file). Xcode reads the +# same file, which keeps the two build paths in agreement. +# 3. the neutral default below +local_bundle_id() { + [ -f Packaging/Local.xcconfig ] || return 0 + sed -n 's/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER[[:space:]]*=[[:space:]]*//p' \ + Packaging/Local.xcconfig | tail -1 | tr -d '[:space:]' +} +BUNDLE_ID=${BUNDLE_ID:-$(local_bundle_id)} +BUNDLE_ID=${BUNDLE_ID:-io.github.jackhumphries.airport-utility} +MARKETING_VERSION=${MARKETING_VERSION:-0.1.0} +CURRENT_PROJECT_VERSION=${CURRENT_PROJECT_VERSION:-1} +OUTPUT_DIR=${OUTPUT_DIR:-dist} +SIGN_IDENTITY=${SIGN_IDENTITY:-} +ARCHS=${ARCHS:-"arm64 x86_64"} +NOTARY_PROFILE=${NOTARY_PROFILE:-} + +DO_SIGN=0 +DO_NOTARIZE=0 +DO_ZIP=0 +for arg in "$@"; do + case "$arg" in + --sign) DO_SIGN=1 ;; + --notarize) DO_SIGN=1; DO_NOTARIZE=1 ;; + --zip) DO_ZIP=1 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +say() { printf '\033[1m==>\033[0m %s\n' "$*"; } + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- +# Build universal by default. Xcode's Release build is universal +# (x86_64 + arm64); matching that here keeps the two build paths equivalent and +# is what a distributed, notarized app should ship so it runs on Intel Macs too. +ARCH_FLAGS="" +for a in $ARCHS; do + ARCH_FLAGS="$ARCH_FLAGS --arch $a" +done + +say "Building release executable ($ARCHS)" +# shellcheck disable=SC2086 +swift build -c release $ARCH_FLAGS +# shellcheck disable=SC2086 +BIN_DIR=$(swift build -c release $ARCH_FLAGS --show-bin-path) + +if [ ! -x "$BIN_DIR/$EXECUTABLE_NAME" ]; then + echo "error: executable not found at $BIN_DIR/$EXECUTABLE_NAME" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Assemble the bundle +# --------------------------------------------------------------------------- +APP="$OUTPUT_DIR/$APP_NAME.app" +say "Assembling $APP" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +cp "$BIN_DIR/$EXECUTABLE_NAME" "$APP/Contents/MacOS/$EXECUTABLE_NAME" + +# SwiftPM resource bundles must sit in Contents/Resources so that +# Bundle.module resolves them through Bundle.main.resourceURL. +shopt -s nullglob +for bundle in "$BIN_DIR"/*.bundle; do + cp -R "$bundle" "$APP/Contents/Resources/" +done +shopt -u nullglob + +cp Packaging/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" + +# The Python backend ships inside the bundle so the app is self-contained. +# It must live in Contents/Resources, NOT Contents/MacOS: codesign treats +# everything in Contents/MacOS as code and rejects non-Mach-O files there with +# "code object is not signed at all". AirportConnection.defaultRepoPath() +# resolves it via Bundle.main.resourceURL. +# __pycache__ is excluded: stale bytecode would be sealed into the signature. +say "Embedding Python backend" +mkdir -p "$APP/Contents/Resources/backend" +for f in backend/*.py; do + cp "$f" "$APP/Contents/Resources/backend/" +done +chmod +x "$APP/Contents/Resources/backend/airport_backend.py" + +# Info.plist: resolve the same $(VAR) placeholders Xcode substitutes natively, +# so one plist serves both build paths. +sed -e "s|\$(EXECUTABLE_NAME)|$EXECUTABLE_NAME|g" \ + -e "s|\$(PRODUCT_BUNDLE_IDENTIFIER)|$BUNDLE_ID|g" \ + -e "s|\$(MARKETING_VERSION)|$MARKETING_VERSION|g" \ + -e "s|\$(CURRENT_PROJECT_VERSION)|$CURRENT_PROJECT_VERSION|g" \ + Packaging/Info.plist > "$APP/Contents/Info.plist" +plutil -lint "$APP/Contents/Info.plist" >/dev/null + +printf 'APPL????' > "$APP/Contents/PkgInfo" + +# --------------------------------------------------------------------------- +# Sign +# --------------------------------------------------------------------------- +if [ "$DO_SIGN" -eq 1 ]; then + if [ -z "$SIGN_IDENTITY" ]; then + SIGN_IDENTITY=$(security find-identity -v -p codesigning \ + | grep "Developer ID Application" | head -1 \ + | sed -E 's/.*"(.*)"/\1/') + fi + if [ -z "$SIGN_IDENTITY" ]; then + echo "error: no Developer ID Application identity found" >&2 + exit 1 + fi + say "Signing as: $SIGN_IDENTITY" + + # Nested bundles must be signed before the enclosing app. --deep is + # deprecated and signs nested code with the wrong entitlements, so sign + # explicitly, inside out. + # + # SwiftPM resource bundles are flat directories with no Info.plist and no + # executable. codesign rejects those as bundles ("bundle format unrecognized"), + # and they need no signature of their own -- carrying no code, they are sealed + # as ordinary resources by the enclosing app signature. So sign only real + # nested bundles, identified by the presence of an Info.plist. + shopt -s nullglob + for bundle in "$APP/Contents/Resources"/*.bundle; do + if [ -f "$bundle/Contents/Info.plist" ] || [ -f "$bundle/Info.plist" ]; then + codesign --force --timestamp --options runtime \ + --sign "$SIGN_IDENTITY" "$bundle" + else + say "Skipping code-free resource bundle: $(basename "$bundle")" + fi + done + shopt -u nullglob + + codesign --force --timestamp --options runtime \ + --entitlements Packaging/AirPortUtility.entitlements \ + --sign "$SIGN_IDENTITY" "$APP" + + say "Verifying signature" + codesign --verify --strict --verbose=2 "$APP" +fi + +# --------------------------------------------------------------------------- +# Notarize +# --------------------------------------------------------------------------- +if [ "$DO_NOTARIZE" -eq 1 ]; then + # Credentials come either from a stored keychain profile (convenient locally) + # or from Apple ID + team + app-specific password (what CI can supply). + NOTARY_ARGS=() + if [ -n "$NOTARY_PROFILE" ]; then + NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE") + elif [ -n "${NOTARY_APPLE_ID:-}" ] && [ -n "${NOTARY_TEAM_ID:-}" ] \ + && [ -n "${NOTARY_PASSWORD:-}" ]; then + NOTARY_ARGS=(--apple-id "$NOTARY_APPLE_ID" --team-id "$NOTARY_TEAM_ID" \ + --password "$NOTARY_PASSWORD") + else + cat >&2 <<'MSG' +error: no notarization credentials. + +Either store a keychain profile once: + + xcrun notarytool store-credentials "airport-utility" \ + --apple-id "you@example.com" \ + --team-id "YOURTEAMID" \ + --password "app-specific-password" + + NOTARY_PROFILE=airport-utility ./make-app.sh --sign --notarize + +or supply credentials directly (used by CI): + + NOTARY_APPLE_ID=... NOTARY_TEAM_ID=... NOTARY_PASSWORD=... \ + ./make-app.sh --sign --notarize +MSG + exit 1 + fi + + NOTARY_ZIP="$OUTPUT_DIR/notarize.zip" + say "Submitting to Apple for notarization (this can take a few minutes)" + ditto -c -k --keepParent "$APP" "$NOTARY_ZIP" + xcrun notarytool submit "$NOTARY_ZIP" "${NOTARY_ARGS[@]}" --wait + rm -f "$NOTARY_ZIP" + + say "Stapling ticket" + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + + say "Gatekeeper assessment" + spctl --assess --type exec --verbose=2 "$APP" +fi + +# --------------------------------------------------------------------------- +# Package +# --------------------------------------------------------------------------- +if [ "$DO_ZIP" -eq 1 ]; then + ZIP="$OUTPUT_DIR/$APP_NAME $MARKETING_VERSION.zip" + say "Creating $ZIP" + rm -f "$ZIP" + ditto -c -k --keepParent "$APP" "$ZIP" +fi + +# Bump the bundle mtime so Finder and LaunchServices re-read it instead of +# serving a stale cached icon. Safe after signing: mtime is not sealed. +touch "$APP" + +say "Done: $APP ($(lipo -archs "$APP/Contents/MacOS/$EXECUTABLE_NAME"))"