From 4abc551f9eb7923da76008e85f36d9efecd7f512 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 02:16:46 +0000 Subject: [PATCH 1/6] chore(android): bump AGP to 8.6.1 --- apps/android/build.gradle.kts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index 8c8f431e9..3504169f1 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - id("com.android.application") version "8.5.2" apply false + id("com.android.application") version "8.6.1" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false } - From 20abf3109391f842a32107086fe34b01591b4503 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 02:17:44 +0000 Subject: [PATCH 2/6] test(ios): share scheme and add deep link tests --- apps/ios/Tests/DeepLinkParserTests.swift | 43 ++++++++++++++++++++++++ apps/ios/project.yml | 10 ++++++ 2 files changed, 53 insertions(+) create mode 100644 apps/ios/Tests/DeepLinkParserTests.swift diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift new file mode 100644 index 000000000..35615d56a --- /dev/null +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -0,0 +1,43 @@ +import ClawdisKit +import Foundation +import Testing + +@Suite struct DeepLinkParserTests { + @Test func parseRejectsNonClawdisScheme() { + let url = URL(string: "https://example.com/agent?message=hi")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseRejectsEmptyMessage() { + let url = URL(string: "clawdis://agent?message=%20%20%0A")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseAgentLinkParsesCommonFields() { + let url = URL(string: "clawdis://agent?message=Hello&deliver=1&sessionKey=node-iris&thinking=low&timeoutSeconds=30")! + #expect( + DeepLinkParser.parse(url) == .agent( + .init( + message: "Hello", + sessionKey: "node-iris", + thinking: "low", + deliver: true, + to: nil, + channel: nil, + timeoutSeconds: 30, + key: nil))) + } + + @Test func parseRejectsNegativeTimeoutSeconds() { + let url = URL(string: "clawdis://agent?message=Hello&timeoutSeconds=-1")! + #expect(DeepLinkParser.parse(url) == .agent(.init( + message: "Hello", + sessionKey: nil, + thinking: nil, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: nil))) + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 2da19ab13..1c4dc1c58 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -9,6 +9,16 @@ packages: ClawdisKit: path: ../shared/ClawdisKit +schemes: + Clawdis: + shared: true + build: + targets: + Clawdis: all + test: + targets: + - ClawdisTests + targets: Clawdis: type: application From 138f4bd85030b3cd60a84366a6e83917003b7f3e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 02:17:54 +0000 Subject: [PATCH 3/6] fix(ios): show connection status badge --- apps/ios/Sources/RootTabs.swift | 109 +++++++++++++++----------------- 1 file changed, 51 insertions(+), 58 deletions(-) diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 3b450647b..fe3d2fe76 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit struct RootTabs: View { @EnvironmentObject private var appModel: NodeAppModel @@ -13,29 +14,13 @@ struct RootTabs: View { .tabItem { Label("Voice", systemImage: "mic") } SettingsTab() - .tabItem { - VStack { - ZStack(alignment: .topTrailing) { - Image(systemName: "gearshape") - Circle() - .fill(self.settingsIndicatorColor) - .frame(width: 9, height: 9) - .overlay( - Circle() - .stroke(.black.opacity(0.2), lineWidth: 0.5)) - .shadow( - color: self.settingsIndicatorGlowColor, - radius: self.settingsIndicatorGlowRadius, - x: 0, - y: 0) - .scaleEffect(self.settingsIndicatorScale) - .opacity(self.settingsIndicatorOpacity) - .offset(x: 7, y: -2) - } - Text("Settings") - } - } + .tabItem { Label("Settings", systemImage: "gearshape") } } + .background(TabBarControllerAccessor { tabBarController in + guard let item = tabBarController.tabBar.items?[Self.settingsTabIndex] else { return } + item.badgeValue = " " + item.badgeColor = self.settingsBadgeColor + }) .onAppear { self.updateConnectingPulse(for: self.bridgeIndicatorState) } .onChange(of: self.bridgeIndicatorState) { _, newValue in self.updateConnectingPulse(for: newValue) @@ -48,55 +33,25 @@ struct RootTabs: View { case disconnected } + private static let settingsTabIndex = 2 + private var bridgeIndicatorState: BridgeIndicatorState { if self.appModel.bridgeServerName != nil { return .connected } if self.appModel.bridgeStatusText.localizedCaseInsensitiveContains("connecting") { return .connecting } return .disconnected } - private var settingsIndicatorColor: Color { + private var settingsBadgeColor: UIColor { switch self.bridgeIndicatorState { case .connected: - Color.green + UIColor.systemGreen case .connecting: - Color.yellow + UIColor.systemYellow.withAlphaComponent(self.isConnectingPulse ? 1.0 : 0.6) case .disconnected: - Color.red + UIColor.systemRed } } - private var settingsIndicatorGlowColor: Color { - switch self.bridgeIndicatorState { - case .connected: - Color.green.opacity(0.75) - case .connecting: - Color.yellow.opacity(0.6) - case .disconnected: - Color.clear - } - } - - private var settingsIndicatorGlowRadius: CGFloat { - switch self.bridgeIndicatorState { - case .connected: - 6 - case .connecting: - self.isConnectingPulse ? 6 : 3 - case .disconnected: - 0 - } - } - - private var settingsIndicatorScale: CGFloat { - guard self.bridgeIndicatorState == .connecting else { return 1 } - return self.isConnectingPulse ? 1.12 : 0.96 - } - - private var settingsIndicatorOpacity: Double { - guard self.bridgeIndicatorState == .connecting else { return 1 } - return self.isConnectingPulse ? 1.0 : 0.75 - } - private func updateConnectingPulse(for state: BridgeIndicatorState) { guard state == .connecting else { withAnimation(.easeOut(duration: 0.2)) { self.isConnectingPulse = false } @@ -109,3 +64,41 @@ struct RootTabs: View { } } } + +private struct TabBarControllerAccessor: UIViewControllerRepresentable { + let onResolve: (UITabBarController) -> Void + + func makeUIViewController(context: Context) -> ResolverViewController { + ResolverViewController(onResolve: self.onResolve) + } + + func updateUIViewController(_ uiViewController: ResolverViewController, context: Context) { + uiViewController.onResolve = self.onResolve + uiViewController.resolveIfPossible() + } +} + +private final class ResolverViewController: UIViewController { + var onResolve: (UITabBarController) -> Void + + init(onResolve: @escaping (UITabBarController) -> Void) { + self.onResolve = onResolve + super.init(nibName: nil, bundle: nil) + self.view.isHidden = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.resolveIfPossible() + } + + func resolveIfPossible() { + guard let tabBarController = self.tabBarController else { return } + self.onResolve(tabBarController) + } +} From b8b20eac6dee44ceea42211c7d24d03966631aeb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 02:19:20 +0000 Subject: [PATCH 4/6] fix(ios): make connection badge visible --- apps/ios/Sources/RootTabs.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index fe3d2fe76..7d4425e21 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -18,7 +18,7 @@ struct RootTabs: View { } .background(TabBarControllerAccessor { tabBarController in guard let item = tabBarController.tabBar.items?[Self.settingsTabIndex] else { return } - item.badgeValue = " " + item.badgeValue = "" item.badgeColor = self.settingsBadgeColor }) .onAppear { self.updateConnectingPulse(for: self.bridgeIndicatorState) } From 7318b20f5534bbf8d0593571868193b6b6bdfb11 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 02:19:51 +0000 Subject: [PATCH 5/6] chore(fastlane): support p8 key path --- apps/ios/fastlane/.env.example | 7 ++++++- apps/ios/fastlane/Fastfile | 15 ++++++++++++++- apps/ios/fastlane/README.md | 3 ++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/ios/fastlane/.env.example b/apps/ios/fastlane/.env.example index 023a4e280..2b7c7668b 100644 --- a/apps/ios/fastlane/.env.example +++ b/apps/ios/fastlane/.env.example @@ -1,6 +1,11 @@ # App Store Connect API key (pick one approach) # -# Recommended: +# Recommended (use the downloaded .p8 directly): +# ASC_KEY_ID=XXXXXXXXXX +# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 +# +# Or (JSON key file): # APP_STORE_CONNECT_API_KEY_PATH=/absolute/path/to/AuthKey_XXXXXX.json # # Or: diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 4ab1f07d0..c09b4fcbc 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -7,11 +7,24 @@ platform :ios do return app_store_connect_api_key(path: key_path) end + p8_path = ENV["ASC_KEY_PATH"] + if p8_path && !p8_path.strip.empty? + key_id = ENV["ASC_KEY_ID"] + issuer_id = ENV["ASC_ISSUER_ID"] + UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| v.nil? || v.strip.empty? } + + return app_store_connect_api_key( + key_id: key_id, + issuer_id: issuer_id, + key_filepath: p8_path + ) + end + key_id = ENV["ASC_KEY_ID"] issuer_id = ENV["ASC_ISSUER_ID"] key_content = ENV["ASC_KEY_CONTENT"] - UI.user_error!("Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH or ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_CONTENT.") if [key_id, issuer_id, key_content].any? { |v| v.nil? || v.strip.empty? } + UI.user_error!("Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json) or ASC_KEY_PATH (p8) or ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_CONTENT.") if [key_id, issuer_id, key_content].any? { |v| v.nil? || v.strip.empty? } is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true diff --git a/apps/ios/fastlane/README.md b/apps/ios/fastlane/README.md index ed6f82d3b..47e0a35f1 100644 --- a/apps/ios/fastlane/README.md +++ b/apps/ios/fastlane/README.md @@ -8,7 +8,8 @@ brew install fastlane Configure App Store Connect auth: -- Recommended: set `APP_STORE_CONNECT_API_KEY_PATH` to a JSON key file path. +- Recommended: set `ASC_KEY_PATH` to the downloaded `.p8` path + set `ASC_KEY_ID` and `ASC_ISSUER_ID`. +- Alternative: set `APP_STORE_CONNECT_API_KEY_PATH` to a JSON key file path. - Alternative: set `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_CONTENT` (base64 p8). Common lanes: From c4d0eb93501471ea5d8c3ebd4a636bba1a5276f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 02:35:35 +0000 Subject: [PATCH 6/6] fix(ios): make fastlane beta lane work --- .gitignore | 5 +++ apps/ios/README.md | 2 +- apps/ios/fastlane/.env.example | 3 ++ apps/ios/fastlane/Fastfile | 70 ++++++++++++++++++++++++---------- apps/ios/fastlane/README.md | 24 ------------ apps/ios/fastlane/SETUP.md | 31 +++++++++++++++ 6 files changed, 89 insertions(+), 46 deletions(-) delete mode 100644 apps/ios/fastlane/README.md create mode 100644 apps/ios/fastlane/SETUP.md diff --git a/.gitignore b/.gitignore index 9bffd5214..5ab00c4b7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,13 @@ apps/ios/*.xcworkspace/ apps/ios/.swiftpm/ # fastlane (iOS) +apps/ios/fastlane/README.md apps/ios/fastlane/report.xml apps/ios/fastlane/Preview.html apps/ios/fastlane/screenshots/ apps/ios/fastlane/test_output/ apps/ios/fastlane/logs/ + +# fastlane build artifacts (local) +apps/ios/*.ipa +apps/ios/*.dSYM.zip diff --git a/apps/ios/README.md b/apps/ios/README.md index d1dbb991a..f1e384562 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -25,4 +25,4 @@ cd apps/ios fastlane lanes ``` -See `apps/ios/fastlane/README.md` for App Store Connect auth + upload lanes. +See `apps/ios/fastlane/SETUP.md` for App Store Connect auth + upload lanes. diff --git a/apps/ios/fastlane/.env.example b/apps/ios/fastlane/.env.example index 2b7c7668b..7f2c61333 100644 --- a/apps/ios/fastlane/.env.example +++ b/apps/ios/fastlane/.env.example @@ -13,6 +13,9 @@ # ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # ASC_KEY_CONTENT=BASE64_P8_CONTENT +# Code signing +# IOS_DEVELOPMENT_TEAM=XXXXXXXXXX + # Deliver toggles (off by default) # DELIVER_METADATA=1 # DELIVER_SCREENSHOTS=1 diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index c09b4fcbc..a727692d0 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -1,50 +1,78 @@ default_platform(:ios) +def load_env_file(path) + return unless File.exist?(path) + + File.foreach(path) do |line| + stripped = line.strip + next if stripped.empty? || stripped.start_with?("#") + + key, value = stripped.split("=", 2) + next if key.nil? || key.empty? || value.nil? + + ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty? + end +end + platform :ios do private_lane :asc_api_key do + load_env_file(File.join(__dir__, ".env")) + + api_key = nil + key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] if key_path && !key_path.strip.empty? - return app_store_connect_api_key(path: key_path) - end - - p8_path = ENV["ASC_KEY_PATH"] - if p8_path && !p8_path.strip.empty? + api_key = app_store_connect_api_key(path: key_path) + else + p8_path = ENV["ASC_KEY_PATH"] + if p8_path && !p8_path.strip.empty? key_id = ENV["ASC_KEY_ID"] issuer_id = ENV["ASC_ISSUER_ID"] UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| v.nil? || v.strip.empty? } - return app_store_connect_api_key( + api_key = app_store_connect_api_key( key_id: key_id, issuer_id: issuer_id, key_filepath: p8_path ) + else + key_id = ENV["ASC_KEY_ID"] + issuer_id = ENV["ASC_ISSUER_ID"] + key_content = ENV["ASC_KEY_CONTENT"] + + UI.user_error!("Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json) or ASC_KEY_PATH (p8) or ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_CONTENT.") if [key_id, issuer_id, key_content].any? { |v| v.nil? || v.strip.empty? } + + is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true + + api_key = app_store_connect_api_key( + key_id: key_id, + issuer_id: issuer_id, + key_content: key_content, + is_key_content_base64: is_base64 + ) + end end - key_id = ENV["ASC_KEY_ID"] - issuer_id = ENV["ASC_ISSUER_ID"] - key_content = ENV["ASC_KEY_CONTENT"] - - UI.user_error!("Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json) or ASC_KEY_PATH (p8) or ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_CONTENT.") if [key_id, issuer_id, key_content].any? { |v| v.nil? || v.strip.empty? } - - is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true - - app_store_connect_api_key( - key_id: key_id, - issuer_id: issuer_id, - key_content: key_content, - is_key_content_base64: is_base64 - ) + api_key end desc "Build + upload to TestFlight" lane :beta do api_key = asc_api_key + team_id = ENV["IOS_DEVELOPMENT_TEAM"] + UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty? + build_app( project: "Clawdis.xcodeproj", scheme: "Clawdis", export_method: "app-store", - clean: true + clean: true, + xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates", + export_xcargs: "-allowProvisioningUpdates", + export_options: { + signingStyle: "automatic" + } ) upload_to_testflight( diff --git a/apps/ios/fastlane/README.md b/apps/ios/fastlane/README.md deleted file mode 100644 index 47e0a35f1..000000000 --- a/apps/ios/fastlane/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# fastlane (Clawdis iOS) - -Install fastlane (recommended via Homebrew): - -```bash -brew install fastlane -``` - -Configure App Store Connect auth: - -- Recommended: set `ASC_KEY_PATH` to the downloaded `.p8` path + set `ASC_KEY_ID` and `ASC_ISSUER_ID`. -- Alternative: set `APP_STORE_CONNECT_API_KEY_PATH` to a JSON key file path. -- Alternative: set `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_CONTENT` (base64 p8). - -Common lanes: - -```bash -cd apps/ios -fastlane beta - -# Upload metadata/screenshots only when explicitly enabled: -DELIVER_METADATA=1 fastlane metadata -DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane metadata -``` diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md new file mode 100644 index 000000000..d7ab2bc7a --- /dev/null +++ b/apps/ios/fastlane/SETUP.md @@ -0,0 +1,31 @@ +# fastlane setup (Clawdis iOS) + +Install: + +```bash +brew install fastlane +``` + +Create an App Store Connect API key: + +- App Store Connect → Users and Access → Keys → App Store Connect API → Generate API Key +- Download the `.p8`, note the **Issuer ID** and **Key ID** + +Create `apps/ios/fastlane/.env` (gitignored): + +```bash +ASC_KEY_ID=YOUR_KEY_ID +ASC_ISSUER_ID=YOUR_ISSUER_ID +ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 + +# Code signing (Apple Team ID / App ID Prefix) +IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID +``` + +Run: + +```bash +cd apps/ios +fastlane beta +``` +