feat(ios): prepare app store connect release assets
@@ -2,12 +2,13 @@
|
||||
// This file is only an example and should stay committed.
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_DEVELOPMENT_TEAM = P5Z8X89DJL
|
||||
OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp.extension
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.share
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
|
||||
OPENCLAW_APP_PROFILE = ai.openclaw.ios Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclaw.ios.share Development
|
||||
OPENCLAW_APP_PROFILE = ai.openclaw.client Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclaw.client.share Development
|
||||
|
||||
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
|
||||
// so later assignments in local files override the defaults above.
|
||||
|
||||
@@ -412,11 +412,11 @@ enum GatewayDiagnostics {
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
private static func isoTimestamp() -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private static var fileURL: URL? {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
@@ -476,7 +476,7 @@ enum GatewayDiagnostics {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
let timestamp = self.isoFormatter.string(from: Date())
|
||||
let timestamp = self.isoTimestamp()
|
||||
let line = "[\(timestamp)] gateway diagnostics started\n"
|
||||
if let data = line.data(using: .utf8) {
|
||||
self.appendToLog(url: url, data: data)
|
||||
@@ -486,7 +486,7 @@ enum GatewayDiagnostics {
|
||||
}
|
||||
|
||||
static func log(_ message: String) {
|
||||
let timestamp = self.isoFormatter.string(from: Date())
|
||||
let timestamp = self.isoTimestamp()
|
||||
let line = "[\(timestamp)] \(message)"
|
||||
logger.info("\(line, privacy: .public)")
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>ai.openclaw.ios.bgrefresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -33,6 +37,8 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260307</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
@@ -52,6 +58,10 @@
|
||||
<string>OpenClaw uses your location when you allow location sharing.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OpenClaw needs microphone access for voice wake.</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>OpenClaw may use motion data to support device-aware interactions and automations.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>OpenClaw needs photo library access when you choose existing photos to share with your assistant.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
@@ -66,10 +76,6 @@
|
||||
<string>audio</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>ai.openclaw.ios.bgrefresh</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
@@ -264,61 +264,65 @@ private struct CanvasContent: View {
|
||||
var openSettings: () -> Void
|
||||
|
||||
private var brightenButtons: Bool { self.systemColorScheme == .light }
|
||||
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ZStack {
|
||||
ScreenTab()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) {
|
||||
self.openChat()
|
||||
}
|
||||
.accessibilityLabel("Chat")
|
||||
|
||||
if self.talkButtonEnabled {
|
||||
// Talk mode lives on a side bubble so it doesn't get buried in settings.
|
||||
OverlayButton(
|
||||
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle",
|
||||
brighten: self.brightenButtons,
|
||||
tint: self.appModel.seamColor,
|
||||
isActive: self.appModel.talkMode.isEnabled)
|
||||
{
|
||||
let next = !self.appModel.talkMode.isEnabled
|
||||
self.talkEnabled = next
|
||||
self.appModel.setTalkEnabled(next)
|
||||
}
|
||||
.accessibilityLabel("Talk Mode")
|
||||
}
|
||||
|
||||
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
|
||||
self.openSettings()
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.trailing, 10)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
if self.talkActive {
|
||||
TalkOrbOverlay()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
StatusPill(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
onTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
StatusPill(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
onTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
})
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) {
|
||||
self.openChat()
|
||||
}
|
||||
.accessibilityLabel("Chat")
|
||||
|
||||
if self.talkButtonEnabled {
|
||||
// Keep Talk mode near status controls while freeing right-side screen real estate.
|
||||
OverlayButton(
|
||||
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
|
||||
brighten: self.brightenButtons,
|
||||
tint: self.appModel.seamColor,
|
||||
isActive: self.talkActive)
|
||||
{
|
||||
let next = !self.talkActive
|
||||
self.talkEnabled = next
|
||||
self.appModel.setTalkEnabled(next)
|
||||
}
|
||||
.accessibilityLabel("Talk Mode")
|
||||
}
|
||||
|
||||
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
|
||||
self.openSettings()
|
||||
}
|
||||
})
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
@@ -334,6 +338,12 @@ private struct CanvasContent: View {
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.openSettings() })
|
||||
.onAppear {
|
||||
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
|
||||
if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
self.appModel.setTalkEnabled(self.talkEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.7</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260307</string>
|
||||
</dict>
|
||||
</plist>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.7</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260307</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 32 KiB |
@@ -1,7 +1,15 @@
|
||||
app_identifier("ai.openclaw.ios")
|
||||
app_identifier("ai.openclaw.client")
|
||||
|
||||
# Auth is expected via App Store Connect API key.
|
||||
# Provide either:
|
||||
# - APP_STORE_CONNECT_API_KEY_PATH=/path/to/AuthKey_XXXXXX.p8.json (recommended)
|
||||
# or:
|
||||
# - ASC_KEY_PATH=/path/to/AuthKey_XXXXXX.p8 with ASC_KEY_ID and ASC_ISSUER_ID
|
||||
# - ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_CONTENT (base64 or raw p8 content)
|
||||
# - ASC_KEY_ID and ASC_ISSUER_ID plus Keychain fallback:
|
||||
# ASC_KEYCHAIN_SERVICE (default: openclaw-asc-key)
|
||||
# ASC_KEYCHAIN_ACCOUNT (default: USER/LOGNAME)
|
||||
#
|
||||
# Optional deliver app lookup overrides:
|
||||
# - ASC_APP_IDENTIFIER (bundle ID)
|
||||
# - ASC_APP_ID (numeric App Store Connect app ID)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
require "shellwords"
|
||||
require "open3"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
@@ -16,33 +17,104 @@ def load_env_file(path)
|
||||
end
|
||||
end
|
||||
|
||||
def env_present?(value)
|
||||
!value.nil? && !value.strip.empty?
|
||||
end
|
||||
|
||||
def clear_empty_env_var(key)
|
||||
return unless ENV.key?(key)
|
||||
ENV.delete(key) unless env_present?(ENV[key])
|
||||
end
|
||||
|
||||
def maybe_decode_hex_keychain_secret(value)
|
||||
return value unless env_present?(value)
|
||||
|
||||
candidate = value.strip
|
||||
return candidate unless candidate.match?(/\A[0-9a-fA-F]+\z/) && candidate.length.even?
|
||||
|
||||
begin
|
||||
decoded = [candidate].pack("H*")
|
||||
return candidate unless decoded.valid_encoding?
|
||||
|
||||
# `security find-generic-password -w` can return hex when the stored secret
|
||||
# includes newlines/non-printable bytes (like PEM files).
|
||||
if decoded.include?("BEGIN PRIVATE KEY") || decoded.include?("END PRIVATE KEY")
|
||||
UI.message("Decoded hex-encoded ASC key content from Keychain.")
|
||||
return decoded
|
||||
end
|
||||
rescue StandardError
|
||||
return candidate
|
||||
end
|
||||
|
||||
candidate
|
||||
end
|
||||
|
||||
def read_asc_key_content_from_keychain
|
||||
service = ENV["ASC_KEYCHAIN_SERVICE"]
|
||||
service = "openclaw-asc-key" unless env_present?(service)
|
||||
|
||||
account = ENV["ASC_KEYCHAIN_ACCOUNT"]
|
||||
account = ENV["USER"] unless env_present?(account)
|
||||
account = ENV["LOGNAME"] unless env_present?(account)
|
||||
return nil unless env_present?(account)
|
||||
|
||||
begin
|
||||
stdout, _stderr, status = Open3.capture3(
|
||||
"security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
service,
|
||||
"-a",
|
||||
account,
|
||||
"-w"
|
||||
)
|
||||
|
||||
return nil unless status.success?
|
||||
|
||||
key_content = stdout.to_s.strip
|
||||
key_content = maybe_decode_hex_keychain_secret(key_content)
|
||||
return nil unless env_present?(key_content)
|
||||
|
||||
UI.message("Loaded ASC key content from Keychain service '#{service}' (account '#{account}').")
|
||||
key_content
|
||||
rescue Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
private_lane :asc_api_key do
|
||||
load_env_file(File.join(__dir__, ".env"))
|
||||
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
|
||||
clear_empty_env_var("ASC_KEY_PATH")
|
||||
clear_empty_env_var("ASC_KEY_CONTENT")
|
||||
|
||||
api_key = nil
|
||||
|
||||
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||
if key_path && !key_path.strip.empty?
|
||||
if env_present?(key_path)
|
||||
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? }
|
||||
if env_present?(p8_path)
|
||||
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| !env_present?(v) }
|
||||
|
||||
api_key = app_store_connect_api_key(
|
||||
key_id: key_id,
|
||||
issuer_id: issuer_id,
|
||||
key_filepath: p8_path
|
||||
)
|
||||
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"]
|
||||
key_content = read_asc_key_content_from_keychain unless env_present?(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? }
|
||||
UI.user_error!(
|
||||
"Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), ASC_KEY_PATH (p8), or ASC_KEY_ID/ASC_ISSUER_ID with ASC_KEY_CONTENT (or Keychain via ASC_KEYCHAIN_SERVICE/ASC_KEYCHAIN_ACCOUNT)."
|
||||
) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) }
|
||||
|
||||
is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true
|
||||
|
||||
@@ -64,7 +136,7 @@ platform :ios do
|
||||
|
||||
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
|
||||
if team_id.nil? || team_id.strip.empty?
|
||||
helper_path = File.expand_path("../../scripts/ios-team-id.sh", __dir__)
|
||||
helper_path = File.expand_path("../../../scripts/ios-team-id.sh", __dir__)
|
||||
if File.exist?(helper_path)
|
||||
# Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata.
|
||||
team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip
|
||||
@@ -77,6 +149,7 @@ platform :ios do
|
||||
scheme: "OpenClaw",
|
||||
export_method: "app-store",
|
||||
clean: true,
|
||||
skip_profile_detection: true,
|
||||
xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates",
|
||||
export_xcargs: "-allowProvisioningUpdates",
|
||||
export_options: {
|
||||
@@ -86,19 +159,40 @@ platform :ios do
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
skip_waiting_for_build_processing: true
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
end
|
||||
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
lane :metadata do
|
||||
api_key = asc_api_key
|
||||
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
|
||||
app_identifier = ENV["ASC_APP_IDENTIFIER"]
|
||||
app_id = ENV["ASC_APP_ID"]
|
||||
app_identifier = nil unless env_present?(app_identifier)
|
||||
app_id = nil unless env_present?(app_id)
|
||||
|
||||
deliver(
|
||||
deliver_options = {
|
||||
api_key: api_key,
|
||||
force: true,
|
||||
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
|
||||
skip_metadata: ENV["DELIVER_METADATA"] != "1"
|
||||
)
|
||||
skip_metadata: ENV["DELIVER_METADATA"] != "1",
|
||||
run_precheck_before_submit: false
|
||||
}
|
||||
deliver_options[:app_identifier] = app_identifier if app_identifier
|
||||
if app_id && app_identifier.nil?
|
||||
# `deliver` prefers app_identifier from Appfile unless explicitly blanked.
|
||||
deliver_options[:app_identifier] = ""
|
||||
deliver_options[:app] = app_id
|
||||
end
|
||||
|
||||
deliver(**deliver_options)
|
||||
end
|
||||
|
||||
desc "Validate App Store Connect API auth"
|
||||
lane :auth_check do
|
||||
asc_api_key
|
||||
UI.success("App Store Connect API auth loaded successfully.")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,18 +11,54 @@ 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):
|
||||
Recommended (macOS): store the private key in Keychain and write non-secret vars:
|
||||
|
||||
```bash
|
||||
scripts/ios-asc-keychain-setup.sh \
|
||||
--key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \
|
||||
--issuer-id YOUR_ISSUER_ID \
|
||||
--write-env
|
||||
```
|
||||
|
||||
This writes these auth variables in `apps/ios/fastlane/.env`:
|
||||
|
||||
```bash
|
||||
ASC_KEY_ID=YOUR_KEY_ID
|
||||
ASC_ISSUER_ID=YOUR_ISSUER_ID
|
||||
ASC_KEYCHAIN_SERVICE=openclaw-asc-key
|
||||
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
|
||||
```
|
||||
|
||||
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
|
||||
|
||||
```bash
|
||||
ASC_APP_IDENTIFIER=ai.openclaw.ios
|
||||
# or
|
||||
ASC_APP_ID=6760218713
|
||||
```
|
||||
|
||||
File-based fallback (CI/non-macOS):
|
||||
|
||||
```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)
|
||||
Code signing variable (optional in `.env`):
|
||||
|
||||
```bash
|
||||
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
|
||||
```
|
||||
|
||||
Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing.
|
||||
Tip: run `scripts/ios-team-id.sh` from repo root to print a Team ID for `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing.
|
||||
|
||||
Validate auth:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
fastlane ios auth_check
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
|
||||
47
apps/ios/fastlane/metadata/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# App Store metadata (Fastlane deliver)
|
||||
|
||||
This directory is used by `fastlane deliver` for App Store Connect text metadata.
|
||||
|
||||
## Upload metadata only
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
ASC_APP_ID=6760218713 \
|
||||
DELIVER_METADATA=1 fastlane ios metadata
|
||||
```
|
||||
|
||||
## Optional: include screenshots
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
The `ios metadata` lane uses App Store Connect API key auth from `apps/ios/fastlane/.env`:
|
||||
|
||||
- Keychain-backed (recommended on macOS):
|
||||
- `ASC_KEY_ID`
|
||||
- `ASC_ISSUER_ID`
|
||||
- `ASC_KEYCHAIN_SERVICE` (default: `openclaw-asc-key`)
|
||||
- `ASC_KEYCHAIN_ACCOUNT` (default: current user)
|
||||
- File/path fallback:
|
||||
- `ASC_KEY_ID`
|
||||
- `ASC_ISSUER_ID`
|
||||
- `ASC_KEY_PATH`
|
||||
|
||||
Or set `APP_STORE_CONNECT_API_KEY_PATH`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Locale files live under `metadata/en-US/`.
|
||||
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
|
||||
- If app lookup fails in `deliver`, set one of:
|
||||
- `ASC_APP_IDENTIFIER` (bundle ID)
|
||||
- `ASC_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps/<id>/...` URL)
|
||||
- For first app versions, include review contact files under `metadata/review_information/`:
|
||||
- `first_name.txt`
|
||||
- `last_name.txt`
|
||||
- `email_address.txt`
|
||||
- `phone_number.txt` (E.164-ish, e.g. `+1 415 555 0100`)
|
||||
18
apps/ios/fastlane/metadata/en-US/description.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
OpenClaw is a personal AI assistant you run on your own devices.
|
||||
|
||||
Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation.
|
||||
|
||||
What you can do:
|
||||
- Chat with your assistant from iPhone
|
||||
- Use voice wake and push-to-talk
|
||||
- Capture photos and short clips on request
|
||||
- Record screen snippets for troubleshooting and workflows
|
||||
- Share text, links, and media directly from iOS into OpenClaw
|
||||
- Run location-aware and device-aware automations
|
||||
|
||||
OpenClaw is local-first: you control your gateway, keys, and configuration.
|
||||
|
||||
Getting started:
|
||||
1) Set up your OpenClaw Gateway
|
||||
2) Open the iOS app and pair with your gateway
|
||||
3) Start using commands and automations from your phone
|
||||
1
apps/ios/fastlane/metadata/en-US/keywords.txt
Normal file
@@ -0,0 +1 @@
|
||||
openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node
|
||||
1
apps/ios/fastlane/metadata/en-US/marketing_url.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://openclaw.ai
|
||||
1
apps/ios/fastlane/metadata/en-US/name.txt
Normal file
@@ -0,0 +1 @@
|
||||
OpenClaw - iOS Client
|
||||
1
apps/ios/fastlane/metadata/en-US/privacy_url.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://openclaw.ai/privacy
|
||||
1
apps/ios/fastlane/metadata/en-US/promotional_text.txt
Normal file
@@ -0,0 +1 @@
|
||||
Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions.
|
||||
1
apps/ios/fastlane/metadata/en-US/release_notes.txt
Normal file
@@ -0,0 +1 @@
|
||||
First App Store release of OpenClaw for iPhone. Pair with your OpenClaw Gateway to use chat, voice, sharing, and device actions from iOS.
|
||||
1
apps/ios/fastlane/metadata/en-US/subtitle.txt
Normal file
@@ -0,0 +1 @@
|
||||
Personal AI on your devices
|
||||
1
apps/ios/fastlane/metadata/en-US/support_url.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://docs.openclaw.ai/platforms/ios
|
||||
@@ -0,0 +1 @@
|
||||
support@openclaw.ai
|
||||
@@ -0,0 +1 @@
|
||||
OpenClaw
|
||||
@@ -0,0 +1 @@
|
||||
Team
|
||||
1
apps/ios/fastlane/metadata/review_information/notes.txt
Normal file
@@ -0,0 +1 @@
|
||||
OpenClaw iOS client for gateway-connected workflows. Reviewers can follow the standard onboarding and pairing flow in-app.
|
||||
@@ -0,0 +1 @@
|
||||
+1 415 555 0100
|
||||
BIN
apps/ios/screenshots/session-2026-03-07/canvas-cool.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
apps/ios/screenshots/session-2026-03-07/onboarding.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
apps/ios/screenshots/session-2026-03-07/settings.png
Normal file
|
After Width: | Height: | Size: 300 KiB |
BIN
apps/ios/screenshots/session-2026-03-07/talk-mode.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
187
scripts/ios-asc-keychain-setup.sh
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ios-asc-keychain-setup.sh --key-path /path/to/AuthKey_XXXXXX.p8 --issuer-id <issuer-uuid> [options]
|
||||
|
||||
Required:
|
||||
--key-path <path> Path to App Store Connect API key (.p8)
|
||||
--issuer-id <uuid> App Store Connect issuer ID
|
||||
|
||||
Optional:
|
||||
--key-id <id> API key ID (auto-detected from AuthKey_<id>.p8 if omitted)
|
||||
--service <name> Keychain service name (default: openclaw-asc-key)
|
||||
--account <name> Keychain account name (default: $USER or $LOGNAME)
|
||||
--write-env Upsert non-secret env vars into apps/ios/fastlane/.env
|
||||
--env-file <path> Override env file path used with --write-env
|
||||
-h, --help Show this help
|
||||
|
||||
Example:
|
||||
scripts/ios-asc-keychain-setup.sh \
|
||||
--key-path "$HOME/keys/AuthKey_ABC1234567.p8" \
|
||||
--issuer-id "00000000-1111-2222-3333-444444444444" \
|
||||
--write-env
|
||||
EOF
|
||||
}
|
||||
|
||||
upsert_env_line() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
if [[ -f "$file" ]]; then
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { updated = 0 }
|
||||
$0 ~ ("^" key "=") { print key "=" value; updated = 1; next }
|
||||
{ print }
|
||||
END { if (!updated) print key "=" value }
|
||||
' "$file" >"$tmp"
|
||||
else
|
||||
printf "%s=%s\n" "$key" "$value" >"$tmp"
|
||||
fi
|
||||
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
delete_env_line() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
rm -f "$tmp"
|
||||
return
|
||||
fi
|
||||
|
||||
awk -v key="$key" '
|
||||
$0 ~ ("^" key "=") { next }
|
||||
{ print }
|
||||
' "$file" >"$tmp"
|
||||
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
KEY_PATH=""
|
||||
KEY_ID=""
|
||||
ISSUER_ID=""
|
||||
SERVICE="openclaw-asc-key"
|
||||
ACCOUNT="${USER:-${LOGNAME:-}}"
|
||||
WRITE_ENV=0
|
||||
ENV_FILE=""
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DEFAULT_ENV_FILE="$REPO_ROOT/apps/ios/fastlane/.env"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--key-path)
|
||||
KEY_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--key-id)
|
||||
KEY_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--issuer-id)
|
||||
ISSUER_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--service)
|
||||
SERVICE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--account)
|
||||
ACCOUNT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--write-env)
|
||||
WRITE_ENV=1
|
||||
shift
|
||||
;;
|
||||
--env-file)
|
||||
ENV_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$KEY_PATH" || -z "$ISSUER_ID" ]]; then
|
||||
echo "Missing required arguments." >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$KEY_PATH" ]]; then
|
||||
echo "Key file not found: $KEY_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$KEY_ID" ]]; then
|
||||
key_filename="$(basename "$KEY_PATH")"
|
||||
if [[ "$key_filename" =~ ^AuthKey_([A-Za-z0-9]+)\.p8$ ]]; then
|
||||
KEY_ID="${BASH_REMATCH[1]}"
|
||||
else
|
||||
echo "Could not infer --key-id from filename '$key_filename'. Pass --key-id explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$ACCOUNT" ]]; then
|
||||
echo "Could not determine Keychain account. Pass --account explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KEY_CONTENT="$(cat "$KEY_PATH")"
|
||||
if [[ -z "$KEY_CONTENT" ]]; then
|
||||
echo "Key file is empty: $KEY_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
security add-generic-password \
|
||||
-a "$ACCOUNT" \
|
||||
-s "$SERVICE" \
|
||||
-w "$KEY_CONTENT" \
|
||||
-U >/dev/null
|
||||
|
||||
echo "Stored ASC API private key in macOS Keychain (service='$SERVICE', account='$ACCOUNT')."
|
||||
echo
|
||||
echo "Export these vars for Fastlane:"
|
||||
echo "ASC_KEY_ID=$KEY_ID"
|
||||
echo "ASC_ISSUER_ID=$ISSUER_ID"
|
||||
echo "ASC_KEYCHAIN_SERVICE=$SERVICE"
|
||||
echo "ASC_KEYCHAIN_ACCOUNT=$ACCOUNT"
|
||||
|
||||
if [[ "$WRITE_ENV" -eq 1 ]]; then
|
||||
if [[ -z "$ENV_FILE" ]]; then
|
||||
ENV_FILE="$DEFAULT_ENV_FILE"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
touch "$ENV_FILE"
|
||||
|
||||
upsert_env_line "$ENV_FILE" "ASC_KEY_ID" "$KEY_ID"
|
||||
upsert_env_line "$ENV_FILE" "ASC_ISSUER_ID" "$ISSUER_ID"
|
||||
upsert_env_line "$ENV_FILE" "ASC_KEYCHAIN_SERVICE" "$SERVICE"
|
||||
upsert_env_line "$ENV_FILE" "ASC_KEYCHAIN_ACCOUNT" "$ACCOUNT"
|
||||
# Remove file/path based keys so Keychain is used by default.
|
||||
delete_env_line "$ENV_FILE" "ASC_KEY_PATH"
|
||||
delete_env_line "$ENV_FILE" "ASC_KEY_CONTENT"
|
||||
delete_env_line "$ENV_FILE" "APP_STORE_CONNECT_API_KEY_PATH"
|
||||
|
||||
echo
|
||||
echo "Updated env file: $ENV_FILE"
|
||||
fi
|
||||
@@ -63,6 +63,7 @@ fi
|
||||
bundle_base="$(normalize_bundle_id "${bundle_base}")"
|
||||
|
||||
share_bundle_id="${OPENCLAW_IOS_SHARE_BUNDLE_ID:-${bundle_base}.share}"
|
||||
activity_widget_bundle_id="${OPENCLAW_IOS_ACTIVITY_WIDGET_BUNDLE_ID:-${bundle_base}.activitywidget}"
|
||||
watch_app_bundle_id="${OPENCLAW_IOS_WATCH_APP_BUNDLE_ID:-${bundle_base}.watchkitapp}"
|
||||
watch_extension_bundle_id="${OPENCLAW_IOS_WATCH_EXTENSION_BUNDLE_ID:-${watch_app_bundle_id}.extension}"
|
||||
|
||||
@@ -76,7 +77,8 @@ cat >"${tmp_file}" <<EOF
|
||||
// This file is local-only and should not be committed.
|
||||
// Override values with env vars if needed:
|
||||
// OPENCLAW_IOS_APP_BUNDLE_ID / OPENCLAW_IOS_BUNDLE_ID_BASE
|
||||
// OPENCLAW_IOS_SHARE_BUNDLE_ID / OPENCLAW_IOS_WATCH_APP_BUNDLE_ID / OPENCLAW_IOS_WATCH_EXTENSION_BUNDLE_ID
|
||||
// OPENCLAW_IOS_SHARE_BUNDLE_ID / OPENCLAW_IOS_ACTIVITY_WIDGET_BUNDLE_ID
|
||||
// OPENCLAW_IOS_WATCH_APP_BUNDLE_ID / OPENCLAW_IOS_WATCH_EXTENSION_BUNDLE_ID
|
||||
// OPENCLAW_IOS_CODE_SIGN_STYLE / OPENCLAW_IOS_APP_PROFILE / OPENCLAW_IOS_SHARE_PROFILE
|
||||
OPENCLAW_CODE_SIGN_STYLE = ${code_sign_style}
|
||||
OPENCLAW_DEVELOPMENT_TEAM = ${team_id}
|
||||
@@ -84,6 +86,7 @@ OPENCLAW_DEVELOPMENT_TEAM = ${team_id}
|
||||
OPENCLAW_IOS_SELECTED_TEAM = ${team_id}
|
||||
OPENCLAW_APP_BUNDLE_ID = ${bundle_base}
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ${share_bundle_id}
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ${activity_widget_bundle_id}
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ${watch_app_bundle_id}
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ${watch_extension_bundle_id}
|
||||
OPENCLAW_APP_PROFILE = ${app_profile}
|
||||
|
||||