diff --git a/.github/scripts/ios-assert-frameworks.sh b/.github/scripts/ios-assert-frameworks.sh index 2e286f59..0e7bb17c 100644 --- a/.github/scripts/ios-assert-frameworks.sh +++ b/.github/scripts/ios-assert-frameworks.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Fails unless a built Runner.app holds the native-asset libraries (#1037). +# Fails unless a built iOS app holds the native-asset libraries (#1037). # An iOS build can compile with them missing, and the app then fails only at # runtime, when it opens storage or loads translation. set -eu -app="${1:?usage: ios-assert-frameworks.sh }" +app="${1:?usage: ios-assert-frameworks.sh }" frameworks="${app}/Frameworks" if [ ! -d "${frameworks}" ]; then diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1885c4ac..13fe7bdf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -152,7 +152,7 @@ jobs: echo "Refused, as required." - name: Assert the app holds its native-asset frameworks (#1037) if: needs.classify.outputs.docs_only != 'true' - run: bash .github/scripts/ios-assert-frameworks.sh build/ios/iphoneos/Runner.app + run: bash .github/scripts/ios-assert-frameworks.sh "build/ios/iphoneos/Offband MeshCore.app" # ios-signed.yml cannot run on a PR, so its output check is proven here, # with no secrets: every case that must not count as success (#1040). - name: "Negative: the altool check refuses anything but success" @@ -265,8 +265,19 @@ jobs: if: needs.classify.outputs.docs_only != 'true' run: | set -eu - APP=build/macos/Build/Products/Release/offband_meshcore.app - lipo -archs "${APP}/Contents/MacOS/offband_meshcore" || true + APP="build/macos/Build/Products/Release/Offband MeshCore.app" + NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleName' "${APP}/Contents/Info.plist")" + if [ "${NAME}" != "Offband MeshCore" ]; then + echo "::error::CFBundleName is '${NAME}', expected 'Offband MeshCore' (#1140)." + exit 1 + fi + codesign -d --entitlements - --xml "${APP}" > "${RUNNER_TEMP}/entitlements.plist" + SAVE="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.files.user-selected.read-write' "${RUNNER_TEMP}/entitlements.plist" || true)" + if [ "${SAVE}" != "true" ]; then + echo "::error::App lacks files.user-selected.read-write; the sandbox will refuse the Save dialog (#1143)." + exit 1 + fi + lipo -archs "${APP}/Contents/MacOS/Offband MeshCore" || true ditto -c -k --keepParent "${APP}" offband-macos.zip - name: Upload macOS build if: needs.classify.outputs.docs_only != 'true' diff --git a/.github/workflows/ios-signed.yml b/.github/workflows/ios-signed.yml index 442cc7ad..8d97f727 100644 --- a/.github/workflows/ios-signed.yml +++ b/.github/workflows/ios-signed.yml @@ -198,7 +198,12 @@ jobs: UNPACKED="${RUNNER_TEMP}/ipa" mkdir -p "${UNPACKED}" unzip -q "${IPA}" -d "${UNPACKED}" - echo "APP=${UNPACKED}/Payload/Runner.app" >> "$GITHUB_ENV" + if [ ! -d "${UNPACKED}/Payload/Offband MeshCore.app" ]; then + echo "::error::The IPA holds no 'Offband MeshCore.app' (#1140):" + ls -1 "${UNPACKED}/Payload" + exit 1 + fi + echo "APP=${UNPACKED}/Payload/Offband MeshCore.app" >> "$GITHUB_ENV" - name: Assert the IPA holds its native-asset frameworks (#1037) run: bash .github/scripts/ios-assert-frameworks.sh "${APP}" diff --git a/.github/workflows/macos-signed.yml b/.github/workflows/macos-signed.yml index 05450d8e..2e95393f 100644 --- a/.github/workflows/macos-signed.yml +++ b/.github/workflows/macos-signed.yml @@ -119,7 +119,12 @@ jobs: - name: Sign with Developer ID run: | set -eu - APP=build/macos/Build/Products/Release/offband_meshcore.app + APP="build/macos/Build/Products/Release/Offband MeshCore.app" + NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleName' "${APP}/Contents/Info.plist")" + if [ "${NAME}" != "Offband MeshCore" ]; then + echo "::error::CFBundleName is '${NAME}', expected 'Offband MeshCore' (#1140)." + exit 1 + fi echo "APP=${APP}" >> "$GITHUB_ENV" SIGN=(codesign --force --options runtime --timestamp --sign "${IDENTITY}" --keychain "${KC}") while IFS= read -r -d '' f; do @@ -132,7 +137,13 @@ jobs: done < <(find "${APP}/Contents/Frameworks" -depth -name '*.framework' -type d -print0) "${SIGN[@]}" --entitlements macos/Runner/Release.entitlements "${APP}" codesign --verify --deep --strict --verbose=2 "${APP}" - lipo -archs "${APP}/Contents/MacOS/offband_meshcore" + codesign -d --entitlements - --xml "${APP}" > "${RUNNER_TEMP}/entitlements.plist" + SAVE="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.files.user-selected.read-write' "${RUNNER_TEMP}/entitlements.plist" || true)" + if [ "${SAVE}" != "true" ]; then + echo "::error::Signed app lacks files.user-selected.read-write; the sandbox will refuse the Save dialog (#1143)." + exit 1 + fi + lipo -archs "${APP}/Contents/MacOS/Offband MeshCore" # ditto, not zip: it preserves the .app bundle's symlinks (#1108). The # same zip is what Apple notarizes and what job 3 staples. @@ -218,7 +229,7 @@ jobs: run: | set -eu ditto -x -k offband-macos-signed.zip . - APP=offband_meshcore.app + APP="Offband MeshCore.app" xcrun stapler staple "${APP}" xcrun stapler validate "${APP}" codesign --verify --deep --strict --verbose=2 "${APP}" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 90a6822a..7f9974fa 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -55,7 +55,7 @@ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Offband MeshCore.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -454,7 +454,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = "Offband MeshCore"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -474,7 +474,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Offband MeshCore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Offband MeshCore"; }; name = Debug; }; @@ -489,7 +489,7 @@ PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Offband MeshCore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Offband MeshCore"; }; name = Release; }; @@ -504,7 +504,7 @@ PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Offband MeshCore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Offband MeshCore"; }; name = Profile; }; @@ -633,7 +633,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = "Offband MeshCore"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -655,7 +655,7 @@ "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = "Offband MeshCore"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367e..75e386da 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -12,5 +12,15 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "OffbandPlatform") { + FlutterMethodChannel(name: "offband/platform", binaryMessenger: registrar.messenger()) + .setMethodCallHandler { call, result in + if call.method == "isiOSAppOnMac" { + result(ProcessInfo.processInfo.isiOSAppOnMac) + } else { + result(FlutterMethodNotImplemented) + } + } + } } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b2f3fda3..c6278a38 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -509,6 +509,8 @@ "sponsorPrompt_anytime": "You can support Offband anytime from About in Settings, or at offband.org/donate.", "sponsorPrompt_notNow": "Not now", "sponsorPrompt_never": "Never Show Again", + "iosOnMac_title": "iPhone app on a Mac", + "iosOnMac_body": "This is the iPhone version of Offband MeshCore. It's made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.", "settings_aboutLinkFailed": "Could not open the link", "settings_aboutOpenMeteoAttribution": "LOS elevation data: Open-Meteo (CC BY 4.0)", "settings_aboutPresetsAttribution": "Regional radio presets: MeshCore's suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cd0b9837..74af28ed 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1774,6 +1774,18 @@ abstract class AppLocalizations { /// **'Never Show Again'** String get sponsorPrompt_never; + /// No description provided for @iosOnMac_title. + /// + /// In en, this message translates to: + /// **'iPhone app on a Mac'** + String get iosOnMac_title; + + /// No description provided for @iosOnMac_body. + /// + /// In en, this message translates to: + /// **'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'** + String get iosOnMac_body; + /// No description provided for @settings_aboutLinkFailed. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 16074096..5d8113c7 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -985,6 +985,13 @@ class AppLocalizationsBg extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 86d2d4ac..a8f3b763 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -980,6 +980,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c31afc87..c811df90 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -971,6 +971,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 4a731d04..9f17dd69 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -981,6 +981,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index ca1692eb..0e8898e2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -985,6 +985,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index f8276eeb..8644cccb 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -983,6 +983,13 @@ class AppLocalizationsHu extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index aa91e014..57e89a1d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -983,6 +983,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index e7acd1d5..833e649f 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -956,6 +956,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 0739a01d..33106347 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -956,6 +956,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index f54677a7..3c91073a 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -977,6 +977,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 455aa673..85f1a6b2 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -985,6 +985,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index c46b31ee..cbf5b0bc 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -983,6 +983,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index a9332831..8a52b42e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -982,6 +982,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 18142185..699b6fa0 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -977,6 +977,13 @@ class AppLocalizationsSk extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f2ea851f..32c8ada2 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -974,6 +974,13 @@ class AppLocalizationsSl extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 84f5f16a..39ce744b 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -972,6 +972,13 @@ class AppLocalizationsSv extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 2877c165..ac6f78df 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -980,6 +980,13 @@ class AppLocalizationsUk extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d23c2d3d..b896227c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -948,6 +948,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sponsorPrompt_never => 'Never Show Again'; + @override + String get iosOnMac_title => 'iPhone app on a Mac'; + + @override + String get iosOnMac_body => + 'This is the iPhone version of Offband MeshCore. It\'s made for iPhone and iPad, so on a Mac it runs in a fixed phone-sized window.'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/main.dart b/lib/main.dart index 5a042e93..8c9af68a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,6 +43,7 @@ import 'widgets/storage_unavailable_banner.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; import 'widgets/keep_screen_awake.dart'; +import 'widgets/ios_on_mac_notice.dart'; import 'widgets/sponsor_prompt.dart'; void main() async { @@ -394,7 +395,9 @@ class MeshCoreApp extends StatelessWidget { }, home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) ? const ChromeRequiredScreen() - : const SponsorPromptGate(child: ScannerScreen()), + : const IosOnMacNoticeGate( + child: SponsorPromptGate(child: ScannerScreen()), + ), ); }, ), diff --git a/lib/storage/scoping_inventory.dart b/lib/storage/scoping_inventory.dart index 3afb895a..7235edf6 100644 --- a/lib/storage/scoping_inventory.dart +++ b/lib/storage/scoping_inventory.dart @@ -315,6 +315,14 @@ const List storageSurfaces = [ 'for (#1129). Follows the installed app, not a radio', owner: 'lib/widgets/sponsor_prompt.dart', ), + StorageSurface( + 'ios_on_mac_notice_shown_for', + verdict: ScopingVerdict.globalOnPurpose, + reason: + 'the app version the iPhone-on-a-Mac notice last showed for (#1140). ' + 'A fact about the installed app, not a radio', + owner: 'lib/widgets/ios_on_mac_notice.dart', + ), StorageSurface( 'anr_last_exit_ts_ms', verdict: ScopingVerdict.globalOnPurpose, diff --git a/lib/widgets/ios_on_mac_notice.dart b/lib/widgets/ios_on_mac_notice.dart new file mode 100644 index 00000000..6dd5d5ac --- /dev/null +++ b/lib/widgets/ios_on_mac_notice.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../l10n/l10n.dart'; +import '../storage/prefs_manager.dart'; +import '../utils/app_logger.dart'; +import '../utils/platform_info.dart'; + +/// Tells a Mac user they are running the iPhone app (#1140). Shown once per +/// app version. No link to the macOS download, by the owner's decision. +class IosOnMacNotice { + static const String shownForKey = 'ios_on_mac_notice_shown_for'; + static const MethodChannel _channel = MethodChannel('offband/platform'); + + static Future onLaunch( + BuildContext context, { + bool? isIOS, + Future Function()? isOnMac, + SharedPreferences? prefs, + Future Function()? installedVersion, + }) async { + if (!(isIOS ?? PlatformInfo.isIOS)) return; + if (!await (isOnMac ?? _isOnMac)()) return; + final version = await (installedVersion ?? _installedVersion)(); + if (version == null) return; + final store = prefs ?? PrefsManager.instance; + if (store.getString(shownForKey) == version) return; + await store.setString(shownForKey, version); + if (!context.mounted) return; + final l10n = context.l10n; + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.iosOnMac_title), + content: Text(l10n.iosOnMac_body), + actions: [ + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.common_ok), + ), + ], + ), + ); + } + + static Future _isOnMac() async { + try { + return await _channel.invokeMethod('isiOSAppOnMac') ?? false; + } catch (e) { + appLogger.warn('iOS-on-Mac check failed: $e', tag: 'Platform'); + return false; + } + } + + static Future _installedVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + return '${info.version}+${info.buildNumber}'; + } catch (e) { + appLogger.error('iOS-on-Mac notice: no app version: $e', tag: 'Platform'); + return null; + } + } +} + +/// Wraps the app's first screen and offers the notice after it has drawn. +class IosOnMacNoticeGate extends StatefulWidget { + const IosOnMacNoticeGate({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _IosOnMacNoticeGateState(); +} + +class _IosOnMacNoticeGateState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) IosOnMacNotice.onLaunch(context); + }); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index defe6932..237b3679 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -68,7 +68,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* meshcore_open.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = meshcore_open.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* meshcore_open.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Offband MeshCore.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -482,7 +482,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/meshcore_open.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/meshcore_open"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Offband MeshCore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Offband MeshCore"; }; name = Debug; }; @@ -497,7 +497,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/meshcore_open.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/meshcore_open"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Offband MeshCore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Offband MeshCore"; }; name = Release; }; @@ -512,7 +512,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/meshcore_open.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/meshcore_open"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Offband MeshCore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Offband MeshCore"; }; name = Profile; }; diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index b7622d77..7fb472ce 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = offband_meshcore +PRODUCT_NAME = Offband MeshCore // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 2fcad1b4..f9e0ad11 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -22,5 +22,7 @@ com.apple.security.device.camera + com.apple.security.files.user-selected.read-write + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 2b1c6948..73d0c856 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -18,5 +18,7 @@ com.apple.security.device.camera + com.apple.security.files.user-selected.read-write + diff --git a/test/widgets/ios_on_mac_notice_test.dart b/test/widgets/ios_on_mac_notice_test.dart new file mode 100644 index 00000000..a8a7390e --- /dev/null +++ b/test/widgets/ios_on_mac_notice_test.dart @@ -0,0 +1,150 @@ +// The iPhone-app-on-a-Mac notice (#1140): shown only for the iOS app running +// on a Mac, once per app version, with no link to another download. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/widgets/ios_on_mac_notice.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future _prefs() { + SharedPreferences.setMockInitialValues({}); + return SharedPreferences.getInstance(); +} + +/// One app launch. A button stands in for the gate's post-frame call. +Future _launch( + WidgetTester tester, + SharedPreferences prefs, { + bool isIOS = true, + bool onMac = true, + String version = '1.8.1+70', +}) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => IosOnMacNotice.onLaunch( + context, + isIOS: isIOS, + isOnMac: () async => onMac, + prefs: prefs, + installedVersion: () async => version, + ), + child: const Text('launch'), + ), + ), + ), + ), + ); + await tester.tap(find.text('launch')); + await tester.pumpAndSettle(); +} + +final _title = find.text('iPhone app on a Mac'); + +Future _dismiss(WidgetTester tester) async { + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('the iOS app on a Mac shows it, with OK as its only button', ( + tester, + ) async { + await _launch(tester, await _prefs()); + expect(_title, findsOneWidget); + expect(find.byType(TextButton), findsOneWidget); // the test's own button + expect(find.byType(FilledButton), findsOneWidget); + await _dismiss(tester); + expect(_title, findsNothing); + }); + + testWidgets('an iPhone or iPad never shows it', (tester) async { + await _launch(tester, await _prefs(), onMac: false); + expect(_title, findsNothing); + }); + + testWidgets('no other platform asks or shows it', (tester) async { + var asked = false; + final prefs = await _prefs(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => TextButton( + onPressed: () => IosOnMacNotice.onLaunch( + context, + isIOS: false, + isOnMac: () async => asked = true, + prefs: prefs, + installedVersion: () async => '1.8.1+70', + ), + child: const Text('launch'), + ), + ), + ), + ); + await tester.tap(find.text('launch')); + await tester.pumpAndSettle(); + expect(asked, isFalse); + expect(_title, findsNothing); + }); + + testWidgets('asks the iOS app over offband/platform, isiOSAppOnMac', ( + tester, + ) async { + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('offband/platform'), + (call) async { + calls.add(call.method); + return true; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('offband/platform'), + null, + ), + ); + final prefs = await _prefs(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => TextButton( + onPressed: () => IosOnMacNotice.onLaunch( + context, + isIOS: true, + prefs: prefs, + installedVersion: () async => '1.8.1+70', + ), + child: const Text('launch'), + ), + ), + ), + ); + await tester.tap(find.text('launch')); + await tester.pumpAndSettle(); + expect(calls, ['isiOSAppOnMac']); + expect(_title, findsOneWidget); + }); + + testWidgets('once per version: not again for the same one, again after an ' + 'update', (tester) async { + final prefs = await _prefs(); + await _launch(tester, prefs); + await _dismiss(tester); + await _launch(tester, prefs); + expect(_title, findsNothing); + await _launch(tester, prefs, version: '1.8.2+71'); + expect(_title, findsOneWidget); + }); +} diff --git a/untranslated.json b/untranslated.json index 0bce3ab7..dc0f1162 100644 --- a/untranslated.json +++ b/untranslated.json @@ -118,6 +118,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -518,6 +520,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -918,6 +922,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -1318,6 +1324,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -1718,6 +1726,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -2118,6 +2128,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -2518,6 +2530,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -2918,6 +2932,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -3318,6 +3334,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -3718,6 +3736,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -4118,6 +4138,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -4518,6 +4540,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -4918,6 +4942,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -5318,6 +5344,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -5718,6 +5746,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -6118,6 +6148,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -6518,6 +6550,8 @@ "sponsorPrompt_anytime", "sponsorPrompt_notNow", "sponsorPrompt_never", + "iosOnMac_title", + "iosOnMac_body", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware",