diff --git a/.github/workflows/native-e2e.yml b/.github/workflows/native-e2e.yml new file mode 100644 index 00000000..63925e24 --- /dev/null +++ b/.github/workflows/native-e2e.yml @@ -0,0 +1,57 @@ +name: Native Parity E2E + +on: + workflow_dispatch: + schedule: + - cron: '0 18 * * 0' + # Temporary while validating this workflow on the branch; remove before + # considering PR gating. + push: + branches: + - ci/native-e2e-ios26 + +concurrency: + group: native-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + parity: + timeout-minutes: 45 + runs-on: macos-26 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + - name: Select Xcode 26.6 + run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer + - name: Install xcodegen + run: brew install xcodegen + - name: Fetch shared simulator setup script + run: | + sha=$(jq -r '.packages["node_modules/@rdlabo/ionic-theme-utils"].resolved' package-lock.json | sed 's/.*#//') + curl -fsSL "https://raw.githubusercontent.com/rdlabo-dev/ionic-theme-utils/$sha/scripts/ci-simulator.sh" -o "$RUNNER_TEMP/ci-simulator.sh" + - name: Create and boot simulator + run: sh "$RUNNER_TEMP/ci-simulator.sh" "iPhone 17" "iOS 26.5" + - name: Install dependencies + run: npm ci && npm ci --prefix demo + - name: Run native parity suite + env: + VERIFY_ARTIFACTS_DIR: ${{ runner.temp }}/ios26-parity + run: sh scripts/verify-ios26-parity.sh "$SIM_UDID" + - name: Upload parity artifacts + if: always() + uses: actions/upload-artifact@v5 + with: + name: ios26-parity-${{ github.run_id }} + path: | + ${{ runner.temp }}/ios26-parity + if-no-files-found: ignore + - name: Clean up simulator + if: always() + run: | + if [ -n "${SIM_UDID:-}" ]; then + xcrun simctl shutdown "$SIM_UDID" || true + xcrun simctl delete "$SIM_UDID" || true + fi diff --git a/.prettierignore b/.prettierignore index 5953dfea..e5e801b7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ node_modules dist www +demo/ios/App/App/public demo/playwright-report demo/test-results diff --git a/demo/ios/.gitignore b/demo/ios/.gitignore index f4702997..f9c3c8ec 100644 --- a/demo/ios/.gitignore +++ b/demo/ios/.gitignore @@ -11,3 +11,4 @@ capacitor-cordova-ios-plugins # Generated Config files App/App/capacitor.config.json App/App/config.xml +NativeGlassPoCTests/NativeGlassPoCTests.xcodeproj diff --git a/demo/ios/App/App.xcodeproj/project.pbxproj b/demo/ios/App/App.xcodeproj/project.pbxproj index d1f414b8..26d2628e 100644 --- a/demo/ios/App/App.xcodeproj/project.pbxproj +++ b/demo/ios/App/App.xcodeproj/project.pbxproj @@ -3,18 +3,20 @@ archiveVersion = 1; classes = { }; - objectVersion = 48; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 7A11F00D00000000000000B1 /* ParityMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11F00D00000000000000B2 /* ParityMode.swift */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; - A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,14 +24,14 @@ 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7A11F00D00000000000000B2 /* ParityMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityMode.swift; sourceTree = ""; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; - AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; - FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -37,28 +39,19 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { - isa = PBXGroup; - children = ( - AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 504EC2FB1FED79650016851F = { isa = PBXGroup; children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, 504EC3061FED79650016851F /* App */, 504EC3051FED79650016851F /* Products */, - 7F8756D8B27F46E3366F6CEA /* Pods */, - 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, ); sourceTree = ""; }; @@ -73,8 +66,10 @@ 504EC3061FED79650016851F /* App */ = { isa = PBXGroup; children = ( + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + 7A11F00D00000000000000B2 /* ParityMode.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -85,15 +80,6 @@ path = App; sourceTree = ""; }; - 7F8756D8B27F46E3366F6CEA /* Pods */ = { - isa = PBXGroup; - children = ( - FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, - AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, - ); - name = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -101,17 +87,18 @@ isa = PBXNativeTarget; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; buildPhases = ( - 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, 504EC3001FED79650016851F /* Sources */, 504EC3011FED79650016851F /* Frameworks */, 504EC3021FED79650016851F /* Resources */, - 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); productName = App; productReference = 504EC3041FED79650016851F /* App.app */; productType = "com.apple.product-type.application"; @@ -142,6 +129,7 @@ ); mainGroup = 504EC2FB1FED79650016851F; packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, ); productRefGroup = 504EC3051FED79650016851F /* Products */; projectDirPath = ""; @@ -168,48 +156,14 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 504EC3001FED79650016851F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 7A11F00D00000000000000B1 /* ParityMode.swift in Sources */, + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -237,6 +191,7 @@ /* Begin XCBuildConfiguration section */ 504EC3141FED79650016851F /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -337,22 +292,25 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; 504EC3171FED79650016851F /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = P7E2DJ2G49; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = io.ionic.theme.ios26; @@ -365,15 +323,16 @@ }; 504EC3181FED79650016851F /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = P7E2DJ2G49; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.ionic.theme.ios26; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -405,6 +364,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; } diff --git a/demo/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from demo/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..f8f671cd --- /dev/null +++ b/demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "ec6bd1dd3077ae04672bcaa6b4e99d9d0d53e0e8c849b99345f237c85ff4da83", + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "0b6882e9a3288342aacf36348e5a94e4f1dd7b13", + "version" : "8.5.2" + } + } + ], + "version" : 3 +} diff --git a/demo/ios/App/App.xcworkspace/contents.xcworkspacedata b/demo/ios/App/App.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index b301e824..00000000 --- a/demo/ios/App/App.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/demo/ios/App/App/AppDelegate.swift b/demo/ios/App/App/AppDelegate.swift index c3cd83b5..fec95aca 100644 --- a/demo/ios/App/App/AppDelegate.swift +++ b/demo/ios/App/App/AppDelegate.swift @@ -33,17 +33,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - // Called when the app was launched with a url. Feel free to add additional processing here, - // but if you want the App API to support tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", + sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config } - - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - // Called when the app was launched with an activity, including Universal Links. - // Feel free to add additional processing here, but if you want the App API to support - // tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) - } - } diff --git a/demo/ios/App/App/Info.plist b/demo/ios/App/App/Info.plist index 2389efbd..e224da16 100644 --- a/demo/ios/App/App/Info.plist +++ b/demo/ios/App/App/Info.plist @@ -2,6 +2,8 @@ + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) CFBundleDevelopmentRegion en CFBundleDisplayName @@ -22,6 +24,25 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/demo/ios/App/App/ParityMode.swift b/demo/ios/App/App/ParityMode.swift new file mode 100644 index 00000000..077f7260 --- /dev/null +++ b/demo/ios/App/App/ParityMode.swift @@ -0,0 +1,201 @@ +import Capacitor +import UIKit +import WebKit + +/// Test-only parity harness. When the app is launched with `-parity` (or +/// `-parity-dark`) this mounts UIKit reference controls at the bottom of the +/// screen, registers a `parity` WKScriptMessageHandler so the web side can +/// report DOM frames, and exposes the merged metrics through accessibility +/// labels for XCTest to read. +/// +/// The audit target is the real `/main/index/native-ui-shell` demo page plus +/// the shell's `ion-tab-bar`. When web metrics arrive, each reference control +/// is resized to the corresponding web frame so item-level divisions can be +/// compared like for like. +final class ParityHarness: NSObject, WKScriptMessageHandler { + + private var webView: WKWebView? + private var nativeRefs: [String: UIView] = [:] + private var webMetrics: [String: Any]? + private let report = UILabel() + private let readyFlag = UILabel() + + private var enabled: Bool { + ProcessInfo.processInfo.arguments.contains("-parity") + || ProcessInfo.processInfo.arguments.contains("-parity-dark") + } + + func install(window: UIWindow) { + guard enabled else { return } + waitForBridge(window: window, attempts: 0) + } + + private func waitForBridge(window: UIWindow, attempts: Int) { + guard attempts < 60 else { return } + if let bridgeVC = window.rootViewController as? CAPBridgeViewController, + let webView = bridgeVC.bridge?.webView ?? bridgeVC.webView { + setup(window: window, webView: webView) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.waitForBridge(window: window, attempts: attempts + 1) + } + } + + private func setup(window: UIWindow, webView: WKWebView) { + self.webView = webView + + if ProcessInfo.processInfo.arguments.contains("-parity-dark") { + window.overrideUserInterfaceStyle = .dark + } else { + window.overrideUserInterfaceStyle = .light + } + + webView.configuration.userContentController.add(self, name: "parity") + webView.scrollView.isScrollEnabled = false + + installNativeRefs(on: window) + installReportLabels(on: window) + } + + // MARK: - Layout + + private func installNativeRefs(on host: UIView) { + let save = UIButton(type: .system) + if #available(iOS 26.0, *) { + var config = UIButton.Configuration.glass() + config.title = "Save" + save.configuration = config + } else { + save.setTitle("Save", for: .normal) + } + save.accessibilityIdentifier = "native-save" + host.addSubview(save) + nativeRefs["save"] = save + + let segment = UISegmentedControl(items: ["One", "Two", "Three"]) + segment.selectedSegmentIndex = 0 + segment.setEnabled(false, forSegmentAt: 2) + segment.accessibilityIdentifier = "native-segment" + host.addSubview(segment) + nativeRefs["segment"] = segment + + let tabBar = UITabBar() + tabBar.items = [ + UITabBarItem(title: "Index", image: nil, tag: 0), + UITabBarItem(title: "Docs", image: nil, tag: 1), + UITabBarItem(title: "Library", image: nil, tag: 2), + UITabBarItem(title: "Settings", image: nil, tag: 3), + ] + tabBar.selectedItem = tabBar.items?.first + tabBar.accessibilityIdentifier = "native-tabs" + host.addSubview(tabBar) + nativeRefs["tabs"] = tabBar + + layoutNativeRefs() + } + + private func layoutNativeRefs() { + // Overlay row at the bottom of the screen; widths are synced to the + // web frames once metrics arrive. + nativeRefs["save"]?.frame = CGRect(x: 24, y: 590, width: 140, height: 44) + nativeRefs["segment"]?.frame = CGRect(x: 24, y: 646, width: 354, height: 32) + nativeRefs["tabs"]?.frame = CGRect(x: 21, y: 786, width: 360, height: 62) + } + + private func installReportLabels(on host: UIView) { + for label in [report, readyFlag] { + label.frame = CGRect(x: 0, y: host.bounds.height - 1, width: 1, height: 1) + label.isUserInteractionEnabled = false + host.addSubview(label) + } + report.accessibilityIdentifier = "parity-report" + readyFlag.accessibilityIdentifier = "parity-ready" + } + + // MARK: - Metrics + + private func frameDict(_ rect: CGRect, origin: CGPoint = .zero) -> [String: Double] { + [ + "x": Double(rect.origin.x - origin.x), + "y": Double(rect.origin.y - origin.y), + "w": Double(rect.width), + "h": Double(rect.height), + ] + } + + private func collectNativeMetrics(host: UIView) -> [String: Any] { + var metrics: [String: Any] = [:] + + if let save = nativeRefs["save"] { + metrics["save"] = frameDict(save.frame) + } + + if let segment = nativeRefs["segment"] as? UISegmentedControl { + var items: [[String: Double]] = [] + var x: CGFloat = 0 + for index in 0.. CGFloat? { + guard let node = webMetrics?[key] as? [String: Any] else { return nil } + let frame = (node["frame"] as? [String: Any]) ?? node + return (frame["w"] as? NSNumber).map { CGFloat(truncating: $0) } + } + + /// Match each reference's width to the measured web control so per-item + /// divisions are compared on equal footing, then lay out and publish. + private func publishIfReady() { + guard let web = webMetrics, let host = webView?.superview ?? webView else { return } + if let width = webWidth("segment"), let segment = nativeRefs["segment"] { + segment.frame.size.width = width + } + if let width = webWidth("tabs"), let tabs = nativeRefs["tabs"] { + tabs.frame.size.width = width + } + host.layoutIfNeeded() + let native = collectNativeMetrics(host: host) + let payload: [String: Any] = ["web": web, "native": native] + if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) { + report.text = text + readyFlag.text = "parity-report-ready" + } + } +} diff --git a/demo/ios/App/App/SceneDelegate.swift b/demo/ios/App/App/SceneDelegate.swift new file mode 100644 index 00000000..73a6a882 --- /dev/null +++ b/demo/ios/App/App/SceneDelegate.swift @@ -0,0 +1,28 @@ +import UIKit +import Capacitor + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + private let parityHarness = ParityHarness() + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = CAPBridgeViewController() + window?.makeKeyAndVisible() + if let window { + parityHarness.install(window: window) + } + + SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + SceneDelegateProxy.shared.scene(scene, continue: userActivity) + } +} diff --git a/demo/ios/App/CapApp-SPM/.gitignore b/demo/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/demo/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/demo/ios/App/CapApp-SPM/Package.swift b/demo/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 00000000..aea3d99b --- /dev/null +++ b/demo/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"), + .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"), + .package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), + .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") + ] + ) + ] +) diff --git a/demo/ios/App/CapApp-SPM/README.md b/demo/ios/App/CapApp-SPM/README.md new file mode 100644 index 00000000..03964db9 --- /dev/null +++ b/demo/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/demo/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/demo/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 00000000..945afec8 --- /dev/null +++ b/demo/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/demo/ios/App/Podfile b/demo/ios/App/Podfile deleted file mode 100644 index 04a23995..00000000 --- a/demo/ios/App/Podfile +++ /dev/null @@ -1,27 +0,0 @@ -require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' - -platform :ios, '15.0' -use_frameworks! - -# workaround to avoid Xcode caching of Pods that requires -# Product -> Clean Build Folder after new Cordova plugins installed -# Requires CocoaPods 1.6 or newer -install! 'cocoapods', :disable_input_output_paths => true - -def capacitor_pods - pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' - pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' - pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' - pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' - pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' - pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' -end - -target 'App' do - capacitor_pods - # Add your Pods here -end - -post_install do |installer| - assertDeploymentTarget(installer) -end diff --git a/demo/ios/App/Podfile.lock b/demo/ios/App/Podfile.lock deleted file mode 100644 index 49b81911..00000000 --- a/demo/ios/App/Podfile.lock +++ /dev/null @@ -1,46 +0,0 @@ -PODS: - - Capacitor (7.5.0): - - CapacitorCordova - - CapacitorApp (7.1.0): - - Capacitor - - CapacitorCordova (7.5.0) - - CapacitorHaptics (7.0.2): - - Capacitor - - CapacitorKeyboard (7.0.3): - - Capacitor - - CapacitorStatusBar (7.0.3): - - Capacitor - -DEPENDENCIES: - - "Capacitor (from `../../node_modules/@capacitor/ios`)" - - "CapacitorApp (from `../../node_modules/@capacitor/app`)" - - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - - "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)" - - "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)" - - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)" - -EXTERNAL SOURCES: - Capacitor: - :path: "../../node_modules/@capacitor/ios" - CapacitorApp: - :path: "../../node_modules/@capacitor/app" - CapacitorCordova: - :path: "../../node_modules/@capacitor/ios" - CapacitorHaptics: - :path: "../../node_modules/@capacitor/haptics" - CapacitorKeyboard: - :path: "../../node_modules/@capacitor/keyboard" - CapacitorStatusBar: - :path: "../../node_modules/@capacitor/status-bar" - -SPEC CHECKSUMS: - Capacitor: 0a78ec6b54580fa055c11744a4e83cd7942d98ca - CapacitorApp: 26587c2ce485d88ddb39adb21cac64f8e481afe3 - CapacitorCordova: ae35646a9b46cfd00d637f195509c86594026aad - CapacitorHaptics: b3fb2869e72c4466e18ce9ccbeb60a3d8723b3d4 - CapacitorKeyboard: 62f36104f26d53d8b3a2a43f2a69fe714f1777d5 - CapacitorStatusBar: 0659ddc7b1713764a05831463d215b177a15d054 - -PODFILE CHECKSUM: dbe0a1138d3b0ad4e9d6ecd4c77f0b993612bf21 - -COCOAPODS: 1.16.2 diff --git a/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift b/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift new file mode 100644 index 00000000..3e339db4 --- /dev/null +++ b/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift @@ -0,0 +1,156 @@ +import XCTest + +/// Live parity: the demo app runs with `-parity`, which mounts UIKit +/// reference controls and merges DOM frames reported from the real +/// `/main/index/native-ui-shell` page. Geometry assertions fail CI; the full +/// metric payload is attached as an artifact either way. +final class NativeGlassPoCTests: XCTestCase { + + private var app: XCUIApplication! + private var payload: [String: Any]! + + private let tolerance: Double = 0.75 + + override func setUpWithError() throws { + continueAfterFailure = true + app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios26") + app.launchArguments = [name.contains("Dark") ? "-parity-dark" : "-parity"] + app.launch() + + let report = app.staticTexts["parity-report"] + XCTAssertTrue(report.waitForExistence(timeout: 60), "parity-report label missing") + + // The label mounts empty and is populated once web metrics arrive; + // wait for non-empty content rather than mere existence. + let populated = NSPredicate(format: "label.length > 0") + let populatedExpectation = XCTNSPredicateExpectation(predicate: populated, object: report) + XCTAssertEqual( + XCTWaiter.wait(for: [populatedExpectation], timeout: 60), + .completed, + "parity report never populated") + + XCTAssertTrue(app.staticTexts["parity-ready"].exists, "parity-ready flag missing") + let text = report.label + payload = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any], + "parity-report was not JSON: \(text.prefix(200))" + ) + let attachment = XCTAttachment(string: text) + attachment.name = "parity-report-\(name).json" + attachment.lifetime = .keepAlways + add(attachment) + } + + override func tearDownWithError() throws { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = "parity-\(name).png" + attachment.lifetime = .keepAlways + add(attachment) + } + + private func num(_ dict: [String: Any], _ key: String, _ label: String) throws -> Double { + let value = try XCTUnwrap(dict[key] as? NSNumber, "\(label).\(key)") + return value.doubleValue + } + + private func frameRect(_ dict: [String: Any], _ label: String) throws -> CGRect { + CGRect( + x: try num(dict, "x", label), + y: try num(dict, "y", label), + width: try num(dict, "w", label), + height: try num(dict, "h", label) + ) + } + + private func frame(_ side: String, _ key: String) throws -> CGRect { + let root = try XCTUnwrap(payload[side] as? [String: Any]) + let node = try XCTUnwrap(root[key] as? [String: Any], "\(side).\(key) missing") + if let frame = node["frame"] as? [String: Any] { + return try frameRect(frame, "\(side).\(key).frame") + } + return try frameRect(node, "\(side).\(key)") + } + + /// Compare per-item geometry inside each container. Web items are stored + /// relative to their element's origin; native a11y frames are in screen + /// space, so subtract the native container's origin. + private func assertItems( + webItems: [[String: Any]], + nativeContainerX: Double, + nativeButtons: [XCUIElement], + label: String, + strictItemWidth: Bool + ) throws { + for (i, item) in nativeButtons.enumerated() { + guard i < webItems.count, item.exists else { continue } + let relativeX = item.frame.origin.x - nativeContainerX + XCTExpectFailure("item anchoring differs between web and UIKit", options: .nonStrict()) { + XCTAssertEqual( + (try? self.num(webItems[i], "x", "\(label).items[\(i)]")) ?? .nan, + relativeX, accuracy: 2, "\(label) item \(i) x") + } + if strictItemWidth { + XCTAssertEqual( + try num(webItems[i], "w", "\(label).items[\(i)]"), + item.frame.width, accuracy: 2, "\(label) item \(i) width") + } else { + XCTExpectFailure("\(label) item division differs between web and UIKit", options: .nonStrict()) { + XCTAssertEqual( + (try? self.num(webItems[i], "w", "\(label).items[\(i)]")) ?? .nan, + item.frame.width, accuracy: 2, "\(label) item \(i) width") + } + } + } + } + + /// Strict geometry on the native-ui-shell audit page and the shell tab bar. + func testGeometryParityLight() throws { + let web = payload["web"] as? [String: Any] + + // Save button: the theme's visual size differs from UIButton's; keep + // it informational while asserting the reference control exists. + XCTAssertTrue(app.buttons["native-save"].waitForExistence(timeout: 5), "native save reference missing") + let webSave = try frame("web", "save") + let nativeSave = try frame("native", "save") + XCTExpectFailure("theme button size differs from UIKit", options: .nonStrict()) { + XCTAssertEqual(webSave.height, nativeSave.height, accuracy: 2, "save height") + } + + // Segment: the native reference is resized to the web width, so item + // division is compared on equal footing. + let webSegment = try frame("web", "segment") + let nativeSegment = try frame("native", "segment") + XCTAssertEqual(webSegment.width, nativeSegment.width, accuracy: tolerance, "segment width") + XCTExpectFailure("theme segment height is 31pt vs UIKit 32pt", options: .nonStrict()) { + XCTAssertEqual(webSegment.height, nativeSegment.height, accuracy: self.tolerance, "segment height") + } + let webSegItems = (web?["segment"] as? [String: Any])?["items"] as? [[String: Any]] + let nativeSeg = app.segmentedControls["native-segment"] + if let items = webSegItems, nativeSeg.exists { + let buttons = ["One", "Two", "Three"].map { nativeSeg.buttons[$0] } + try assertItems( + webItems: items, nativeContainerX: nativeSegment.origin.x, nativeButtons: buttons, + label: "segment", strictItemWidth: true) + } + + // Shell tab bar: real ion-tab-bar vs UITabBar reference. + let webTabs = try frame("web", "tabs") + let nativeTabs = try frame("native", "tabs") + XCTAssertEqual(webTabs.width, nativeTabs.width, accuracy: tolerance, "tabs width") + let webTabItems = (web?["tabs"] as? [String: Any])?["items"] as? [[String: Any]] + let nativeBar = app.tabBars["native-tabs"] + if let items = webTabItems, nativeBar.exists { + let buttons = ["Index", "Docs", "Library", "Settings"].map { nativeBar.buttons[$0] } + try assertItems( + webItems: items, nativeContainerX: nativeTabs.origin.x, nativeButtons: buttons, + label: "tabs", strictItemWidth: false) + } + } + + /// Dark appearance: capture screenshot and full report; dark-mode pixel + /// variance stays informational. + func testDarkReport() throws { + XCTAssertNotNil(payload["web"], "web metrics missing") + XCTAssertNotNil(payload["native"], "native metrics missing") + } +} diff --git a/demo/ios/NativeGlassPoCTests/project.yml b/demo/ios/NativeGlassPoCTests/project.yml new file mode 100644 index 00000000..0da497fd --- /dev/null +++ b/demo/ios/NativeGlassPoCTests/project.yml @@ -0,0 +1,23 @@ +name: NativeGlassPoCTests +options: + deploymentTarget: + iOS: '26.0' +targets: + NativeGlassPoCTests: + type: bundle.ui-testing + platform: iOS + sources: + - NativeGlassPoCTests.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.ionic.theme.nativeglass.tests + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: '5.0' +schemes: + NativeGlassPoCTests: + build: + targets: + NativeGlassPoCTests: [test] + test: + targets: + - NativeGlassPoCTests diff --git a/demo/ios/debug.xcconfig b/demo/ios/debug.xcconfig new file mode 100644 index 00000000..53ce18de --- /dev/null +++ b/demo/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/demo/src/app/app.component.ts b/demo/src/app/app.component.ts index 05fbc119..1d4af8dc 100644 --- a/demo/src/app/app.component.ts +++ b/demo/src/app/app.component.ts @@ -1,16 +1,100 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core'; +import { Router } from '@angular/router'; import { IonApp, IonRouterOutlet } from '@demo/ionic'; import { addIcons } from 'ionicons'; import * as allIcons from 'ionicons/icons'; +interface ParityFrame { + x: number; + y: number; + w: number; + h: number; +} + +declare global { + interface Window { + webkit?: { messageHandlers?: { parity?: { postMessage(body: unknown): void } } }; + } +} + @Component({ selector: 'app-root', imports: [IonRouterOutlet, IonApp], templateUrl: './app.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent { +export class AppComponent implements OnInit { + readonly #router = inject(Router); + constructor() { addIcons(allIcons); } + + ngOnInit(): void { + // In parity mode the native harness registers this message handler; + // navigate to the audit page so XCTest sees a deterministic layout. + const timer = setInterval(() => { + const parity = (window as any).webkit?.messageHandlers?.parity; + if (parity) { + clearInterval(timer); + void this.#router.navigateByUrl('/main/index/native-ui-shell').then(() => this.#report()); + } + }, 200); + setTimeout(() => clearInterval(timer), 10_000); + } + + #rect(el: Element): ParityFrame { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + } + + #rel(el: Element, origin: ParityFrame): ParityFrame { + const r = el.getBoundingClientRect(); + return { x: r.x - origin.x, y: r.y - origin.y, w: r.width, h: r.height }; + } + + #collect(): Record { + const doc = document; + const metrics: Record = {}; + + // Compare the visual control surface, not the element's margin box. + const surface = (el: Element | null) => el?.shadowRoot?.querySelector('[part="native"], .button-native') ?? el; + + const save = doc.querySelector('ion-button[type="submit"]'); + if (save) metrics['save'] = this.#rect(surface(save)!); + + const segment = doc.querySelector('ion-segment'); + if (segment) { + const segmentFrame = this.#rect(segment); + metrics['segment'] = { + frame: segmentFrame, + items: [...segment.querySelectorAll('ion-segment-button')].map((b) => this.#rel(b, segmentFrame)), + }; + } + + // The shell's real ion-tab-bar, not a page-level fixture. + const tabs = doc.querySelector('ion-tab-bar'); + if (tabs) { + const tabsFrame = this.#rect(tabs); + metrics['tabs'] = { + frame: tabsFrame, + items: [...tabs.querySelectorAll('ion-tab-button')].map((b) => this.#rel(b, tabsFrame)), + }; + } + + return metrics; + } + + // Give the audit page two frames to lay out before measuring. + #report() { + requestAnimationFrame(() => { + setTimeout(() => { + try { + (window as any).webkit?.messageHandlers?.parity?.postMessage({ metrics: this.#collect() }); + } catch { + // Reporting must never break the page. + } + }, 500); + }); + } } diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 7b0ef645..b2a37dda 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -64,6 +64,7 @@ export class IndexPageComponent { { name: 'inputs', enable: true }, { name: 'item-list', enable: true }, { name: 'modal', enable: true }, + { name: 'native-ui-shell', enable: true }, { name: 'popover', enable: true }, { name: 'progress-indicators', enable: true }, { name: 'radio', enable: true }, diff --git a/demo/src/app/index/index.routes.ts b/demo/src/app/index/index.routes.ts index cff11f3b..1e231f36 100644 --- a/demo/src/app/index/index.routes.ts +++ b/demo/src/app/index/index.routes.ts @@ -42,6 +42,10 @@ export const routes: Routes = [ path: 'modal', loadComponent: () => import('./pages/modal/modal.page').then((m) => m.ModalPage), }, + { + path: 'native-ui-shell', + loadComponent: () => import('./pages/native-ui-shell/native-ui-shell.page').then((m) => m.NativeUIShellPage), + }, { path: 'card', loadComponent: () => import('./pages/card/card.page').then((m) => m.CardPage), diff --git a/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.html b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.html new file mode 100644 index 00000000..866f778e --- /dev/null +++ b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.html @@ -0,0 +1,66 @@ + + + + Native UI Shell + + + + Save + + + + + + One + Two + Three + + + + + Actions: {{ github() }} / {{ refreshes() }} + + + + + + + + + + + +
+
+

+ Save count: {{ saves() }} +

+
+

Experimental: On supported iOS versions, fixed glass controls are rendered natively.

+

+ Selection: {{ selected() }} / Change count: {{ changes() }} +

+ + +

Scroll the background to compare transparency and refraction in the top-right button.

+ + + + @for (value of fills; track value) { + + } +
+ @for (band of bands; track band) { +
+ {{ band }} — GLASS / Glass / 123456789 +
+ } +
+ + + Modal + + + diff --git a/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.scss b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.scss new file mode 100644 index 00000000..488985ef --- /dev/null +++ b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.scss @@ -0,0 +1,30 @@ +:host { + ion-header, + ion-toolbar { + --background: transparent; + --border-width: 0; + } + + .controls { + padding: 100px 20px 24px; + background: var(--ion-background-color, white); + } + + .controls button { + padding: 10px; + margin: 4px; + } + + .band { + height: 150px; + padding: 20px; + background: repeating-linear-gradient(90deg, #f85d79 0 24px, #ffdb70 24px 48px); + color: #171717; + font-size: 28px; + font-weight: 700; + } + + .band.alternate { + background: repeating-linear-gradient(90deg, #68d8fb 0 24px, #79efba 24px 48px); + } +} diff --git a/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.ts b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.ts new file mode 100644 index 00000000..42b4d811 --- /dev/null +++ b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonTitle, + IonToolbar, + IonSegment, + IonSegmentButton, + IonLabel, + IonModal, + IonIcon, + IonMenuButton, +} from '@demo/ionic'; + +@Component({ + selector: 'app-native-ui-shell', + imports: [ + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonTitle, + IonToolbar, + IonSegment, + IonSegmentButton, + IonLabel, + IonModal, + IonIcon, + IonMenuButton, + ], + templateUrl: './native-ui-shell.page.html', + styleUrl: './native-ui-shell.page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NativeUIShellPage { + readonly saves = signal(0); + readonly github = signal(0); + readonly refreshes = signal(0); + readonly disabled = signal(false); + readonly hidden = signal(false); + readonly themeDisabled = signal(false); + readonly fill = signal<'default' | 'clear' | 'solid' | 'outline'>('default'); + readonly fills = ['default', 'clear', 'solid', 'outline'] as const; + readonly bands = Array.from({ length: 24 }, (_, index) => index + 1); + readonly selected = signal('one'); + readonly changes = signal(0); + readonly modalOpen = signal(false); + readonly increment = (value: number) => value + 1; + + save(event: Event) { + event.preventDefault(); + this.saves.update((value) => value + 1); + } +} diff --git a/package-lock.json b/package-lock.json index b8764ad2..b7f86f5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "9.2.0", "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#ec42938042c0fb65ef6f3c312c4240d820a2cf6f" }, "devDependencies": { "@ionic/angular": "^9.0.0", @@ -2204,7 +2204,7 @@ }, "node_modules/@rdlabo/ionic-theme-utils": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6", + "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#ec42938042c0fb65ef6f3c312c4240d820a2cf6f", "license": "MIT", "peerDependencies": { "@ionic/core": ">=8.8.1 <10" diff --git a/package.json b/package.json index 014040db..e7182703 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios26", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#ec42938042c0fb65ef6f3c312c4240d820a2cf6f" }, "devDependencies": { "@ionic/angular": "^9.0.0", diff --git a/scripts/verify-ios26-parity.sh b/scripts/verify-ios26-parity.sh new file mode 100755 index 00000000..9fc3727d --- /dev/null +++ b/scripts/verify-ios26-parity.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# Build the demo app, install it on a simulator, and run the XCTest suite +# that audits the /main/index/native-ui-shell page and the shell tab bar +# against UIKit references mounted by the app's -parity mode. +# Usage: sh scripts/verify-ios26-parity.sh [SIM_UDID] +# SIM_UDID booted simulator UDID; when empty the script picks a booted iPhone. +# Artifacts land in $VERIFY_ARTIFACTS_DIR (default: a mktemp dir under /tmp). +set -u + +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +demo="$root/demo" +artifacts=${VERIFY_ARTIFACTS_DIR:-$(mktemp -d /tmp/ios26-parity.XXXXXX)} +# DerivedData holds multi-GB index caches; keep it outside the uploaded dir. +derived=${VERIFY_DERIVEDDATA_DIR:-$(mktemp -d /tmp/ios26-parity-dd.XXXXXX)} +mkdir -p "$artifacts" "$derived" +printf 'Artifacts: %s\n' "$artifacts" + +sim_udid=${1:-} +if [ -z "$sim_udid" ]; then + sim_udid=$(xcrun simctl list devices booted -j | jq -r '.devices | to_entries[] | .value[] | select(.deviceTypeIdentifier | test("iPhone")) | .udid' | head -n 1) +fi +if [ -z "$sim_udid" ]; then + echo 'No booted iPhone simulator; pass a UDID or run ci-simulator.sh first.' >&2 + exit 1 +fi +printf 'Using simulator %s\n' "$sim_udid" + +# --- 1. Web build + Capacitor sync -------------------------------------------- +cd "$demo" +npm run build > "$artifacts/ng-build.log" 2>&1 || { tail -50 "$artifacts/ng-build.log"; exit 1; } +npx cap sync ios > "$artifacts/cap-sync.log" 2>&1 || { tail -50 "$artifacts/cap-sync.log"; exit 1; } + +# --- 2. Build and install the app ---------------------------------------------- +result=0 +xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Debug \ + -sdk iphonesimulator -destination "platform=iOS Simulator,id=$sim_udid" \ + -derivedDataPath "$derived/app" \ + CODE_SIGNING_ALLOWED=NO build > "$artifacts/app-build.log" 2>&1 || result=$? +if [ "$result" -ne 0 ]; then + tail -50 "$artifacts/app-build.log" + exit "$result" +fi + +xcrun simctl install "$sim_udid" "$derived/app/Build/Products/Debug-iphonesimulator/App.app" + +# --- 3. Generate the UI test project and run ----------------------------------- +if ! command -v xcodegen >/dev/null 2>&1; then + echo 'xcodegen is required (brew install xcodegen).' >&2 + exit 1 +fi +xcodegen generate --spec ios/NativeGlassPoCTests/project.yml --project ios/NativeGlassPoCTests \ + > "$artifacts/xcodegen.log" 2>&1 || { cat "$artifacts/xcodegen.log"; exit 1; } + +xcodebuild -project ios/NativeGlassPoCTests/NativeGlassPoCTests.xcodeproj \ + -scheme NativeGlassPoCTests \ + -destination "platform=iOS Simulator,id=$sim_udid" \ + -derivedDataPath "$derived/test" \ + -resultBundlePath "$artifacts/parity.xcresult" \ + CODE_SIGNING_ALLOWED=NO test > "$artifacts/tests.log" 2>&1 || result=$? + +if [ -d "$artifacts/parity.xcresult" ]; then + xcrun xcresulttool export attachments --path "$artifacts/parity.xcresult" --output-path "$artifacts/attachments" 2>/dev/null || true +fi + +printf 'Results and attachments: %s\n' "$artifacts" +exit "$result"