diff --git a/.gitignore b/.gitignore
index 03012a7c..9b36971b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ packages/flutter-inspector/.dart_tool/
packages/flutter-inspector/pubspec.lock
docs/.vitepress/dist/
docs/.vitepress/cache/
+ios/DerivedData/
cloud/
.cache/
.playwright-mcp/
diff --git a/README.md b/README.md
index fa9e63e6..ec3f0512 100644
--- a/README.md
+++ b/README.md
@@ -4,19 +4,23 @@
SimDeck
- SimDeck is a developer tool built for streamlining mobile app development for coding agents.
- Drive iOS Simulators and Android emulators from the CLI using agents, browser, and automated tests on macOS.
+ SimDeck is a developer tool built for streamlining mobile app development using agents.
+ Drive iOS Simulators and Android emulators from browser & CLI.
+
+
## Try it out
```sh
npx simdeck
```
+Open the URL in your IDE of choice, for example in-app browser in Codex.
+
Install the CLI globally for agentic-use:
```sh
@@ -35,11 +39,10 @@ view inside the editor.
## Features
-- Local iOS Simulator video over browser-native WebRTC H.264 with VideoToolbox hardware encode and x264 software encode
-- Android emulator frames are sourced from emulator gRPC; loopback browsers use raw RGBA over WebRTC, and non-loopback browsers use VideoToolbox-encoded H.264
+- Supports streaming both iOS simulators and Android emulators
- Full simulator control & inspection using private iOS accessibility APIs and Android UIAutomator - available using `simdeck` CLI
- Real-time screen `describe` command using accessibility view tree - available in token-efficient format for agents
-- Simulator app performance gauges for CPU, memory, disk writes, network throughput, hang signals, and stack sampling
+- Profiling built-in: CPU, memory, disk writes, network throughput, hang signals, and stack sampling
- CoreSimulator chrome asset rendering for device bezels
- NativeScript, React Native, Flutter, UIKit and SwiftUI runtime inspector plugins to debug app's view hierarchy live
- `simdeck/test` for fast JS-based app tests that can query accessibility state and drive simulator controls
@@ -57,7 +60,6 @@ documented in the [GitHub Actions guide](https://simdeck.nativescript.org/guide/
simdeck
```
-This starts a workspace-local foreground daemon, prints local and LAN HTTP URLs plus a pairing code for LAN browsers, and stops when you press `q` or Ctrl-C.
To focus a specific simulator by name or UDID, pass it as the only argument:
```sh
@@ -69,6 +71,18 @@ simdeck "iPhone 17 Pro Max"
The served loopback browser UI receives the generated API access token automatically.
LAN clients should pair with the printed code before receiving the API cookie.
+For pairing with SimDeck iOS app:
+
+```sh
+simdeck pair
+```
+
+This starts or refreshes the global LaunchAgent-backed SimDeck service, prints
+local, LAN, and Tailscale URLs when available, and shows a QR code with a
+`simdeck://pair` link. The QR contains the pairing code plus all detected
+non-loopback addresses, so pairing once can save both the LAN and Tailscale
+routes with the same service token.
+
CLI commands automatically use the same warm daemon:
```sh
diff --git a/assets/codex-screenshot.png b/assets/codex-screenshot.png
new file mode 100644
index 00000000..e1082393
Binary files /dev/null and b/assets/codex-screenshot.png differ
diff --git a/cli/XCWChromeRenderer.m b/cli/XCWChromeRenderer.m
index abd4feab..d304ad12 100644
--- a/cli/XCWChromeRenderer.m
+++ b/cli/XCWChromeRenderer.m
@@ -12,7 +12,31 @@ @interface XCWChromeRenderer ()
+ (nullable NSDictionary *)inputNamed:(NSString *)buttonName
chromeInfo:(NSDictionary *)chromeInfo
error:(NSError * _Nullable __autoreleasing *)error;
-+ (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo;
++ (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize;
++ (CGSize)screenPaddingForChromeInfo:(NSDictionary *)chromeInfo;
++ (CGFloat)inputScaleForChromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize;
++ (CGFloat)inputAssetScaleForInput:(NSDictionary *)input
+ chromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize;
++ (CGFloat)inputCoordinateScaleForChromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize;
++ (CGFloat)inputVerticalAdjustmentForInput:(NSDictionary *)input
+ chromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize;
++ (CGFloat)inputHorizontalAdjustmentForInput:(NSDictionary *)input
+ chromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize;
++ (CGSize)scaledInputAssetSize:(CGSize)assetSize scale:(CGFloat)scale;
++ (BOOL)isDigitalCrownInput:(NSDictionary *)input;
++ (BOOL)input:(NSDictionary *)input shouldDrawOnTopForChromeInfo:(NSDictionary *)chromeInfo;
++ (void)drawDigitalCrownTextureInRect:(CGRect)rect context:(CGContextRef)context;
++ (CGSize)framebufferMaskSizeForChromeInfo:(NSDictionary *)chromeInfo;
++ (CGRect)blackScreenBoundsForChromeInfo:(NSDictionary *)chromeInfo
+ matchingDisplaySize:(CGSize)displaySize;
++ (CGFloat)framebufferMaskCornerRadiusForChromeInfo:(NSDictionary *)chromeInfo
+ pointScreenWidth:(CGFloat)pointScreenWidth;
@end
@implementation XCWChromeRenderer
@@ -394,8 +418,6 @@ + (nullable NSData *)screenshotPNGDataForDeviceName:(NSString *)deviceName
BOOL hasModernPhoneSensor = [self shouldRenderPhoneChromeFromSlices:plist sensorName:sensorName];
BOOL hasComposite = !hasModernPhoneSensor && [self compositeAssetPathForChromeInfo:chromeInfo].length > 0;
CGFloat screenScale = MAX([self numberValue:plist[@"mainScreenScale"]], 1.0);
- CGFloat profileScreenWidth = [self numberValue:plist[@"mainScreenWidth"]];
- CGFloat profileScreenHeight = [self numberValue:plist[@"mainScreenHeight"]];
CGSize profileScreenSize = [self screenSizeForChromeInfo:chromeInfo
chromeSize:compositeSize
screenScale:screenScale];
@@ -406,27 +428,90 @@ + (nullable NSData *)screenshotPNGDataForDeviceName:(NSString *)deviceName
CGFloat screenHeight;
CGFloat screenX;
CGFloat screenY;
- if (hasComposite && pointScreenWidth > 0.0 && pointScreenHeight > 0.0) {
+ CGFloat contentWidth;
+ CGFloat contentHeight;
+ CGFloat contentX;
+ CGFloat contentY;
+ if (watchProfile) {
+ CGSize maskSize = [self framebufferMaskSizeForChromeInfo:chromeInfo];
+ CGSize displaySize = maskSize.width > 0.0 && maskSize.height > 0.0
+ ? maskSize
+ : profileScreenSize;
+ CGRect blackScreenBounds = [self blackScreenBoundsForChromeInfo:chromeInfo
+ matchingDisplaySize:displaySize];
+ if (!CGRectIsEmpty(blackScreenBounds)) {
+ screenX = CGRectGetMinX(blackScreenBounds);
+ screenY = CGRectGetMinY(blackScreenBounds);
+ screenWidth = CGRectGetWidth(blackScreenBounds);
+ screenHeight = CGRectGetHeight(blackScreenBounds);
+ } else {
+ CGFloat usableHeight = MAX(compositeSize.height - standHeight, 1.0);
+ screenX = MAX(sizingLeft, 0.0);
+ screenY = MAX(sizingTop, 0.0);
+ screenWidth = MAX(compositeSize.width - sizingLeft - sizingRight, 1.0);
+ screenHeight = MAX(usableHeight - sizingTop - sizingBottom, 1.0);
+ }
+ CGRect contentBounds = CGRectMake(screenX, screenY, screenWidth, screenHeight);
+ if (!CGRectIsEmpty(blackScreenBounds)) {
+ CGSize screenPadding = [self screenPaddingForChromeInfo:chromeInfo];
+ CGFloat contentInset = MAX(MAX(screenPadding.width, screenPadding.height), 0.0);
+ CGFloat horizontalBorderInset = MIN(MAX(borderLeft, 0.0), MAX(borderRight, 0.0));
+ if (horizontalBorderInset > 0.0) {
+ contentInset = MAX(contentInset,
+ horizontalBorderInset + MAX(screenPadding.width, 0.0));
+ }
+ if (contentInset > 0.0 &&
+ CGRectGetWidth(contentBounds) > (contentInset * 2.0) &&
+ CGRectGetHeight(contentBounds) > (contentInset * 2.0)) {
+ contentBounds = CGRectInset(contentBounds, contentInset, contentInset);
+ }
+ }
+ if (displaySize.width > 0.0 && displaySize.height > 0.0) {
+ CGFloat fitScale = MIN(CGRectGetWidth(contentBounds) / displaySize.width,
+ CGRectGetHeight(contentBounds) / displaySize.height);
+ if (!isfinite(fitScale) || fitScale <= 0.0) {
+ fitScale = 1.0;
+ }
+ fitScale = MIN(fitScale, 1.0);
+ contentWidth = displaySize.width * fitScale;
+ contentHeight = displaySize.height * fitScale;
+ } else {
+ contentWidth = CGRectGetWidth(contentBounds);
+ contentHeight = CGRectGetHeight(contentBounds);
+ }
+ contentX = CGRectGetMinX(contentBounds) + MAX((CGRectGetWidth(contentBounds) - contentWidth) / 2.0, 0.0);
+ contentY = CGRectGetMinY(contentBounds) + MAX((CGRectGetHeight(contentBounds) - contentHeight) / 2.0, 0.0);
+ } else if (hasComposite && pointScreenWidth > 0.0 && pointScreenHeight > 0.0) {
screenWidth = pointScreenWidth;
screenHeight = pointScreenHeight;
screenX = MAX((compositeSize.width - screenWidth) / 2.0, 0.0);
CGFloat usableHeight = compositeSize.height - standHeight;
screenY = MAX((usableHeight - screenHeight) / 2.0, bezelTop);
- } else if (watchProfile) {
- screenWidth = profileScreenWidth;
- screenHeight = profileScreenHeight;
- screenX = MAX((compositeSize.width - screenWidth) / 2.0, 0.0);
- screenY = MAX((compositeSize.height - screenHeight) / 2.0, 0.0);
+ contentX = screenX;
+ contentY = screenY;
+ contentWidth = screenWidth;
+ contentHeight = screenHeight;
} else {
screenX = bezelLeft;
screenY = bezelTop;
screenWidth = MAX(compositeSize.width - bezelLeft - bezelRight, 1.0);
screenHeight = MAX(compositeSize.height - standHeight - bezelTop - bezelBottom, 1.0);
+ contentX = screenX;
+ contentY = screenY;
+ contentWidth = screenWidth;
+ contentHeight = screenHeight;
}
CGFloat innerRadius = MAX(rawCornerRadius - MAX(screenX, screenY), 0.0);
- CGFloat radiusScale = pointScreenWidth > 0.0 ? screenWidth / pointScreenWidth : 1.0;
+ CGFloat radiusScale = !watchProfile && pointScreenWidth > 0.0 ? screenWidth / pointScreenWidth : 1.0;
CGFloat chromeCornerRadius = innerRadius * radiusScale;
+ if (watchProfile) {
+ CGFloat maskCornerRadius = [self framebufferMaskCornerRadiusForChromeInfo:chromeInfo
+ pointScreenWidth:contentWidth];
+ if (maskCornerRadius > 0.0) {
+ chromeCornerRadius = maskCornerRadius;
+ }
+ }
CGFloat cornerRadius = chromeCornerRadius;
CGRect fullFrame = [self fullFrameForChromeInfo:chromeInfo chromeSize:compositeSize];
@@ -448,6 +533,10 @@ + (nullable NSData *)screenshotPNGDataForDeviceName:(NSString *)deviceName
@"screenY": @(screenY + chromeY),
@"screenWidth": @(screenWidth),
@"screenHeight": @(screenHeight),
+ @"contentX": @(contentX + chromeX),
+ @"contentY": @(contentY + chromeY),
+ @"contentWidth": @(contentWidth),
+ @"contentHeight": @(contentHeight),
@"cornerRadius": @(cornerRadius),
@"chromeCornerRadius": @(chromeCornerRadius),
@"hasScreenMask": @(hasScreenMask),
@@ -747,7 +836,7 @@ + (BOOL)drawInputImagesForChromeInfo:(NSDictionary *)chromeInfo
continue;
}
NSDictionary *input = inputValue;
- BOOL onTop = [input[@"onTop"] respondsToSelector:@selector(boolValue)] && [input[@"onTop"] boolValue];
+ BOOL onTop = [self input:input shouldDrawOnTopForChromeInfo:chromeInfo];
if (onTop != onlyOnTop) {
continue;
}
@@ -756,22 +845,80 @@ + (BOOL)drawInputImagesForChromeInfo:(NSDictionary *)chromeInfo
continue;
}
NSString *assetPath = [self resolvedChromeAssetPathForName:assetName chromePath:chromePath];
- CGSize assetSize = [self PDFPageSizeAtPath:assetPath];
+ CGFloat inputScale = [self inputAssetScaleForInput:input chromeInfo:chromeInfo chromeSize:size];
+ CGFloat coordinateScale = [self inputCoordinateScaleForChromeInfo:chromeInfo chromeSize:size];
+ CGSize assetSize = [self scaledInputAssetSize:[self PDFPageSizeAtPath:assetPath]
+ scale:inputScale];
if (assetSize.width <= 0.0 || assetSize.height <= 0.0) {
continue;
}
- CGRect rect = [self inputFrameForInput:input assetSize:assetSize inSize:size];
+ CGRect rect = [self inputFrameForInput:input
+ assetSize:assetSize
+ inSize:size
+ scale:coordinateScale];
+ rect = CGRectOffset(rect,
+ [self inputHorizontalAdjustmentForInput:input chromeInfo:chromeInfo chromeSize:size],
+ 0.0);
+ rect = CGRectOffset(rect,
+ 0.0,
+ [self inputVerticalAdjustmentForInput:input chromeInfo:chromeInfo chromeSize:size]);
if (![self drawPDFAtPath:assetPath inRect:rect context:context error:error]) {
return NO;
}
+ if ([self isDigitalCrownInput:input]) {
+ [self drawDigitalCrownTextureInRect:rect context:context];
+ }
}
return YES;
}
+ (CGRect)fullFrameForChromeInfo:(NSDictionary *)chromeInfo
chromeSize:(CGSize)chromeSize {
- NSEdgeInsets padding = [self devicePaddingForChromeInfo:chromeInfo];
+ if ([self isWatchProfile:chromeInfo[@"plist"]]) {
+ CGSize padding = [self screenPaddingForChromeInfo:chromeInfo];
+ CGRect bounds = CGRectMake(-MAX(padding.width, 0.0),
+ -MAX(padding.height, 0.0),
+ chromeSize.width + (MAX(padding.width, 0.0) * 2.0),
+ chromeSize.height + (MAX(padding.height, 0.0) * 2.0));
+ NSDictionary *json = chromeInfo[@"json"];
+ NSString *chromePath = chromeInfo[@"chromePath"];
+ NSArray *inputs = [json[@"inputs"] isKindOfClass:[NSArray class]] ? json[@"inputs"] : @[];
+ for (id inputValue in inputs) {
+ if (![inputValue isKindOfClass:[NSDictionary class]]) {
+ continue;
+ }
+ NSDictionary *input = inputValue;
+ NSString *assetName = [input[@"image"] isKindOfClass:[NSString class]] ? input[@"image"] : @"";
+ if (assetName.length == 0) {
+ continue;
+ }
+ NSString *assetPath = [self resolvedChromeAssetPathForName:assetName chromePath:chromePath];
+ CGFloat inputScale = [self inputAssetScaleForInput:input chromeInfo:chromeInfo chromeSize:chromeSize];
+ CGFloat coordinateScale = [self inputCoordinateScaleForChromeInfo:chromeInfo chromeSize:chromeSize];
+ CGFloat verticalAdjustment = [self inputVerticalAdjustmentForInput:input chromeInfo:chromeInfo chromeSize:chromeSize];
+ CGSize assetSize = [self scaledInputAssetSize:[self PDFPageSizeAtPath:assetPath] scale:inputScale];
+ if (assetSize.width <= 0.0 || assetSize.height <= 0.0) {
+ continue;
+ }
+ CGRect normalRect = [self inputFrameForInput:input
+ assetSize:assetSize
+ inSize:chromeSize
+ scale:coordinateScale
+ offsetName:@"normal"];
+ CGRect rolloverRect = [self inputFrameForInput:input
+ assetSize:assetSize
+ inSize:chromeSize
+ scale:coordinateScale
+ offsetName:@"rollover"];
+ CGFloat horizontalAdjustment = [self inputHorizontalAdjustmentForInput:input chromeInfo:chromeInfo chromeSize:chromeSize];
+ bounds = CGRectUnion(bounds, CGRectOffset(normalRect, horizontalAdjustment, verticalAdjustment));
+ bounds = CGRectUnion(bounds, CGRectOffset(rolloverRect, horizontalAdjustment, verticalAdjustment));
+ }
+ return CGRectIntegral(bounds);
+ }
+
+ NSEdgeInsets padding = [self devicePaddingForChromeInfo:chromeInfo chromeSize:chromeSize];
if (padding.top != 0.0 || padding.left != 0.0 || padding.bottom != 0.0 || padding.right != 0.0) {
return CGRectMake(-padding.left,
-padding.top,
@@ -790,7 +937,7 @@ + (CGRect)fullFrameForChromeInfo:(NSDictionary *)chromeInfo
continue;
}
NSDictionary *input = inputValue;
- BOOL onTop = [input[@"onTop"] respondsToSelector:@selector(boolValue)] && [input[@"onTop"] boolValue];
+ BOOL onTop = [self input:input shouldDrawOnTopForChromeInfo:chromeInfo];
if (hasComposite && watchProfile && !onTop) {
continue;
}
@@ -799,18 +946,27 @@ + (CGRect)fullFrameForChromeInfo:(NSDictionary *)chromeInfo
continue;
}
NSString *assetPath = [self resolvedChromeAssetPathForName:assetName chromePath:chromePath];
- CGSize assetSize = [self PDFPageSizeAtPath:assetPath];
+ CGFloat inputScale = [self inputAssetScaleForInput:input chromeInfo:chromeInfo chromeSize:chromeSize];
+ CGFloat coordinateScale = [self inputCoordinateScaleForChromeInfo:chromeInfo chromeSize:chromeSize];
+ CGFloat verticalAdjustment = [self inputVerticalAdjustmentForInput:input chromeInfo:chromeInfo chromeSize:chromeSize];
+ CGSize assetSize = [self scaledInputAssetSize:[self PDFPageSizeAtPath:assetPath]
+ scale:inputScale];
if (assetSize.width <= 0.0 || assetSize.height <= 0.0) {
continue;
}
- bounds = CGRectUnion(bounds, [self inputFrameForInput:input
- assetSize:assetSize
- inSize:chromeSize
- offsetName:@"normal"]);
- bounds = CGRectUnion(bounds, [self inputFrameForInput:input
- assetSize:assetSize
- inSize:chromeSize
- offsetName:@"rollover"]);
+ CGRect normalRect = [self inputFrameForInput:input
+ assetSize:assetSize
+ inSize:chromeSize
+ scale:coordinateScale
+ offsetName:@"normal"];
+ CGRect rolloverRect = [self inputFrameForInput:input
+ assetSize:assetSize
+ inSize:chromeSize
+ scale:coordinateScale
+ offsetName:@"rollover"];
+ CGFloat horizontalAdjustment = [self inputHorizontalAdjustmentForInput:input chromeInfo:chromeInfo chromeSize:chromeSize];
+ bounds = CGRectUnion(bounds, CGRectOffset(normalRect, horizontalAdjustment, verticalAdjustment));
+ bounds = CGRectUnion(bounds, CGRectOffset(rolloverRect, horizontalAdjustment, verticalAdjustment));
}
return CGRectIntegral(bounds);
}
@@ -818,12 +974,31 @@ + (CGRect)fullFrameForChromeInfo:(NSDictionary *)chromeInfo
+ (CGRect)inputFrameForInput:(NSDictionary *)input
assetSize:(CGSize)assetSize
inSize:(CGSize)size {
- return [self inputFrameForInput:input assetSize:assetSize inSize:size offsetName:@"normal"];
+ return [self inputFrameForInput:input assetSize:assetSize inSize:size scale:1.0 offsetName:@"normal"];
}
+ (CGRect)inputFrameForInput:(NSDictionary *)input
assetSize:(CGSize)assetSize
inSize:(CGSize)size
+ scale:(CGFloat)scale {
+ return [self inputFrameForInput:input assetSize:assetSize inSize:size scale:scale offsetName:@"normal"];
+}
+
++ (CGRect)inputFrameForInput:(NSDictionary *)input
+ assetSize:(CGSize)assetSize
+ inSize:(CGSize)size
+ offsetName:(NSString *)offsetName {
+ return [self inputFrameForInput:input
+ assetSize:assetSize
+ inSize:size
+ scale:1.0
+ offsetName:offsetName];
+}
+
++ (CGRect)inputFrameForInput:(NSDictionary *)input
+ assetSize:(CGSize)assetSize
+ inSize:(CGSize)size
+ scale:(CGFloat)scale
offsetName:(NSString *)offsetName {
NSDictionary *offsets = [input[@"offsets"] isKindOfClass:[NSDictionary class]] ? input[@"offsets"] : @{};
NSDictionary *normalOffset = [offsets[@"normal"] isKindOfClass:[NSDictionary class]] ? offsets[@"normal"] : nil;
@@ -831,10 +1006,11 @@ + (CGRect)inputFrameForInput:(NSDictionary *)input
NSDictionary *requestedOffset = [offsets[offsetName] isKindOfClass:[NSDictionary class]] ? offsets[offsetName] : nil;
NSDictionary *primaryOffset = normalOffset ?: rolloverOffset ?: requestedOffset ?: @{};
NSDictionary *secondaryOffset = rolloverOffset ?: normalOffset ?: requestedOffset ?: @{};
- CGFloat normalX = [self numberValue:primaryOffset[@"x"]];
- CGFloat normalY = [self numberValue:primaryOffset[@"y"]];
- CGFloat rolloverX = [self numberValue:secondaryOffset[@"x"]];
- CGFloat rolloverY = [self numberValue:secondaryOffset[@"y"]];
+ CGFloat coordinateScale = MAX(scale, 1.0);
+ CGFloat normalX = [self numberValue:primaryOffset[@"x"]] * coordinateScale;
+ CGFloat normalY = [self numberValue:primaryOffset[@"y"]] * coordinateScale;
+ CGFloat rolloverX = [self numberValue:secondaryOffset[@"x"]] * coordinateScale;
+ CGFloat rolloverY = [self numberValue:secondaryOffset[@"y"]] * coordinateScale;
CGFloat restX = normalX;
CGFloat restY = normalY;
NSString *anchor = [input[@"anchor"] isKindOfClass:[NSString class]] ? input[@"anchor"] : @"";
@@ -843,20 +1019,12 @@ + (CGRect)inputFrameForInput:(NSDictionary *)input
if ([offsetName isEqualToString:@"rollover"]) {
restX = rolloverX;
restY = rolloverY;
- } else if ([anchor isEqualToString:@"left"]) {
- restX = rolloverX;
- restY = rolloverY;
- } else if ([anchor isEqualToString:@"right"] ||
- [anchor isEqualToString:@"top"] ||
- [anchor isEqualToString:@"bottom"]) {
- restX = (2.0 * normalX) - rolloverX;
- restY = (2.0 * normalY) - rolloverY;
}
CGFloat x = restX - (assetSize.width / 2.0);
CGFloat y = restY;
if ([anchor isEqualToString:@"left"]) {
- x = restX - (assetSize.width / 2.0);
+ x = restX - assetSize.width;
} else if ([anchor isEqualToString:@"right"]) {
x = size.width + restX;
y = restY;
@@ -895,14 +1063,24 @@ + (CGRect)inputFrameForInput:(NSDictionary *)input
return CGRectMake(x, y, assetSize.width, assetSize.height);
}
-+ (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo {
++ (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize {
NSDictionary *json = chromeInfo[@"json"];
NSDictionary *images = [json[@"images"] isKindOfClass:[NSDictionary class]] ? json[@"images"] : @{};
NSDictionary *padding = [images[@"devicePadding"] isKindOfClass:[NSDictionary class]] ? images[@"devicePadding"] : @{};
- return NSEdgeInsetsMake([self numberValue:padding[@"top"]],
- [self numberValue:padding[@"left"]],
- [self numberValue:padding[@"bottom"]],
- [self numberValue:padding[@"right"]]);
+ CGFloat inputScale = [self inputScaleForChromeInfo:chromeInfo chromeSize:chromeSize];
+ return NSEdgeInsetsMake([self numberValue:padding[@"top"]] * inputScale,
+ [self numberValue:padding[@"left"]] * inputScale,
+ [self numberValue:padding[@"bottom"]] * inputScale,
+ [self numberValue:padding[@"right"]] * inputScale);
+}
+
++ (CGSize)screenPaddingForChromeInfo:(NSDictionary *)chromeInfo {
+ NSDictionary *json = chromeInfo[@"json"];
+ NSDictionary *images = [json[@"images"] isKindOfClass:[NSDictionary class]] ? json[@"images"] : @{};
+ NSDictionary *padding = [images[@"padding"] isKindOfClass:[NSDictionary class]] ? images[@"padding"] : @{};
+ return CGSizeMake([self numberValue:padding[@"width"]],
+ [self numberValue:padding[@"height"]]);
}
+ (NSArray *> *)buttonProfilesForChromeInfo:(NSDictionary *)chromeInfo
@@ -925,12 +1103,24 @@ + (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo {
}
NSString *assetPath = [self resolvedChromeAssetPathForName:assetName chromePath:chromePath];
- CGSize assetSize = [self PDFPageSizeAtPath:assetPath];
+ CGFloat inputScale = [self inputAssetScaleForInput:input chromeInfo:chromeInfo chromeSize:chromeSize];
+ CGFloat coordinateScale = [self inputCoordinateScaleForChromeInfo:chromeInfo chromeSize:chromeSize];
+ CGSize assetSize = [self scaledInputAssetSize:[self PDFPageSizeAtPath:assetPath]
+ scale:inputScale];
if (assetSize.width <= 0.0 || assetSize.height <= 0.0) {
continue;
}
- CGRect rect = [self inputFrameForInput:input assetSize:assetSize inSize:chromeSize];
+ CGRect rect = [self inputFrameForInput:input
+ assetSize:assetSize
+ inSize:chromeSize
+ scale:coordinateScale];
+ rect = CGRectOffset(rect,
+ [self inputHorizontalAdjustmentForInput:input chromeInfo:chromeInfo chromeSize:chromeSize],
+ 0.0);
+ rect = CGRectOffset(rect,
+ 0.0,
+ [self inputVerticalAdjustmentForInput:input chromeInfo:chromeInfo chromeSize:chromeSize]);
rect = CGRectOffset(rect, chromeOffset.x, chromeOffset.y);
if (CGRectGetWidth(rect) <= 0.0 || CGRectGetHeight(rect) <= 0.0) {
continue;
@@ -945,7 +1135,7 @@ + (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo {
NSString *align = [input[@"align"] isKindOfClass:[NSString class]] ? input[@"align"] : @"";
NSString *imageDownName = [input[@"imageDown"] isKindOfClass:[NSString class]] ? input[@"imageDown"] : @"";
NSString *imageDownDrawMode = [input[@"imageDownDrawMode"] isKindOfClass:[NSString class]] ? input[@"imageDownDrawMode"] : @"";
- BOOL onTop = [input[@"onTop"] respondsToSelector:@selector(boolValue)] && [input[@"onTop"] boolValue];
+ BOOL onTop = [self input:input shouldDrawOnTopForChromeInfo:chromeInfo];
NSMutableDictionary *button = [@{
@"name": name,
@@ -960,12 +1150,12 @@ + (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo {
@"align": align,
@"onTop": @(onTop),
@"normalOffset": @{
- @"x": @([self numberValue:normalOffset[@"x"]]),
- @"y": @([self numberValue:normalOffset[@"y"]]),
+ @"x": @([self numberValue:normalOffset[@"x"]] * coordinateScale),
+ @"y": @([self numberValue:normalOffset[@"y"]] * coordinateScale),
},
@"rolloverOffset": @{
- @"x": @([self numberValue:rolloverOffset[@"x"]]),
- @"y": @([self numberValue:rolloverOffset[@"y"]]),
+ @"x": @([self numberValue:rolloverOffset[@"x"]] * coordinateScale),
+ @"y": @([self numberValue:rolloverOffset[@"y"]] * coordinateScale),
},
} mutableCopy];
@@ -1027,13 +1217,202 @@ + (CGSize)screenSizeForChromeInfo:(NSDictionary *)chromeInfo
return CGSizeMake(rawWidth / scale, rawHeight / scale);
}
- if (chromeSize.width > 0.0 &&
- chromeSize.height > 0.0 &&
- rawWidth <= chromeSize.width &&
- rawHeight <= chromeSize.height) {
- return CGSizeMake(rawWidth, rawHeight);
+ return CGSizeMake(rawWidth, rawHeight);
+}
+
++ (CGFloat)inputScaleForChromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize {
+ NSDictionary *plist = chromeInfo[@"plist"];
+ if (![self isWatchProfile:plist]) {
+ (void)chromeSize;
+ return 1.0;
+ }
+
+ CGFloat coordinateScale = [self inputCoordinateScaleForChromeInfo:chromeInfo chromeSize:chromeSize];
+ return MAX(coordinateScale, 1.0);
+}
+
++ (CGFloat)inputAssetScaleForInput:(NSDictionary *)input
+ chromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize {
+ (void)input;
+ CGFloat baseScale = [self inputScaleForChromeInfo:chromeInfo chromeSize:chromeSize];
+ return baseScale;
+}
+
++ (CGFloat)inputCoordinateScaleForChromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize {
+ NSDictionary *plist = chromeInfo[@"plist"];
+ if (![self isWatchProfile:plist]) {
+ return [self inputScaleForChromeInfo:chromeInfo chromeSize:chromeSize];
+ }
+
+ CGFloat screenScale = MAX([self numberValue:plist[@"mainScreenScale"]], 1.0);
+ NSDictionary *json = chromeInfo[@"json"];
+ NSDictionary *images = [json[@"images"] isKindOfClass:[NSDictionary class]] ? json[@"images"] : @{};
+ NSDictionary *sizing = [images[@"sizing"] isKindOfClass:[NSDictionary class]] ? images[@"sizing"] : @{};
+ NSDictionary *stand = [images[@"stand"] isKindOfClass:[NSDictionary class]] ? images[@"stand"] : @{};
+ CGFloat nominalWidth = chromeSize.width -
+ [self numberValue:sizing[@"leftWidth"]] -
+ [self numberValue:sizing[@"rightWidth"]];
+ CGFloat nominalHeight = chromeSize.height -
+ [self numberValue:stand[@"height"]] -
+ [self numberValue:sizing[@"topHeight"]] -
+ [self numberValue:sizing[@"bottomHeight"]];
+ CGFloat screenWidth = [self numberValue:plist[@"mainScreenWidth"]];
+ CGFloat screenHeight = [self numberValue:plist[@"mainScreenHeight"]];
+ if (nominalWidth <= 0.0 || nominalHeight <= 0.0 || screenWidth <= 0.0 || screenHeight <= 0.0) {
+ return screenScale;
+ }
+
+ CGFloat fitScale = MIN(screenWidth / nominalWidth, screenHeight / nominalHeight);
+ if (!isfinite(fitScale) || fitScale <= 0.0) {
+ return screenScale;
+ }
+ return screenScale * MIN(fitScale, 1.0);
+}
+
++ (CGFloat)inputVerticalAdjustmentForInput:(NSDictionary *)input
+ chromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize {
+ NSDictionary *plist = chromeInfo[@"plist"];
+ if (![self isWatchProfile:plist]) {
+ return 0.0;
+ }
+
+ (void)input;
+ NSDictionary *json = chromeInfo[@"json"];
+ NSDictionary *images = [json[@"images"] isKindOfClass:[NSDictionary class]] ? json[@"images"] : @{};
+ NSDictionary *sizing = [images[@"sizing"] isKindOfClass:[NSDictionary class]] ? images[@"sizing"] : @{};
+ NSDictionary *stand = [images[@"stand"] isKindOfClass:[NSDictionary class]] ? images[@"stand"] : @{};
+ CGFloat slotHeight = chromeSize.height -
+ [self numberValue:stand[@"height"]] -
+ [self numberValue:sizing[@"topHeight"]] -
+ [self numberValue:sizing[@"bottomHeight"]];
+ CGSize maskSize = [self framebufferMaskSizeForChromeInfo:chromeInfo];
+ CGSize displaySize = maskSize.width > 0.0 && maskSize.height > 0.0
+ ? maskSize
+ : [self screenSizeForChromeInfo:chromeInfo
+ chromeSize:chromeSize
+ screenScale:MAX([self numberValue:plist[@"mainScreenScale"]], 1.0)];
+ if (slotHeight <= 0.0 || displaySize.height <= 0.0) {
+ return 0.0;
+ }
+ return MAX((slotHeight - MIN(displaySize.height, slotHeight)) / 2.0, 0.0);
+}
+
++ (CGFloat)inputHorizontalAdjustmentForInput:(NSDictionary *)input
+ chromeInfo:(NSDictionary *)chromeInfo
+ chromeSize:(CGSize)chromeSize {
+ if (![self isWatchProfile:chromeInfo[@"plist"]]) {
+ return 0.0;
+ }
+
+ (void)chromeSize;
+ CGSize maskSize = [self framebufferMaskSizeForChromeInfo:chromeInfo];
+ CGRect blackScreenBounds = [self blackScreenBoundsForChromeInfo:chromeInfo
+ matchingDisplaySize:maskSize];
+ if (CGRectIsEmpty(blackScreenBounds)) {
+ return 0.0;
+ }
+
+ CGSize screenPadding = [self screenPaddingForChromeInfo:chromeInfo];
+ CGFloat adjustment = MAX(screenPadding.width, 0.0);
+ if (adjustment <= 0.0) {
+ return 0.0;
+ }
+
+ NSString *anchor = [input[@"anchor"] isKindOfClass:[NSString class]] ? input[@"anchor"] : @"";
+ NSString *name = [input[@"name"] isKindOfClass:[NSString class]] ? input[@"name"] : @"";
+ if (![anchor isEqualToString:@"right"]) {
+ return 0.0;
+ }
+ if ([self isDigitalCrownInput:input]) {
+ return 0.0;
+ }
+ if ([name isEqualToString:@"side-button"]) {
+ return -adjustment;
}
- return CGSizeMake(rawWidth / scale, rawHeight / scale);
+ return 0.0;
+}
+
++ (CGSize)scaledInputAssetSize:(CGSize)assetSize scale:(CGFloat)scale {
+ CGFloat inputScale = MAX(scale, 1.0);
+ return CGSizeMake(assetSize.width * inputScale, assetSize.height * inputScale);
+}
+
++ (BOOL)isDigitalCrownInput:(NSDictionary *)input {
+ NSString *type = [input[@"type"] isKindOfClass:[NSString class]] ? input[@"type"] : @"";
+ NSString *name = [input[@"name"] isKindOfClass:[NSString class]] ? input[@"name"] : @"";
+ return [type isEqualToString:@"crown"] || [name isEqualToString:@"digital-crown"];
+}
+
++ (BOOL)input:(NSDictionary *)input shouldDrawOnTopForChromeInfo:(NSDictionary *)chromeInfo {
+ if ([input[@"onTop"] respondsToSelector:@selector(boolValue)] && [input[@"onTop"] boolValue]) {
+ return YES;
+ }
+ return [self isWatchProfile:chromeInfo[@"plist"]] && [self isDigitalCrownInput:input];
+}
+
++ (void)drawDigitalCrownTextureInRect:(CGRect)rect context:(CGContextRef)context {
+ if (CGRectGetWidth(rect) <= 3.0 || CGRectGetHeight(rect) <= 6.0) {
+ return;
+ }
+
+ CGFloat width = CGRectGetWidth(rect);
+ CGFloat height = CGRectGetHeight(rect);
+ CGFloat radius = MIN(width, height) / 2.0;
+ CGFloat stripWidth = MAX(width * 0.22, 2.5);
+ CGRect textureRect = CGRectMake(CGRectGetMaxX(rect) - stripWidth,
+ CGRectGetMinY(rect) + 8.0,
+ stripWidth,
+ MAX(height - 16.0, 1.0));
+
+ CGContextSaveGState(context);
+ CGMutablePathRef clip = CGPathCreateMutable();
+ CGPathAddRoundedRect(clip, NULL, rect, radius, radius);
+ CGContextAddPath(context, clip);
+ CGContextClip(context);
+ CGPathRelease(clip);
+ CGContextClipToRect(context, textureRect);
+
+ CGFloat minY = CGRectGetMinY(textureRect);
+ CGFloat maxY = CGRectGetMaxY(textureRect);
+ CGFloat minX = CGRectGetMinX(textureRect);
+ CGFloat maxX = CGRectGetMaxX(textureRect);
+ CGFloat lineSpacing = MAX(1.35, height / 52.0);
+ CGContextSetLineCap(context, kCGLineCapButt);
+ CGContextSetLineWidth(context, 0.22);
+ for (CGFloat y = minY; y <= maxY; y += lineSpacing) {
+ CGFloat normalized = (y - CGRectGetMinY(rect)) / MAX(height, 1.0);
+ CGFloat capInset = sin(normalized * (CGFloat)M_PI) * stripWidth * 0.16;
+ CGFloat strokeStart = minX + capInset + stripWidth * 0.18;
+ CGFloat strokeEnd = maxX - stripWidth * 0.08;
+ CGContextSetStrokeColorWithColor(context, [NSColor colorWithWhite:0.88 alpha:0.10].CGColor);
+ CGContextMoveToPoint(context, strokeStart, y);
+ CGContextAddLineToPoint(context, strokeEnd, y);
+ CGContextStrokePath(context);
+
+ CGContextSetStrokeColorWithColor(context, [NSColor colorWithWhite:0.0 alpha:0.08].CGColor);
+ CGContextMoveToPoint(context, strokeStart, y + 0.34);
+ CGContextAddLineToPoint(context, strokeEnd, y + 0.34);
+ CGContextStrokePath(context);
+ }
+
+ CGRect innerShadow = CGRectMake(CGRectGetMinX(textureRect),
+ CGRectGetMinY(textureRect),
+ MAX(stripWidth * 0.18, 1.0),
+ CGRectGetHeight(textureRect));
+ CGContextSetFillColorWithColor(context, [NSColor colorWithWhite:0.0 alpha:0.10].CGColor);
+ CGContextFillRect(context, innerShadow);
+
+ CGRect edgeHighlight = CGRectMake(CGRectGetMaxX(textureRect) - MAX(stripWidth * 0.18, 1.0),
+ CGRectGetMinY(textureRect),
+ MAX(stripWidth * 0.08, 1.0),
+ CGRectGetHeight(textureRect));
+ CGContextSetFillColorWithColor(context, [NSColor colorWithWhite:1.0 alpha:0.035].CGColor);
+ CGContextFillRect(context, edgeHighlight);
+ CGContextRestoreGState(context);
}
+ (BOOL)drawRasterizedPDFAtPath:(NSString *)path
@@ -1141,10 +1520,18 @@ + (BOOL)clearScreenAreaForChromeInfo:(NSDictionary *)chromeInfo
profile:(NSDictionary *)profile
context:(CGContextRef)context
error:(NSError * _Nullable __autoreleasing *)error {
- CGFloat x = [self numberValue:profile[@"screenX"]];
- CGFloat y = [self numberValue:profile[@"screenY"]];
- CGFloat width = [self numberValue:profile[@"screenWidth"]];
- CGFloat height = [self numberValue:profile[@"screenHeight"]];
+ CGFloat x = [profile[@"contentX"] respondsToSelector:@selector(doubleValue)]
+ ? [self numberValue:profile[@"contentX"]]
+ : [self numberValue:profile[@"screenX"]];
+ CGFloat y = [profile[@"contentY"] respondsToSelector:@selector(doubleValue)]
+ ? [self numberValue:profile[@"contentY"]]
+ : [self numberValue:profile[@"screenY"]];
+ CGFloat width = [profile[@"contentWidth"] respondsToSelector:@selector(doubleValue)]
+ ? [self numberValue:profile[@"contentWidth"]]
+ : [self numberValue:profile[@"screenWidth"]];
+ CGFloat height = [profile[@"contentHeight"] respondsToSelector:@selector(doubleValue)]
+ ? [self numberValue:profile[@"contentHeight"]]
+ : [self numberValue:profile[@"screenHeight"]];
if (width <= 0.0 || height <= 0.0) {
return YES;
}
@@ -1250,6 +1637,126 @@ + (NSString *)screenMaskPathForChromeInfo:(NSDictionary *)chromeInfo {
return [[NSFileManager defaultManager] fileExistsAtPath:maskPath] ? maskPath : @"";
}
++ (CGSize)framebufferMaskSizeForChromeInfo:(NSDictionary *)chromeInfo {
+ NSString *maskPath = [self screenMaskPathForChromeInfo:chromeInfo];
+ if (maskPath.length == 0) {
+ return CGSizeZero;
+ }
+
+ CGPDFDocumentRef document = CGPDFDocumentCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:maskPath]);
+ if (document == NULL) {
+ return CGSizeZero;
+ }
+ CGPDFPageRef page = CGPDFDocumentGetPage(document, 1);
+ CGRect mediaBox = page != NULL ? CGPDFPageGetBoxRect(page, kCGPDFMediaBox) : CGRectZero;
+ CGPDFDocumentRelease(document);
+ return mediaBox.size;
+}
+
++ (CGRect)blackScreenBoundsForChromeInfo:(NSDictionary *)chromeInfo
+ matchingDisplaySize:(CGSize)displaySize {
+ if (![self isWatchProfile:chromeInfo[@"plist"]] ||
+ displaySize.width <= 0.0 ||
+ displaySize.height <= 0.0) {
+ return CGRectZero;
+ }
+
+ NSString *compositePath = [self compositeAssetPathForChromeInfo:chromeInfo];
+ if (compositePath.length == 0) {
+ return CGRectZero;
+ }
+
+ NSString *cacheKey = [NSString stringWithFormat:@"%@:%.3fx%.3f",
+ compositePath,
+ displaySize.width,
+ displaySize.height];
+ static NSMutableDictionary *boundsCache = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ boundsCache = [NSMutableDictionary dictionary];
+ });
+ @synchronized(boundsCache) {
+ NSValue *cached = boundsCache[cacheKey];
+ if (cached != nil) {
+ return cached.rectValue;
+ }
+ }
+
+ CGRect result = CGRectZero;
+ CGPDFDocumentRef document = CGPDFDocumentCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:compositePath]);
+ if (document != NULL) {
+ CGPDFPageRef page = CGPDFDocumentGetPage(document, 1);
+ CGRect mediaBox = page != NULL ? CGPDFPageGetBoxRect(page, kCGPDFMediaBox) : CGRectZero;
+ NSInteger width = MAX((NSInteger)ceil(mediaBox.size.width), 1);
+ NSInteger height = MAX((NSInteger)ceil(mediaBox.size.height), 1);
+ if (page != NULL && width > 1 && height > 1 && width <= 4096 && height <= 4096) {
+ size_t bytesPerRow = (size_t)width * 4;
+ NSMutableData *pixels = [NSMutableData dataWithLength:(size_t)height * bytesPerRow];
+ CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
+ CGContextRef context = CGBitmapContextCreate(pixels.mutableBytes,
+ (size_t)width,
+ (size_t)height,
+ 8,
+ bytesPerRow,
+ colorSpace,
+ kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
+ CGColorSpaceRelease(colorSpace);
+ if (context != NULL) {
+ CGContextClearRect(context, CGRectMake(0, 0, width, height));
+ CGContextSaveGState(context);
+ CGContextTranslateCTM(context, 0, height);
+ CGContextScaleCTM(context,
+ (CGFloat)width / MAX(mediaBox.size.width, 1.0),
+ -((CGFloat)height / MAX(mediaBox.size.height, 1.0)));
+ CGContextTranslateCTM(context, -mediaBox.origin.x, -mediaBox.origin.y);
+ CGContextDrawPDFPage(context, page);
+ CGContextRestoreGState(context);
+ CGContextRelease(context);
+
+ const unsigned char *bytes = pixels.bytes;
+ NSInteger minX = width;
+ NSInteger minY = height;
+ NSInteger maxX = -1;
+ NSInteger maxY = -1;
+ for (NSInteger y = 0; y < height; y++) {
+ for (NSInteger x = 0; x < width; x++) {
+ size_t index = ((size_t)y * bytesPerRow) + ((size_t)x * 4);
+ unsigned char red = bytes[index];
+ unsigned char green = bytes[index + 1];
+ unsigned char blue = bytes[index + 2];
+ unsigned char alpha = bytes[index + 3];
+ if (alpha > 127 && red < 8 && green < 8 && blue < 8) {
+ minX = MIN(minX, x);
+ minY = MIN(minY, y);
+ maxX = MAX(maxX, x);
+ maxY = MAX(maxY, y);
+ }
+ }
+ }
+
+ if (maxX >= minX && maxY >= minY) {
+ CGRect pixelBounds = CGRectMake((CGFloat)minX,
+ (CGFloat)minY,
+ (CGFloat)(maxX - minX + 1),
+ (CGFloat)(maxY - minY + 1));
+ CGFloat widthDelta = fabs(CGRectGetWidth(pixelBounds) - displaySize.width);
+ CGFloat heightDelta = fabs(CGRectGetHeight(pixelBounds) - displaySize.height);
+ CGFloat tolerance = MAX(8.0, MAX(displaySize.width, displaySize.height) * 0.02);
+ if (widthDelta <= tolerance && heightDelta <= tolerance) {
+ result = pixelBounds;
+ }
+ }
+ }
+ }
+ CGPDFDocumentRelease(document);
+ }
+
+ @synchronized(boundsCache) {
+ boundsCache[cacheKey] = [NSValue valueWithRect:result];
+ }
+ return result;
+}
+
+ (CGFloat)framebufferMaskCornerRadiusForChromeInfo:(NSDictionary *)chromeInfo
pointScreenWidth:(CGFloat)pointScreenWidth {
NSString *maskPath = [self screenMaskPathForChromeInfo:chromeInfo];
diff --git a/client/package-lock.json b/client/package-lock.json
index 6ca02901..022b6cb8 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -53,7 +53,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1239,7 +1238,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1433,7 +1431,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -1800,7 +1797,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -1842,7 +1838,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2093,7 +2088,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
diff --git a/client/src/api/types.ts b/client/src/api/types.ts
index 1a650054..ce0733f5 100644
--- a/client/src/api/types.ts
+++ b/client/src/api/types.ts
@@ -231,6 +231,10 @@ export interface ChromeProfile {
screenY: number;
screenWidth: number;
screenHeight: number;
+ contentX?: number;
+ contentY?: number;
+ contentWidth?: number;
+ contentHeight?: number;
cornerRadius: number;
cornerRadii?: {
topLeft?: number;
diff --git a/client/src/app/AppShell.tsx b/client/src/app/AppShell.tsx
index 06b5f76a..3a27948d 100644
--- a/client/src/app/AppShell.tsx
+++ b/client/src/app/AppShell.tsx
@@ -84,6 +84,7 @@ import {
buildShellRotationTransform,
clampPan,
clampZoom,
+ computeChromeBackingRect,
computeChromeScreenBorderRadius,
computeChromeScreenRect,
normalizeQuarterTurns,
@@ -149,6 +150,7 @@ const STREAM_TRANSPORT_VALUES = new Set([
"webrtc",
]);
const MOBILE_VIEWPORT_MEDIA_QUERY = "(max-width: 600px)";
+const CHROME_RENDERER_ASSET_VERSION = "chrome-renderer-watch-bezel-inset-22";
clearLegacyVolatileUiState();
interface StreamQualityResponse {
@@ -211,6 +213,63 @@ function buildAuthenticatedAssetUrl(
return url.toString();
}
+function chromeStampNumber(value: number | undefined): string {
+ return Number.isFinite(value) ? String(Math.round((value ?? 0) * 1000)) : "0";
+}
+
+function chromeStampText(value: string | undefined | null): string {
+ return (value ?? "").replace(/[^a-zA-Z0-9_.-]+/g, "_");
+}
+
+function buildChromeProfileAssetStamp(profile: ChromeProfile | null): string {
+ if (!profile) {
+ return "";
+ }
+
+ const geometryStamp = [
+ profile.totalWidth,
+ profile.totalHeight,
+ profile.screenX,
+ profile.screenY,
+ profile.screenWidth,
+ profile.screenHeight,
+ profile.contentX,
+ profile.contentY,
+ profile.contentWidth,
+ profile.contentHeight,
+ profile.cornerRadius,
+ ]
+ .map(chromeStampNumber)
+ .join("x");
+ const maskStamp = profile.hasScreenMask ? "mask" : "nomask";
+ const buttonStamp = [...(profile.buttons ?? [])]
+ .sort((left, right) => left.name.localeCompare(right.name))
+ .map((button) =>
+ [
+ chromeStampText(button.name),
+ chromeStampText(button.type),
+ chromeStampText(button.imageName),
+ chromeStampText(button.imageDownName),
+ chromeStampText(button.anchor),
+ chromeStampText(button.align),
+ button.onTop ? "top" : "under",
+ chromeStampNumber(button.x),
+ chromeStampNumber(button.y),
+ chromeStampNumber(button.width),
+ chromeStampNumber(button.height),
+ chromeStampNumber(button.normalOffset?.x),
+ chromeStampNumber(button.normalOffset?.y),
+ chromeStampNumber(button.rolloverOffset?.x),
+ chromeStampNumber(button.rolloverOffset?.y),
+ String(button.usagePage ?? ""),
+ String(button.usage ?? ""),
+ ].join(","),
+ )
+ .join(";");
+
+ return [geometryStamp, maskStamp, buttonStamp].filter(Boolean).join(":");
+}
+
function shouldUseRemoteStreamDefault(apiRoot: string): boolean {
if (apiRoot) {
return true;
@@ -918,23 +977,25 @@ export function AppShell({
button.name.toLowerCase() === "digital-crown",
),
);
+ const chromeGeometryStamp = buildChromeProfileAssetStamp(
+ viewportChromeProfile,
+ );
const chromeAssetStamp = [
selectedSimulator?.deviceTypeIdentifier,
selectedSimulator?.deviceTypeName,
selectedSimulator?.runtimeIdentifier,
selectedSimulator?.runtimeName,
selectedSimulator?.udid,
- chromeHasInteractiveButtons ? "buttons" : "no-buttons",
+ chromeGeometryStamp,
+ CHROME_RENDERER_ASSET_VERSION,
+ chromeHasInteractiveButtons ? "baked-buttons" : "no-buttons",
chromeHasCrown ? "crown" : "no-crown",
]
.filter(Boolean)
.join(":");
+ const chromeButtonsRenderedInChrome = chromeHasInteractiveButtons;
const chromeUrl = selectedSimulator
- ? buildChromeUrl(
- selectedSimulator.udid,
- chromeAssetStamp,
- !chromeHasInteractiveButtons || chromeHasCrown,
- )
+ ? buildChromeUrl(selectedSimulator.udid, chromeAssetStamp, true)
: "";
const chromeButtonUrl = useCallback(
(button: string, pressed = false) =>
@@ -963,10 +1024,12 @@ export function AppShell({
if (viewportChromeProfile.hasScreenMask) {
urls.add(buildScreenMaskUrl(selectedSimulator.udid, chromeAssetStamp));
}
- for (const button of viewportChromeProfile.buttons ?? []) {
- urls.add(chromeButtonUrl(button.name, false));
- if (button.imageDownName) {
- urls.add(chromeButtonUrl(button.name, true));
+ if (!chromeButtonsRenderedInChrome) {
+ for (const button of viewportChromeProfile.buttons ?? []) {
+ urls.add(chromeButtonUrl(button.name, false));
+ if (button.imageDownName) {
+ urls.add(chromeButtonUrl(button.name, true));
+ }
}
}
return [...urls].filter(Boolean);
@@ -975,6 +1038,7 @@ export function AppShell({
chromeRequired,
chromeUrl,
chromeAssetStamp,
+ chromeButtonsRenderedInChrome,
selectedSimulator?.udid,
viewportChromeProfile,
]);
@@ -1698,10 +1762,17 @@ export function AppShell({
viewportChromeProfile,
effectiveDeviceNaturalSize,
);
+ const chromeScreenBackingRect = computeChromeBackingRect(
+ viewportChromeProfile,
+ );
const chromeScreenBorderRadius = computeChromeScreenBorderRadius(
viewportChromeProfile,
chromeScreenRect,
);
+ const chromeScreenBackingBorderRadius = computeChromeScreenBorderRadius(
+ viewportChromeProfile,
+ chromeScreenBackingRect,
+ );
const chromeScreenStyle =
viewportChromeProfile && chromeScreenRect
? ({
@@ -1731,6 +1802,16 @@ export function AppShell({
: {}),
} satisfies CSSProperties)
: null;
+ const chromeScreenBackingStyle =
+ viewportChromeProfile && chromeScreenBackingRect
+ ? ({
+ left: `${(chromeScreenBackingRect.x / viewportChromeProfile.totalWidth) * 100}%`,
+ top: `${(chromeScreenBackingRect.y / viewportChromeProfile.totalHeight) * 100}%`,
+ width: `${(chromeScreenBackingRect.width / viewportChromeProfile.totalWidth) * 100}%`,
+ height: `${(chromeScreenBackingRect.height / viewportChromeProfile.totalHeight) * 100}%`,
+ borderRadius: chromeScreenBackingBorderRadius ?? "0",
+ } satisfies CSSProperties)
+ : null;
const screenOnlyStyle =
!viewportChromeProfile && chromeProfile && chromeProfile.screenWidth > 0
? isAndroidViewport
@@ -2746,6 +2827,8 @@ export function AppShell({
chromeLoaded={chromeLoaded}
chromeProfile={viewportChromeProfile}
chromeRequired={chromeRequired}
+ chromeButtonsRenderedInChrome={chromeButtonsRenderedInChrome}
+ chromeScreenBackingStyle={chromeScreenBackingStyle}
chromeScreenStyle={viewportScreenStyle}
chromeUrl={chromeUrl}
chromeButtonUrl={chromeButtonUrl}
diff --git a/client/src/features/viewport/DeviceChrome.tsx b/client/src/features/viewport/DeviceChrome.tsx
index e548617b..37333df4 100644
--- a/client/src/features/viewport/DeviceChrome.tsx
+++ b/client/src/features/viewport/DeviceChrome.tsx
@@ -16,6 +16,8 @@ interface DeviceChromeProps {
accessibilityRoots: AccessibilityNode[];
accessibilitySelectedId: string;
chromeProfile: ChromeProfile | null;
+ chromeButtonsRenderedInChrome: boolean;
+ chromeScreenBackingStyle: CSSProperties | null;
chromeScreenStyle: CSSProperties | null;
chromeUrl: string;
chromeButtonUrl: (button: string, pressed?: boolean) => string;
@@ -61,6 +63,8 @@ export function DeviceChrome({
accessibilityRoots,
accessibilitySelectedId,
chromeProfile,
+ chromeButtonsRenderedInChrome,
+ chromeScreenBackingStyle,
chromeScreenStyle,
chromeUrl,
chromeButtonUrl,
@@ -109,6 +113,7 @@ export function DeviceChrome({
chromeProfile={chromeProfile}
layer="under"
onEvent={onChromeButtonEvent}
+ renderImages={!chromeButtonsRenderedInChrome}
/>
+ {chromeScreenBackingStyle ? (
+
+ ) : null}
string;
chromeProfile: ChromeProfile | null;
@@ -228,6 +242,7 @@ function ChromeButtonOverlay({
usagePage?: number,
usage?: number,
) => void;
+ renderImages: boolean;
}) {
const buttons = chromeProfile?.buttons ?? [];
if (!chromeProfile || buttons.length === 0) {
@@ -254,6 +269,7 @@ function ChromeButtonOverlay({
chromeButtonUrl={chromeButtonUrl}
key={`${button.name}-${button.x}-${button.y}`}
onEvent={onEvent}
+ renderImages={renderImages}
totalHeight={chromeProfile.totalHeight}
totalWidth={chromeProfile.totalWidth}
wireName={wireName}
@@ -268,6 +284,7 @@ function ChromeButtonHitTarget({
button,
chromeButtonUrl,
onEvent,
+ renderImages,
totalHeight,
totalWidth,
wireName,
@@ -280,6 +297,7 @@ function ChromeButtonHitTarget({
usagePage?: number,
usage?: number,
) => void;
+ renderImages: boolean;
totalHeight: number;
totalWidth: number;
wireName: string;
@@ -372,7 +390,7 @@ function ChromeButtonHitTarget({
title={label}
type="button"
>
- {downCompositeUnder ? (
+ {renderImages && downCompositeUnder ? (
) : null}
-
- {!pressed && pressedImageUrl ? (
+ {renderImages ? (
+
+ ) : null}
+ {renderImages && !pressed && pressedImageUrl ? (
{
expect(clamped).toEqual({ x: 0, y: -60 });
});
- it("fits device aspect inside chrome screen rect", () => {
+ it("uses the exact chrome screen rect even when stream aspect differs", () => {
const rect = computeChromeScreenRect(
{
cornerRadius: 40,
@@ -58,8 +59,12 @@ describe("viewportMath", () => {
);
expect(rect).not.toBeNull();
- expect(rect?.x).toBeGreaterThanOrEqual(50);
- expect(rect?.y).toBeGreaterThanOrEqual(25);
+ expect(rect).toEqual({
+ height: 600,
+ width: 300,
+ x: 50,
+ y: 25,
+ });
});
it("uses the full chrome screen when stream and profile aspect nearly match", () => {
@@ -84,6 +89,37 @@ describe("viewportMath", () => {
});
});
+ it("keeps watch display backing separate from stream content", () => {
+ const profile = {
+ contentHeight: 464.062,
+ contentWidth: 381,
+ contentX: 88.5,
+ contentY: 58.469,
+ cornerRadius: 135,
+ screenHeight: 513,
+ screenWidth: 422,
+ screenX: 68,
+ screenY: 34,
+ totalHeight: 581,
+ totalWidth: 573,
+ };
+
+ expect(computeChromeBackingRect(profile)).toEqual({
+ height: 513,
+ width: 422,
+ x: 68,
+ y: 34,
+ });
+ expect(
+ computeChromeScreenRect(profile, { width: 422, height: 514 }),
+ ).toEqual({
+ height: 464.062,
+ width: 381,
+ x: 88.5,
+ y: 58.469,
+ });
+ });
+
it("only rounds stream corners that touch the physical screen corners", () => {
const profile = {
cornerRadius: 40,
diff --git a/client/src/features/viewport/viewportMath.ts b/client/src/features/viewport/viewportMath.ts
index bee52234..2ebee5db 100644
--- a/client/src/features/viewport/viewportMath.ts
+++ b/client/src/features/viewport/viewportMath.ts
@@ -88,44 +88,63 @@ export function mapDisplayedPointToNaturalOrientation(
export function computeChromeScreenRect(
chromeProfile: ChromeProfile | null,
- deviceNaturalSize: Size | null,
+ _deviceNaturalSize: Size | null,
): ScreenRect | null {
- if (!chromeProfile) {
- return null;
+ const contentRect = computeChromeContentRect(chromeProfile);
+ if (contentRect) {
+ return contentRect;
}
+ return computeChromeBackingRect(chromeProfile);
+}
- const profileAspect = chromeProfile.screenWidth / chromeProfile.screenHeight;
- const deviceAspect = deviceNaturalSize
- ? deviceNaturalSize.width / deviceNaturalSize.height
- : profileAspect;
- if (!deviceAspect || !Number.isFinite(deviceAspect)) {
+export function computeChromeBackingRect(
+ chromeProfile: ChromeProfile | null,
+): ScreenRect | null {
+ if (!chromeProfile) {
return null;
}
- const aspectDelta = Math.abs(deviceAspect - profileAspect) / profileAspect;
- if (aspectDelta <= 0.01) {
- return {
- height: chromeProfile.screenHeight,
- width: chromeProfile.screenWidth,
- x: chromeProfile.screenX,
- y: chromeProfile.screenY,
- };
+ if (
+ !Number.isFinite(chromeProfile.screenX) ||
+ !Number.isFinite(chromeProfile.screenY) ||
+ !Number.isFinite(chromeProfile.screenWidth) ||
+ !Number.isFinite(chromeProfile.screenHeight) ||
+ chromeProfile.screenWidth <= 0 ||
+ chromeProfile.screenHeight <= 0
+ ) {
+ return null;
}
- let width = chromeProfile.screenWidth;
- let height = width / deviceAspect;
- let x = chromeProfile.screenX;
- let y = chromeProfile.screenY;
+ return {
+ height: chromeProfile.screenHeight,
+ width: chromeProfile.screenWidth,
+ x: chromeProfile.screenX,
+ y: chromeProfile.screenY,
+ };
+}
- if (height > chromeProfile.screenHeight) {
- height = chromeProfile.screenHeight;
- width = height * deviceAspect;
- x += (chromeProfile.screenWidth - width) / 2;
- } else {
- y += (chromeProfile.screenHeight - height) / 2;
+function computeChromeContentRect(
+ chromeProfile: ChromeProfile | null,
+): ScreenRect | null {
+ if (!chromeProfile) {
+ return null;
}
-
- return { x, y, width, height };
+ if (
+ !Number.isFinite(chromeProfile.contentX) ||
+ !Number.isFinite(chromeProfile.contentY) ||
+ !Number.isFinite(chromeProfile.contentWidth) ||
+ !Number.isFinite(chromeProfile.contentHeight) ||
+ (chromeProfile.contentWidth ?? 0) <= 0 ||
+ (chromeProfile.contentHeight ?? 0) <= 0
+ ) {
+ return null;
+ }
+ return {
+ height: chromeProfile.contentHeight ?? 0,
+ width: chromeProfile.contentWidth ?? 0,
+ x: chromeProfile.contentX ?? 0,
+ y: chromeProfile.contentY ?? 0,
+ };
}
export function computeChromeScreenBorderRadius(
@@ -164,6 +183,27 @@ export function computeChromeScreenBorderRadius(
const bottomRight = bottomTouches && rightTouches ? radius : 0;
const bottomLeft = bottomTouches && leftTouches ? radius : 0;
+ if (
+ topLeft === 0 &&
+ topRight === 0 &&
+ bottomRight === 0 &&
+ bottomLeft === 0 &&
+ chromeProfile.contentWidth &&
+ chromeProfile.contentHeight &&
+ Math.abs(screenRect.x - (chromeProfile.contentX ?? Number.NaN)) <=
+ epsilon &&
+ Math.abs(screenRect.y - (chromeProfile.contentY ?? Number.NaN)) <=
+ epsilon &&
+ Math.abs(screenRect.width - chromeProfile.contentWidth) <= epsilon &&
+ Math.abs(screenRect.height - chromeProfile.contentHeight) <= epsilon
+ ) {
+ const contentRadius = Math.max(
+ 0,
+ Math.min(radius, screenRect.width / 2, screenRect.height / 2),
+ );
+ return `${contentRadius}px ${contentRadius}px ${contentRadius}px ${contentRadius}px`;
+ }
+
return `${topLeft}px ${topRight}px ${bottomRight}px ${bottomLeft}px`;
}
diff --git a/client/src/styles/components.css b/client/src/styles/components.css
index 79faab71..3f93b1d1 100644
--- a/client/src/styles/components.css
+++ b/client/src/styles/components.css
@@ -2374,6 +2374,13 @@
z-index: 3;
}
+.device-screen-backing {
+ position: absolute;
+ background: #000000;
+ pointer-events: none;
+ z-index: 0;
+}
+
.device-chrome-button {
position: absolute;
display: block;
diff --git a/docs/api/rest.md b/docs/api/rest.md
index 6d92e03f..13411d15 100644
--- a/docs/api/rest.md
+++ b/docs/api/rest.md
@@ -19,6 +19,13 @@ LAN browsers can pair with the printed six-digit code through:
POST /api/pair
```
+Successful pairing sets the browser auth cookie and also returns the access token
+for native clients:
+
+```json
+{ "ok": true, "accessToken": "" }
+```
+
## Quick Examples
```sh
diff --git a/docs/cli/commands.md b/docs/cli/commands.md
index 65755ef0..e110c5ce 100644
--- a/docs/cli/commands.md
+++ b/docs/cli/commands.md
@@ -12,6 +12,7 @@ Replace `simdeck` with `./build/simdeck` when running from a source checkout.
| `simdeck -k` | Stop the detached project daemon |
| `simdeck -r` | Restart the detached project daemon |
| `simdeck ui --open` | Open the browser UI from a daemon |
+| `simdeck pair` | Show native iOS pairing code and QR |
| `simdeck daemon status` | Show daemon URL, PID, token, and log path |
| `simdeck daemon stop` | Stop the current project daemon |
| `simdeck daemon killall` | Stop all project daemons |
@@ -22,9 +23,15 @@ Examples:
```sh
simdeck ui --port 4320 --open
simdeck ui --open
+simdeck pair
simdeck daemon restart --video-codec software --stream-quality low
```
+`simdeck pair` uses the global LaunchAgent-backed service instead of a
+project-local daemon. It binds the service for LAN access, preserves an existing
+service token and pairing code when present, detects LAN and Tailscale IPv4
+addresses, and prints a `simdeck://pair` QR for the native iOS app.
+
## Device Lifecycle
```sh
diff --git a/docs/guide/lan-access.md b/docs/guide/lan-access.md
index d3167781..6d74a356 100644
--- a/docs/guide/lan-access.md
+++ b/docs/guide/lan-access.md
@@ -26,9 +26,13 @@ Use an IP address or hostname that the remote device can resolve:
```sh
simdeck ui --bind 0.0.0.0 --advertise-host my-mac.local --open
simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open
+simdeck ui --bind 0.0.0.0 --advertise-host 100.101.102.103 --open
```
If you bind to `0.0.0.0` but advertise `localhost`, remote browsers will try to connect to themselves.
+Tailscale addresses work like direct HTTP hosts; discovery does not use LAN
+broadcast across the tailnet, so use the Tailscale IP or MagicDNS name when
+pairing a native client.
## Direct API Access
diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md
index 06633d58..3a32fc6f 100644
--- a/docs/guide/troubleshooting.md
+++ b/docs/guide/troubleshooting.md
@@ -189,12 +189,18 @@ Start SimDeck with a LAN bind and reachable advertised host:
simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open
```
+For native iOS pairing, prefer:
+
+```sh
+simdeck pair
+```
+
Then check:
- The remote browser opens `http://192.168.1.50:4310`.
- macOS Firewall allows the port.
-- The pairing code matches the current daemon.
-- API scripts send the daemon token.
+- The pairing code matches the current daemon or global service.
+- API scripts send the daemon or service token.
See [LAN Access](/guide/lan-access).
diff --git a/ios/README.md b/ios/README.md
new file mode 100644
index 00000000..a1c5b936
--- /dev/null
+++ b/ios/README.md
@@ -0,0 +1,10 @@
+# SimDeck Studio iOS
+
+Native SwiftUI client for SimDeck live sessions.
+
+- Opens LAN, Tailscale, and SimDeck Studio URLs.
+- Uses the daemon's `/api/simulators/{udid}/webrtc/offer` endpoint and renders the H.264 WebRTC track with Metal.
+- Sends touch and hardware controls over the `simdeck-control` WebRTC data channel.
+- Supports `https://simdeck.djdev.me/simulator/{id}` links through Associated Domains and the `simdeck://` custom URL scheme.
+
+Open `SimDeckStudio.xcodeproj`, select the `SimDeckStudio` scheme, and run on an iPhone or iPad target. The app display name is `SimDeck`.
diff --git a/ios/SimDeckStudio.xcodeproj/project.pbxproj b/ios/SimDeckStudio.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..dc6f9109
--- /dev/null
+++ b/ios/SimDeckStudio.xcodeproj/project.pbxproj
@@ -0,0 +1,443 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 32F600000000000000000101 /* SimDeckStudioApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000201 /* SimDeckStudioApp.swift */; };
+ 32F600000000000000000102 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000202 /* Models.swift */; };
+ 32F600000000000000000103 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000203 /* AppModel.swift */; };
+ 32F600000000000000000104 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000204 /* ContentView.swift */; };
+ 32F600000000000000000105 /* SimulatorStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000205 /* SimulatorStreamView.swift */; };
+ 32F600000000000000000106 /* SimDeckAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000206 /* SimDeckAPI.swift */; };
+ 32F600000000000000000107 /* StudioLinkResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000207 /* StudioLinkResolver.swift */; };
+ 32F600000000000000000108 /* SimDeckDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000208 /* SimDeckDiscovery.swift */; };
+ 32F600000000000000000109 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000209 /* WebRTCClient.swift */; };
+ 32F600000000000000000110 /* WebRTCVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000210 /* WebRTCVideoView.swift */; };
+ 32F600000000000000000111 /* WebRTC.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000213 /* WebRTC.xcframework */; };
+ 32F600000000000000000112 /* WebRTC.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000213 /* WebRTC.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+ 32F600000000000000000113 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 32F600000000000000000214 /* Assets.xcassets */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 32F600000000000000000201 /* SimDeckStudioApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimDeckStudioApp.swift; sourceTree = ""; };
+ 32F600000000000000000202 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; };
+ 32F600000000000000000203 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; };
+ 32F600000000000000000204 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 32F600000000000000000205 /* SimulatorStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorStreamView.swift; sourceTree = ""; };
+ 32F600000000000000000206 /* SimDeckAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimDeckAPI.swift; sourceTree = ""; };
+ 32F600000000000000000207 /* StudioLinkResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioLinkResolver.swift; sourceTree = ""; };
+ 32F600000000000000000208 /* SimDeckDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimDeckDiscovery.swift; sourceTree = ""; };
+ 32F600000000000000000209 /* WebRTCClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCClient.swift; sourceTree = ""; };
+ 32F600000000000000000210 /* WebRTCVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCVideoView.swift; sourceTree = ""; };
+ 32F600000000000000000211 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 32F600000000000000000212 /* SimDeckStudio.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SimDeckStudio.entitlements; sourceTree = ""; };
+ 32F600000000000000000213 /* WebRTC.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = WebRTC.xcframework; sourceTree = ""; };
+ 32F600000000000000000214 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 32F600000000000000000301 /* SimDeckStudio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimDeckStudio.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 32F600000000000000000401 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 32F600000000000000000111 /* WebRTC.xcframework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 32F600000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000002 /* SimDeckStudio */,
+ 32F600000000000000000004 /* Vendor */,
+ 32F600000000000000000003 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 32F600000000000000000002 /* SimDeckStudio */ = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000011 /* App */,
+ 32F600000000000000000012 /* Discovery */,
+ 32F600000000000000000013 /* Networking */,
+ 32F600000000000000000014 /* Streaming */,
+ 32F600000000000000000015 /* Views */,
+ 32F600000000000000000214 /* Assets.xcassets */,
+ 32F600000000000000000211 /* Info.plist */,
+ 32F600000000000000000212 /* SimDeckStudio.entitlements */,
+ );
+ path = SimDeckStudio;
+ sourceTree = "";
+ };
+ 32F600000000000000000003 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000301 /* SimDeckStudio.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 32F600000000000000000004 /* Vendor */ = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000213 /* WebRTC.xcframework */,
+ );
+ path = Vendor;
+ sourceTree = "";
+ };
+ 32F600000000000000000011 /* App */ = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000201 /* SimDeckStudioApp.swift */,
+ 32F600000000000000000202 /* Models.swift */,
+ 32F600000000000000000203 /* AppModel.swift */,
+ );
+ path = App;
+ sourceTree = "";
+ };
+ 32F600000000000000000012 /* Discovery */ = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000208 /* SimDeckDiscovery.swift */,
+ );
+ path = Discovery;
+ sourceTree = "";
+ };
+ 32F600000000000000000013 /* Networking */ = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000206 /* SimDeckAPI.swift */,
+ 32F600000000000000000207 /* StudioLinkResolver.swift */,
+ );
+ path = Networking;
+ sourceTree = "";
+ };
+ 32F600000000000000000014 /* Streaming */ = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000209 /* WebRTCClient.swift */,
+ 32F600000000000000000210 /* WebRTCVideoView.swift */,
+ );
+ path = Streaming;
+ sourceTree = "";
+ };
+ 32F600000000000000000015 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ 32F600000000000000000204 /* ContentView.swift */,
+ 32F600000000000000000205 /* SimulatorStreamView.swift */,
+ );
+ path = Views;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 32F600000000000000000501 /* SimDeckStudio */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 32F600000000000000000702 /* Build configuration list for PBXNativeTarget "SimDeckStudio" */;
+ buildPhases = (
+ 32F600000000000000000601 /* Sources */,
+ 32F600000000000000000401 /* Frameworks */,
+ 32F600000000000000000603 /* Embed Frameworks */,
+ 32F600000000000000000602 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = SimDeckStudio;
+ packageProductDependencies = (
+ );
+ productName = SimDeckStudio;
+ productReference = 32F600000000000000000301 /* SimDeckStudio.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 32F600000000000000000701 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2650;
+ LastUpgradeCheck = 2650;
+ TargetAttributes = {
+ 32F600000000000000000501 = {
+ CreatedOnToolsVersion = 26.5;
+ };
+ };
+ };
+ buildConfigurationList = 32F600000000000000000703 /* Build configuration list for PBXProject "SimDeckStudio" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 32F600000000000000000001;
+ packageReferences = (
+ );
+ productRefGroup = 32F600000000000000000003 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 32F600000000000000000501 /* SimDeckStudio */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 32F600000000000000000603 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ 32F600000000000000000112 /* WebRTC.xcframework in Embed Frameworks */,
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 32F600000000000000000602 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 32F600000000000000000113 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 32F600000000000000000601 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 32F600000000000000000101 /* SimDeckStudioApp.swift in Sources */,
+ 32F600000000000000000102 /* Models.swift in Sources */,
+ 32F600000000000000000103 /* AppModel.swift in Sources */,
+ 32F600000000000000000104 /* ContentView.swift in Sources */,
+ 32F600000000000000000105 /* SimulatorStreamView.swift in Sources */,
+ 32F600000000000000000106 /* SimDeckAPI.swift in Sources */,
+ 32F600000000000000000107 /* StudioLinkResolver.swift in Sources */,
+ 32F600000000000000000108 /* SimDeckDiscovery.swift in Sources */,
+ 32F600000000000000000109 /* WebRTCClient.swift in Sources */,
+ 32F600000000000000000110 /* WebRTCVideoView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 32F600000000000000000801 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 32F600000000000000000802 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 32F600000000000000000803 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = SimDeckStudio/SimDeckStudio.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 202605180103;
+ DEVELOPMENT_ASSET_PATHS = "";
+ DEVELOPMENT_TEAM = CS838V553Y;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = SimDeckStudio/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = org.nativescript.simdeck;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_STRICT_CONCURRENCY = targeted;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 32F600000000000000000804 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = SimDeckStudio/SimDeckStudio.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 202605180103;
+ DEVELOPMENT_ASSET_PATHS = "";
+ DEVELOPMENT_TEAM = CS838V553Y;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = SimDeckStudio/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = org.nativescript.simdeck;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_STRICT_CONCURRENCY = targeted;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 32F600000000000000000702 /* Build configuration list for PBXNativeTarget "SimDeckStudio" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 32F600000000000000000803 /* Debug */,
+ 32F600000000000000000804 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 32F600000000000000000703 /* Build configuration list for PBXProject "SimDeckStudio" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 32F600000000000000000801 /* Debug */,
+ 32F600000000000000000802 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+ };
+ rootObject = 32F600000000000000000701 /* Project object */;
+}
diff --git a/ios/SimDeckStudio.xcodeproj/xcshareddata/xcschemes/SimDeckStudio.xcscheme b/ios/SimDeckStudio.xcodeproj/xcshareddata/xcschemes/SimDeckStudio.xcscheme
new file mode 100644
index 00000000..c818df0a
--- /dev/null
+++ b/ios/SimDeckStudio.xcodeproj/xcshareddata/xcschemes/SimDeckStudio.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/SimDeckStudio/App/AppModel.swift b/ios/SimDeckStudio/App/AppModel.swift
new file mode 100644
index 00000000..cd9bce08
--- /dev/null
+++ b/ios/SimDeckStudio/App/AppModel.swift
@@ -0,0 +1,1473 @@
+import Foundation
+import CryptoKit
+import Observation
+import SwiftUI
+import UIKit
+@preconcurrency import WebRTC
+
+enum StreamState: String {
+ case idle = "Idle"
+ case connecting = "Connecting"
+ case connected = "Connected"
+ case disconnected = "Disconnected"
+ case failed = "Failed"
+}
+
+enum HardwareButtonPhase: String {
+ case down
+ case up
+}
+
+private struct HardwareButtonControlPayload: Encodable {
+ let button: String
+ let durationMs: Int?
+ let phase: String?
+ let usagePage: Int?
+ let usage: Int?
+}
+
+private struct KeyControlPayload: Encodable {
+ let keyCode: Int
+ let modifiers: Int
+}
+
+private struct EmptyControlPayload: Encodable {}
+
+private struct ChromeAssets {
+ var profile: ChromeProfile?
+ var image: UIImage?
+ var screenMask: UIImage?
+
+ var isEmpty: Bool {
+ profile == nil && image == nil && screenMask == nil
+ }
+}
+
+@MainActor
+@Observable
+final class AppModel {
+ let discovery = SimDeckDiscovery()
+ private static let savedEndpointsKey = "savedEndpoints"
+ private static let legacyRecentEndpointsKey = "recentEndpoints"
+ private static let selectedEndpointKey = "selectedEndpoint"
+ private static let streamConfigKey = "streamConfig"
+ private static let hapticsEnabledKey = "hapticsEnabled"
+ private static let touchOverlayVisibleKey = "touchOverlayVisible"
+ private static let lastFrameCacheDirectoryName = "LastStreamFrames"
+
+ var endpoint: SimDeckEndpoint?
+ var savedEndpoints: [SimDeckEndpoint] = []
+ var simulators: [SimulatorMetadata] = []
+ var selectedSimulatorID: String?
+ var manualAddress = ""
+ var manualToken = ""
+ var pairingCode = ""
+ var authEndpoint: SimDeckEndpoint?
+ var status = "Ready"
+ var isBusy = false
+ var streamState: StreamState = .idle
+ var videoSize: CGSize = .zero
+ var chromeProfile: ChromeProfile?
+ var chromeImage: UIImage?
+ var chromeScreenMask: UIImage?
+ var streamDiagnostics = StreamDiagnostics()
+ var streamReconnects = 0
+ var streamReconnectReason = ""
+ var bootingSimulatorID: String?
+ var streamDisplayToken = 0
+ var hasCurrentStreamFrame = false
+ var lastStreamFrame: UIImage?
+ var streamConfig = AppModel.loadStreamConfig()
+ var hapticsEnabled = AppModel.loadHapticsEnabled() {
+ didSet {
+ UserDefaults.standard.set(hapticsEnabled, forKey: Self.hapticsEnabledKey)
+ }
+ }
+ var touchOverlayVisible = AppModel.loadTouchOverlayVisible() {
+ didSet {
+ UserDefaults.standard.set(touchOverlayVisible, forKey: Self.touchOverlayVisibleKey)
+ }
+ }
+
+ @ObservationIgnored private var streamClient: WebRTCClient?
+ @ObservationIgnored private var hasAutoConnected = false
+ @ObservationIgnored private var isAutoConnecting = false
+ @ObservationIgnored private var streamRequestGeneration = 0
+ @ObservationIgnored private var reconnectTask: Task?
+ @ObservationIgnored private var lastReconnectStartedAt = Date.distantPast
+ @ObservationIgnored private var chromeCache: [String: ChromeAssets] = [:]
+ @ObservationIgnored private var chromeCacheOrder: [String] = []
+ @ObservationIgnored private var lastStreamFrameKey: String?
+ private static let chromeCacheLimit = 24
+
+ init() {
+ discovery.onEndpoint = { [weak self] endpoint in
+ Task { @MainActor in
+ await self?.autoConnectIfNeeded(endpoint)
+ }
+ }
+ }
+
+ var selectedSimulator: SimulatorMetadata? {
+ simulators.first { $0.udid == selectedSimulatorID }
+ }
+
+ var currentStreamClient: WebRTCClient? { streamClient }
+ var canStopStream: Bool {
+ streamState != .idle || streamClient != nil
+ }
+
+ var isSelectedSimulatorBooting: Bool {
+ bootingSimulatorID == selectedSimulatorID
+ }
+
+ var availableEndpoints: [SimDeckEndpoint] {
+ savedEndpoints + automaticEndpoints
+ }
+
+ var automaticEndpoints: [SimDeckEndpoint] {
+ var endpoints = discovery.endpoints.filter { discovered in
+ !savedEndpoints.contains { endpointsRepresentSameServer($0, discovered) }
+ }
+ if let endpoint,
+ !savedEndpoints.contains(where: { endpointsRepresentSameServer($0, endpoint) }),
+ !endpoints.contains(where: { endpointsRepresentSameServer($0, endpoint) }) {
+ endpoints.insert(endpoint, at: 0)
+ }
+ return endpoints
+ }
+
+ var selectedEndpointTitle: String {
+ endpoint?.name ?? "Select Server"
+ }
+
+ var selectedEndpointSubtitle: String {
+ endpoint?.baseURL.host(percentEncoded: false) ?? "No SimDeck connected"
+ }
+
+ var streamNavigationSubtitle: String {
+ endpoint?.name ?? "No SimDeck connected"
+ }
+
+ func start() {
+ loadSavedEndpoints()
+ if let lastSelectedEndpoint = loadSelectedEndpoint() {
+ isAutoConnecting = true
+ discovery.upsert(lastSelectedEndpoint)
+ Task {
+ let connected = await connect(
+ lastSelectedEndpoint,
+ autoStart: false,
+ saveEndpoint: false,
+ presentPairingOnAuth: false
+ )
+ isAutoConnecting = false
+ if connected {
+ hasAutoConnected = true
+ } else {
+ await autoConnectToAvailableEndpointIfNeeded()
+ }
+ }
+ }
+ discovery.start()
+ }
+
+ @discardableResult
+ func connectManual() async -> Bool {
+ guard let endpoint = StudioLinkResolver.endpointFromAddress(manualAddress, token: manualToken) else {
+ status = "Enter a SimDeck URL or host."
+ return false
+ }
+ return await connect(endpoint, autoStart: false, saveEndpoint: true)
+ }
+
+ func handle(url: URL) {
+ guard let route = StudioLinkResolver.route(for: url) else {
+ status = "Unsupported link."
+ return
+ }
+ switch route {
+ case let .endpoint(endpoint, autoStart):
+ Task { await connect(endpoint, autoStart: autoStart, saveEndpoint: true) }
+ case let .pairing(link, autoStart):
+ Task { await pair(link, autoStart: autoStart) }
+ }
+ }
+
+ @discardableResult
+ func connect(
+ _ endpoint: SimDeckEndpoint,
+ autoStart: Bool,
+ saveEndpoint: Bool = false,
+ presentPairingOnAuth: Bool = true
+ ) async -> Bool {
+ let connectionEndpoint = endpointWithReusableToken(endpoint)
+ isBusy = true
+ status = "Connecting to \(connectionEndpoint.name)"
+ defer { isBusy = false }
+
+ var pendingAuthEndpoint: SimDeckEndpoint?
+ var lastError: Error?
+ for candidate in connectionCandidates(for: connectionEndpoint) {
+ do {
+ let api = SimDeckAPI(endpoint: candidate)
+ let health = try await api.health()
+ var resolvedCandidate = candidate
+ resolvedCandidate.serverID = health.serverId ?? resolvedCandidate.serverID
+ resolvedCandidate.alternateBaseURLs = uniquedURLs(
+ resolvedCandidate.alternateBaseURLs + alternateURLs(from: health, fallbackPort: normalizedPort(for: resolvedCandidate.baseURL))
+ ).filter { $0 != resolvedCandidate.baseURL }
+ let simulators = try await SimDeckAPI(endpoint: resolvedCandidate).simulators()
+ stopStream()
+ self.endpoint = resolvedCandidate
+ self.authEndpoint = nil
+ self.simulators = simulators
+ selectedSimulatorID = autoStart
+ ? resolvedCandidate.preferredSimulatorID
+ ?? simulators.first(where: \.isBooted)?.udid
+ ?? simulators.first?.udid
+ : resolvedCandidate.preferredSimulatorID
+ if saveEndpoint {
+ saveUserEndpoint(resolvedCandidate)
+ }
+ saveSelectedEndpoint(resolvedCandidate)
+ status = simulators.isEmpty ? "Connected. No simulators found." : "Connected."
+ hapticSuccess()
+ if autoStart, selectedSimulatorID != nil {
+ await prepareSelectedSimulator()
+ }
+ return true
+ } catch SimDeckAPIError.authRequired {
+ var pendingEndpoint = candidate
+ pendingEndpoint.requiresPairing = true
+ pendingAuthEndpoint = pendingEndpoint
+ discovery.upsert(pendingEndpoint)
+ lastError = SimDeckAPIError.authRequired
+ } catch {
+ lastError = error
+ }
+ }
+
+ if let pendingAuthEndpoint {
+ status = "Pairing required."
+ hapticWarning()
+ guard presentPairingOnAuth else {
+ return false
+ }
+ self.endpoint = pendingAuthEndpoint
+ self.authEndpoint = pendingAuthEndpoint
+ self.simulators = []
+ self.selectedSimulatorID = nil
+ manualAddress = pendingAuthEndpoint.baseURL.absoluteString
+ manualToken = pendingAuthEndpoint.token ?? ""
+ saveSelectedEndpoint(pendingAuthEndpoint)
+ return false
+ }
+
+ if let lastError {
+ status = lastError.localizedDescription
+ hapticWarning()
+ return false
+ }
+
+ status = "Unable to connect."
+ hapticWarning()
+ return false
+
+ }
+
+ @discardableResult
+ func pair() async -> Bool {
+ guard let authEndpoint else { return false }
+ return await pair(endpoint: authEndpoint, code: pairingCode, alternateEndpoints: [], autoStart: false)
+ }
+
+ @discardableResult
+ func pair(_ link: SimDeckPairingLink, autoStart: Bool) async -> Bool {
+ let candidates = uniquedByBaseURL([link.endpoint] + link.alternateEndpoints)
+ if let token = link.endpoint.token?.nilIfBlank {
+ savePairedEndpoints(primary: link.endpoint, alternates: link.alternateEndpoints, token: token)
+ for candidate in candidates {
+ var pairedEndpoint = candidate
+ pairedEndpoint.token = token
+ if await connect(pairedEndpoint, autoStart: autoStart, saveEndpoint: true) {
+ return true
+ }
+ }
+ return false
+ }
+ guard let code = link.pairingCode?.nilIfBlank else {
+ authEndpoint = link.endpoint
+ pairingCode = ""
+ status = "Pairing code missing."
+ hapticWarning()
+ return false
+ }
+ for candidate in candidates {
+ let alternates = candidates.filter { $0.baseURL != candidate.baseURL }
+ if await pair(endpoint: candidate, code: code, alternateEndpoints: alternates, autoStart: autoStart) {
+ return true
+ }
+ }
+ return false
+ }
+
+ @discardableResult
+ private func pair(
+ endpoint authEndpoint: SimDeckEndpoint,
+ code: String,
+ alternateEndpoints: [SimDeckEndpoint],
+ autoStart: Bool
+ ) async -> Bool {
+ isBusy = true
+ defer { isBusy = false }
+ do {
+ let token = try await SimDeckAPI(endpoint: authEndpoint).pair(code: code)
+ var pairedEndpoint = authEndpoint
+ if let token {
+ pairedEndpoint.token = token
+ manualToken = token
+ savePairedEndpoints(primary: pairedEndpoint, alternates: alternateEndpoints, token: token)
+ }
+ pairingCode = ""
+ let connected = await connect(pairedEndpoint, autoStart: autoStart, saveEndpoint: true)
+ if connected {
+ hapticSuccess()
+ }
+ return connected
+ } catch {
+ status = error.localizedDescription
+ hapticWarning()
+ return false
+ }
+ }
+
+ @discardableResult
+ func useToken() async -> Bool {
+ guard var authEndpoint else { return false }
+ authEndpoint.token = manualToken.nilIfBlank
+ let connected = await connect(authEndpoint, autoStart: false, saveEndpoint: true)
+ if connected {
+ hapticSuccess()
+ }
+ return connected
+ }
+
+ func handleScannedPairingPayload(_ value: String) {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let url = URL(string: trimmed), let route = StudioLinkResolver.route(for: url) {
+ switch route {
+ case let .pairing(link, autoStart):
+ Task { await pair(link, autoStart: autoStart) }
+ case let .endpoint(endpoint, autoStart):
+ Task { await connect(endpoint, autoStart: autoStart, saveEndpoint: true) }
+ }
+ return
+ }
+ let digits = trimmed.filter(\.isNumber)
+ if !digits.isEmpty {
+ pairingCode = String(digits.prefix(6))
+ hapticSelection()
+ } else {
+ status = "That QR code is not a SimDeck pairing link."
+ hapticWarning()
+ }
+ }
+
+ func refreshSimulators() async {
+ guard let endpoint else { return }
+ do {
+ simulators = try await SimDeckAPI(endpoint: endpoint).simulators()
+ if selectedSimulatorID == nil {
+ selectedSimulatorID = simulators.first(where: \.isBooted)?.udid ?? simulators.first?.udid
+ }
+ status = "Updated."
+ hapticSelection()
+ } catch {
+ status = error.localizedDescription
+ hapticWarning()
+ }
+ }
+
+ func selectSimulator(_ udid: String?) {
+ guard selectedSimulatorID != udid else { return }
+ hapticSelection()
+ selectedSimulatorID = udid
+ resetStreamPresentation()
+ guard endpoint != nil, udid != nil else {
+ stopStream()
+ return
+ }
+ Task { await prepareSelectedSimulator() }
+ }
+
+ func prepareSelectedSimulator() async {
+ guard let selectedSimulator else { return }
+ if selectedSimulator.isBooted {
+ await startStream()
+ } else {
+ await loadSelectedSimulatorChrome()
+ }
+ }
+
+ @discardableResult
+ func startStream(automaticReconnect: Bool = false) async -> Bool {
+ guard let endpoint, let selectedSimulatorID else { return false }
+ guard selectedSimulator?.isBooted == true else {
+ await loadSelectedSimulatorChrome()
+ return false
+ }
+ streamRequestGeneration += 1
+ let generation = streamRequestGeneration
+ stopCurrentStream(resetState: false)
+ resetStreamPresentation()
+ streamState = .connecting
+ status = automaticReconnect ? "Reconnecting WebRTC." : "Starting WebRTC."
+ do {
+ let api = SimDeckAPI(endpoint: endpoint)
+ async let health = try api.health(timeout: 8)
+ let client = WebRTCClient()
+ client.onConnectionState = { [weak self] state in
+ Task { @MainActor in
+ guard self?.isCurrentStreamRequest(generation, simulatorID: selectedSimulatorID) == true else { return }
+ self?.streamState = StreamState(peerState: state)
+ }
+ }
+ client.onVideoSize = { [weak self] size in
+ Task { @MainActor in
+ guard self?.isCurrentStreamRequest(generation, simulatorID: selectedSimulatorID) == true else { return }
+ if self?.videoSize != size {
+ self?.videoSize = size
+ }
+ }
+ }
+ client.onDiagnostics = { [weak self] diagnostics in
+ Task { @MainActor in
+ guard self?.isCurrentStreamRequest(generation, simulatorID: selectedSimulatorID) == true else { return }
+ self?.streamDiagnostics = diagnostics
+ }
+ }
+ let clientToken = ObjectIdentifier(client)
+ client.onReconnectNeeded = { [weak self] reason in
+ Task { @MainActor in
+ guard let self,
+ let activeClient = self.streamClient,
+ self.isCurrentStreamRequest(generation, simulatorID: selectedSimulatorID),
+ ObjectIdentifier(activeClient) == clientToken else { return }
+ self.scheduleStreamReconnect(reason: reason)
+ }
+ }
+ let loadedChromeAssets = await chromeAssets(api: api, endpoint: endpoint, simulatorID: selectedSimulatorID, forceRefresh: true)
+ guard isCurrentStreamRequest(generation, simulatorID: selectedSimulatorID) else {
+ client.disconnect()
+ return false
+ }
+ applyChromeAssets(loadedChromeAssets)
+ let loadedHealth = try await health
+ guard isCurrentStreamRequest(generation, simulatorID: selectedSimulatorID) else {
+ client.disconnect()
+ return false
+ }
+ let answer = try await client.connect(
+ api: api,
+ simulatorID: selectedSimulatorID,
+ health: loadedHealth,
+ streamConfig: streamConfig
+ )
+ guard isCurrentStreamRequest(generation, simulatorID: selectedSimulatorID) else {
+ client.disconnect()
+ return false
+ }
+ streamClient = client
+ if let video = answer.video, video.width > 0, video.height > 0 {
+ videoSize = CGSize(width: video.width, height: video.height)
+ }
+ status = "WebRTC connected."
+ if !automaticReconnect {
+ hapticSuccess()
+ }
+ return true
+ } catch {
+ guard streamRequestGeneration == generation else { return false }
+ streamState = .failed
+ status = error.localizedDescription
+ if !automaticReconnect {
+ hapticWarning()
+ scheduleStreamReconnect(reason: "connect-failed")
+ }
+ stopCurrentStream(resetState: false)
+ return false
+ }
+ }
+
+ func loadSelectedSimulatorChrome() async {
+ guard let endpoint, let selectedSimulatorID else { return }
+ streamRequestGeneration += 1
+ let generation = streamRequestGeneration
+ stopCurrentStream(resetState: false)
+ resetStreamPresentation()
+ streamState = .idle
+ status = "Loading device chrome."
+
+ let api = SimDeckAPI(endpoint: endpoint)
+ let loadedChromeAssets = await chromeAssets(api: api, endpoint: endpoint, simulatorID: selectedSimulatorID, forceRefresh: true)
+ guard isCurrentStreamRequest(generation, simulatorID: selectedSimulatorID) else { return }
+ applyChromeAssets(loadedChromeAssets)
+ status = selectedSimulator?.isBooted == true ? "Ready." : "Ready to boot."
+ }
+
+ func bootSelectedSimulator() async {
+ guard let endpoint, let selectedSimulatorID, let selectedSimulator else { return }
+ guard !selectedSimulator.isBooted else {
+ await startStream()
+ return
+ }
+ bootingSimulatorID = selectedSimulatorID
+ streamState = .connecting
+ status = "Booting \(selectedSimulator.name)."
+ hapticSelection()
+ do {
+ let api = SimDeckAPI(endpoint: endpoint)
+ try await api.bootSimulator(udid: selectedSimulatorID)
+ simulators = try await api.simulators()
+ status = "Booted."
+ bootingSimulatorID = nil
+ hapticSuccess()
+ await startStream()
+ } catch {
+ streamState = .failed
+ status = error.localizedDescription
+ bootingSimulatorID = nil
+ hapticWarning()
+ }
+ }
+
+ func stopStream() {
+ streamRequestGeneration += 1
+ reconnectTask?.cancel()
+ reconnectTask = nil
+ bootingSimulatorID = nil
+ stopCurrentStream(resetState: true)
+ hapticSelection()
+ }
+
+ @discardableResult
+ func createSimulator(_ request: CreateSimulatorRequest) async -> Bool {
+ guard let endpoint else {
+ status = "Select a SimDeck server first."
+ hapticWarning()
+ return false
+ }
+ isBusy = true
+ status = "Creating simulator."
+ defer { isBusy = false }
+ do {
+ let api = SimDeckAPI(endpoint: endpoint)
+ let response = try await api.createSimulator(request)
+ let refreshed = (try? await api.simulators()) ?? []
+ if refreshed.isEmpty {
+ upsertSimulator(response.simulator)
+ if let pairedWatchSimulator = response.pairedWatchSimulator {
+ upsertSimulator(pairedWatchSimulator)
+ }
+ } else {
+ simulators = refreshed
+ }
+ selectedSimulatorID = response.simulator.udid
+ resetStreamPresentation()
+ status = "Created \(response.simulator.name)."
+ hapticSuccess()
+ await prepareSelectedSimulator()
+ return true
+ } catch {
+ status = error.localizedDescription
+ hapticWarning()
+ return false
+ }
+ }
+
+ private func stopCurrentStream(resetState: Bool) {
+ streamClient?.disconnect()
+ streamClient = nil
+ if resetState {
+ streamState = .idle
+ resetStreamPresentation()
+ }
+ }
+
+ func sendTouch(location: CGPoint, in screenFrame: CGRect, phase: String) {
+ guard let point = normalizedTouchPoint(location: location, in: screenFrame) else { return }
+ streamClient?.sendTouch(x: Double(point.x), y: Double(point.y), phase: phase)
+ }
+
+ func sendEdgeTouch(location: CGPoint, in screenFrame: CGRect, phase: String, edge: String) {
+ guard let point = normalizedTouchPoint(location: location, in: screenFrame) else { return }
+ streamClient?.sendEdgeTouch(x: Double(point.x), y: Double(point.y), phase: phase, edge: edge)
+ }
+
+ func normalizedTouchPoint(location: CGPoint, in screenFrame: CGRect) -> CGPoint? {
+ guard screenFrame.width > 0, screenFrame.height > 0 else { return nil }
+ let x = ((location.x - screenFrame.minX) / screenFrame.width).clamped(to: 0...1)
+ let y = ((location.y - screenFrame.minY) / screenFrame.height).clamped(to: 0...1)
+ return CGPoint(x: x, y: y)
+ }
+
+ func markStreamFrameRendered(displayToken: Int) {
+ guard displayToken == streamDisplayToken else { return }
+ hasCurrentStreamFrame = true
+ }
+
+ func updateLastStreamFrame(_ image: UIImage, displayToken: Int) {
+ guard displayToken == streamDisplayToken,
+ let endpoint,
+ let selectedSimulatorID else {
+ return
+ }
+ lastStreamFrameKey = lastFrameCacheKey(endpoint: endpoint, simulatorID: selectedSimulatorID)
+ lastStreamFrame = image
+ if videoSize == .zero {
+ videoSize = image.size
+ }
+ persistLastStreamFrame(image, endpoint: endpoint, simulatorID: selectedSimulatorID)
+ }
+
+ func sendTouch(x: Double, y: Double, phase: String) {
+ streamClient?.sendTouch(x: x, y: y, phase: phase)
+ }
+
+ func sendKeyboardText(_ text: String) {
+ for character in text {
+ guard let key = Self.keyControl(for: character) else {
+ status = "Unsupported keyboard input."
+ hapticWarning()
+ continue
+ }
+ sendKey(keyCode: key.keyCode, modifiers: key.modifiers)
+ }
+ }
+
+ func sendKeyboardBackspace() {
+ sendKey(keyCode: 42, modifiers: 0)
+ }
+
+ func dismissSimulatorKeyboard() {
+ let sent = streamClient?.dismissSimulatorKeyboard() ?? false
+ guard !sent else { return }
+ Task {
+ await postDismissKeyboard()
+ }
+ }
+
+ @discardableResult
+ func sendKey(keyCode: Int, modifiers: Int = 0) -> Bool {
+ guard selectedSimulatorID != nil, (0...65_535).contains(keyCode) else { return false }
+ let sent = streamClient?.sendKey(keyCode: keyCode, modifiers: modifiers) ?? false
+ guard !sent else { return true }
+ Task {
+ await postKey(keyCode: keyCode, modifiers: modifiers)
+ }
+ return false
+ }
+
+ func sendHome() {
+ tapHardwareButton(named: "home")
+ }
+
+ func sendAppSwitcher() {
+ hapticImpact()
+ streamClient?.sendAppSwitcher()
+ }
+
+ func sendLock() {
+ tapHardwareButton(named: "power")
+ }
+
+ func sendHardwareButton(named button: String, phase: HardwareButtonPhase, usagePage: Int? = nil, usage: Int? = nil) {
+ guard selectedSimulatorID != nil else { return }
+ switch phase {
+ case .down:
+ hapticImpact()
+ case .up:
+ hapticSelection()
+ }
+ let sent = streamClient?.sendHardwareButton(
+ button: button,
+ phase: phase.rawValue,
+ usagePage: usagePage,
+ usage: usage
+ ) ?? false
+ guard !sent else { return }
+ Task {
+ await postHardwareButton(
+ named: button,
+ durationMs: nil,
+ phase: phase,
+ usagePage: usagePage,
+ usage: usage
+ )
+ }
+ }
+
+ func tapHardwareButton(named button: String, usagePage: Int? = nil, usage: Int? = nil, durationMs: Int = 80) {
+ guard selectedSimulatorID != nil else { return }
+ hapticImpact()
+ let sent = streamClient?.pressHardwareButton(
+ button: button,
+ durationMs: durationMs,
+ usagePage: usagePage,
+ usage: usage
+ ) ?? false
+ guard !sent else { return }
+ Task {
+ await postHardwareButton(
+ named: button,
+ durationMs: durationMs,
+ phase: nil,
+ usagePage: usagePage,
+ usage: usage
+ )
+ }
+ }
+
+ func rotateLeft() {
+ hapticSelection()
+ streamClient?.sendRotateLeft()
+ }
+
+ func rotateRight() {
+ hapticSelection()
+ streamClient?.sendRotateRight()
+ }
+
+ func toggleAppearance() {
+ guard selectedSimulatorID != nil else { return }
+ hapticSelection()
+ let sent = streamClient?.sendToggleAppearance() ?? false
+ guard !sent else { return }
+ Task {
+ await postToggleAppearance()
+ }
+ }
+
+ func requestKeyframe() {
+ hapticImpact()
+ streamClient?.requestKeyframe()
+ }
+
+ func retryStream() {
+ reconnectTask?.cancel()
+ reconnectTask = nil
+ hapticSelection()
+ Task {
+ await startStream()
+ }
+ }
+
+ func setStreamEncoder(_ encoder: StreamEncoder) {
+ updateStreamConfig { $0.encoder = encoder }
+ }
+
+ func setStreamFPS(_ fps: Int) {
+ updateStreamConfig { $0.fps = fps }
+ }
+
+ func setStreamQuality(_ quality: StreamQualityPreset) {
+ updateStreamConfig { $0.quality = quality }
+ }
+
+ func setTouchOverlayVisible(_ isVisible: Bool) {
+ guard touchOverlayVisible != isVisible else { return }
+ touchOverlayVisible = isVisible
+ hapticSelection()
+ }
+
+ private func autoConnectIfNeeded(_ endpoint: SimDeckEndpoint) async {
+ guard !hasAutoConnected, !isAutoConnecting, self.endpoint == nil, authEndpoint == nil else { return }
+ await autoConnectToAvailableEndpointIfNeeded(preferredEndpoint: endpoint)
+ }
+
+ private func autoConnectToAvailableEndpointIfNeeded(preferredEndpoint: SimDeckEndpoint? = nil) async {
+ guard !hasAutoConnected, !isAutoConnecting, self.endpoint == nil, authEndpoint == nil else { return }
+ let candidates = autoConnectCandidates(preferredEndpoint: preferredEndpoint)
+ guard !candidates.isEmpty else { return }
+
+ isAutoConnecting = true
+ var connected = false
+ for candidate in candidates {
+ connected = await connect(
+ candidate,
+ autoStart: false,
+ saveEndpoint: false,
+ presentPairingOnAuth: false
+ )
+ if connected {
+ break
+ }
+ }
+ isAutoConnecting = false
+ if connected {
+ hasAutoConnected = true
+ }
+ }
+
+ private func autoConnectCandidates(preferredEndpoint: SimDeckEndpoint?) -> [SimDeckEndpoint] {
+ let orderedEndpoints = [preferredEndpoint].compactMap(\.self)
+ + discovery.endpoints
+ + savedEndpoints
+ return uniqued(orderedEndpoints)
+ .map(endpointWithReusableToken)
+ .filter { endpoint in
+ !endpoint.requiresPairing || endpoint.token?.nilIfBlank != nil
+ }
+ }
+
+ private func isCurrentStreamRequest(_ generation: Int, simulatorID: String) -> Bool {
+ streamRequestGeneration == generation && selectedSimulatorID == simulatorID
+ }
+
+ func handleScenePhase(_ phase: ScenePhase) {
+ switch phase {
+ case .active:
+ streamClient?.appDidBecomeActive()
+ if streamClient == nil, streamState == .disconnected || streamState == .failed {
+ scheduleStreamReconnect(reason: "foreground")
+ }
+ case .background:
+ streamClient?.appDidEnterBackground()
+ case .inactive:
+ break
+ @unknown default:
+ break
+ }
+ }
+
+ func scheduleStreamReconnect(reason: String) {
+ guard endpoint != nil, selectedSimulatorID != nil, selectedSimulator?.isBooted == true else { return }
+ guard streamState != .connecting else { return }
+ reconnectTask?.cancel()
+ reconnectTask = Task { @MainActor [weak self] in
+ guard let self else { return }
+ let elapsed = Date().timeIntervalSince(self.lastReconnectStartedAt)
+ if elapsed < 1.5 {
+ try? await Task.sleep(for: .milliseconds(Int((1.5 - elapsed) * 1000)))
+ }
+ var attempt = 0
+ while !Task.isCancelled,
+ self.endpoint != nil,
+ self.selectedSimulatorID != nil,
+ self.selectedSimulator?.isBooted == true {
+ attempt += 1
+ self.streamReconnects += 1
+ self.streamReconnectReason = reason
+ self.lastReconnectStartedAt = Date()
+ self.status = attempt == 1
+ ? (reason == "foreground" ? "Resuming stream." : "Recovering stream.")
+ : "Retrying stream."
+ let connected = await self.startStream(automaticReconnect: true)
+ guard !connected else { return }
+ let delay = min(10.0, pow(1.8, Double(attempt)))
+ self.status = "Retrying stream in \(Int(delay.rounded(.up)))s."
+ try? await Task.sleep(for: .milliseconds(Int(delay * 1000)))
+ }
+ }
+ }
+
+ private func postHardwareButton(
+ named button: String,
+ durationMs: Int?,
+ phase: HardwareButtonPhase?,
+ usagePage: Int?,
+ usage: Int?
+ ) async {
+ guard let endpoint, let selectedSimulatorID else { return }
+ do {
+ let payload = HardwareButtonControlPayload(
+ button: button,
+ durationMs: durationMs,
+ phase: phase?.rawValue,
+ usagePage: usagePage,
+ usage: usage
+ )
+ let encodedID = selectedSimulatorID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? selectedSimulatorID
+ try await SimDeckAPI(endpoint: endpoint).postControl(payload, path: "/api/simulators/\(encodedID)/button")
+ } catch {
+ status = error.localizedDescription
+ hapticWarning()
+ }
+ }
+
+ private func postKey(keyCode: Int, modifiers: Int) async {
+ guard let endpoint, let selectedSimulatorID else { return }
+ do {
+ let payload = KeyControlPayload(keyCode: keyCode, modifiers: modifiers)
+ let encodedID = selectedSimulatorID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? selectedSimulatorID
+ try await SimDeckAPI(endpoint: endpoint).postControl(payload, path: "/api/simulators/\(encodedID)/key")
+ } catch {
+ status = error.localizedDescription
+ hapticWarning()
+ }
+ }
+
+ private func postDismissKeyboard() async {
+ guard let endpoint, let selectedSimulatorID else { return }
+ do {
+ let encodedID = selectedSimulatorID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? selectedSimulatorID
+ try await SimDeckAPI(endpoint: endpoint).postControl(
+ EmptyControlPayload(),
+ path: "/api/simulators/\(encodedID)/dismiss-keyboard"
+ )
+ } catch {
+ status = error.localizedDescription
+ hapticWarning()
+ }
+ }
+
+ private func postToggleAppearance() async {
+ guard let endpoint, let selectedSimulatorID else { return }
+ do {
+ let encodedID = selectedSimulatorID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? selectedSimulatorID
+ try await SimDeckAPI(endpoint: endpoint).postControl(
+ EmptyControlPayload(),
+ path: "/api/simulators/\(encodedID)/toggle-appearance"
+ )
+ } catch {
+ status = error.localizedDescription
+ hapticWarning()
+ }
+ }
+
+ private func resetStreamPresentation() {
+ streamDisplayToken &+= 1
+ if !applyCachedChromeAssetsForSelection() {
+ chromeProfile = nil
+ chromeImage = nil
+ chromeScreenMask = nil
+ }
+ if !applyCachedLastStreamFrameForSelection() {
+ lastStreamFrameKey = nil
+ lastStreamFrame = nil
+ videoSize = .zero
+ } else if let lastStreamFrame {
+ videoSize = lastStreamFrame.size
+ }
+ hasCurrentStreamFrame = false
+ streamDiagnostics = StreamDiagnostics()
+ }
+
+ private func chromeAssets(
+ api: SimDeckAPI,
+ endpoint: SimDeckEndpoint,
+ simulatorID: String,
+ forceRefresh: Bool = false
+ ) async -> ChromeAssets {
+ if !forceRefresh, let cached = cachedChromeAssets(endpoint: endpoint, simulatorID: simulatorID) {
+ return cached
+ }
+
+ let loadedProfile = try? await api.chromeProfile(udid: simulatorID)
+ let assetStamp = loadedProfile?.assetStamp
+ let loadedImage = try? await api.chromeImage(udid: simulatorID, stamp: assetStamp)
+ let loadedScreenMask: UIImage?
+ if loadedProfile?.hasScreenMask == true {
+ loadedScreenMask = try? await api.screenMaskImage(udid: simulatorID, stamp: assetStamp)
+ } else {
+ loadedScreenMask = nil
+ }
+ let loadedAssets = ChromeAssets(profile: loadedProfile, image: loadedImage, screenMask: loadedScreenMask)
+ cacheChromeAssets(loadedAssets, endpoint: endpoint, simulatorID: simulatorID)
+ return loadedAssets
+ }
+
+ @discardableResult
+ private func applyCachedChromeAssetsForSelection() -> Bool {
+ guard let endpoint, let selectedSimulatorID,
+ let cached = cachedChromeAssets(endpoint: endpoint, simulatorID: selectedSimulatorID) else {
+ return false
+ }
+ applyChromeAssets(cached)
+ return true
+ }
+
+ private func applyChromeAssets(_ assets: ChromeAssets) {
+ chromeProfile = assets.profile
+ chromeImage = assets.image
+ chromeScreenMask = assets.screenMask
+ }
+
+ private func cachedChromeAssets(endpoint: SimDeckEndpoint, simulatorID: String) -> ChromeAssets? {
+ let key = chromeCacheKey(endpoint: endpoint, simulatorID: simulatorID)
+ guard let cached = chromeCache[key] else { return nil }
+ markChromeCacheKeyUsed(key)
+ return cached
+ }
+
+ private func cacheChromeAssets(_ assets: ChromeAssets, endpoint: SimDeckEndpoint, simulatorID: String) {
+ guard !assets.isEmpty else { return }
+ let key = chromeCacheKey(endpoint: endpoint, simulatorID: simulatorID)
+ chromeCache[key] = assets
+ markChromeCacheKeyUsed(key)
+ while chromeCacheOrder.count > Self.chromeCacheLimit, let evictedKey = chromeCacheOrder.first {
+ chromeCacheOrder.removeFirst()
+ chromeCache[evictedKey] = nil
+ }
+ }
+
+ private func markChromeCacheKeyUsed(_ key: String) {
+ chromeCacheOrder.removeAll { $0 == key }
+ chromeCacheOrder.append(key)
+ }
+
+ private func chromeCacheKey(endpoint: SimDeckEndpoint, simulatorID: String) -> String {
+ "\(endpoint.baseURL.absoluteString)|\(simulatorID)"
+ }
+
+ @discardableResult
+ private func applyCachedLastStreamFrameForSelection() -> Bool {
+ guard let endpoint, let selectedSimulatorID else {
+ return false
+ }
+ let cacheKey = lastFrameCacheKey(endpoint: endpoint, simulatorID: selectedSimulatorID)
+ if lastStreamFrameKey == cacheKey, lastStreamFrame != nil {
+ return true
+ }
+ guard let image = loadLastStreamFrame(endpoint: endpoint, simulatorID: selectedSimulatorID) else {
+ return false
+ }
+ lastStreamFrameKey = cacheKey
+ lastStreamFrame = image
+ return true
+ }
+
+ private func loadLastStreamFrame(endpoint: SimDeckEndpoint, simulatorID: String) -> UIImage? {
+ guard let url = lastFrameCacheURL(endpoint: endpoint, simulatorID: simulatorID),
+ let data = try? Data(contentsOf: url) else {
+ return nil
+ }
+ return UIImage(data: data)
+ }
+
+ private func persistLastStreamFrame(_ image: UIImage, endpoint: SimDeckEndpoint, simulatorID: String) {
+ guard let url = lastFrameCacheURL(endpoint: endpoint, simulatorID: simulatorID),
+ let data = image.jpegData(compressionQuality: 0.78) else {
+ return
+ }
+ Task.detached(priority: .utility) {
+ do {
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try data.write(to: url, options: [.atomic])
+ } catch {
+ #if DEBUG
+ print("Unable to persist SimDeck frame cache: \(error.localizedDescription)")
+ #endif
+ }
+ }
+ }
+
+ private func lastFrameCacheURL(endpoint: SimDeckEndpoint, simulatorID: String) -> URL? {
+ guard let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ return nil
+ }
+ return baseURL
+ .appendingPathComponent(Self.lastFrameCacheDirectoryName, isDirectory: true)
+ .appendingPathComponent("\(lastFrameCacheKey(endpoint: endpoint, simulatorID: simulatorID)).jpg")
+ }
+
+ private func lastFrameCacheKey(endpoint: SimDeckEndpoint, simulatorID: String) -> String {
+ let source = "\(endpoint.baseURL.absoluteString)|\(simulatorID)"
+ let digest = SHA256.hash(data: Data(source.utf8))
+ return digest.map { String(format: "%02x", $0) }.joined()
+ }
+
+ private func updateStreamConfig(_ update: (inout StreamConfig) -> Void) {
+ var next = streamConfig
+ update(&next)
+ guard next != streamConfig else { return }
+ streamConfig = next
+ saveStreamConfig(next)
+ streamClient?.applyStreamQuality(next)
+ if streamClient != nil {
+ status = "Stream set to \(next.summary)."
+ }
+ hapticSelection()
+ }
+
+ private func upsertSimulator(_ simulator: SimulatorMetadata) {
+ if let index = simulators.firstIndex(where: { $0.udid == simulator.udid }) {
+ simulators[index] = simulator
+ } else {
+ simulators.insert(simulator, at: 0)
+ }
+ }
+
+ private static func keyControl(for character: Character) -> (keyCode: Int, modifiers: Int)? {
+ let shift = 1 << 0
+ let value = String(character)
+ if let keyCode = unshiftedHIDUsage[value] {
+ return (keyCode, 0)
+ }
+ if let keyCode = shiftedHIDUsage[value] {
+ return (keyCode, shift)
+ }
+ return nil
+ }
+
+ private static let unshiftedHIDUsage: [String: Int] = [
+ "a": 4, "b": 5, "c": 6, "d": 7, "e": 8, "f": 9, "g": 10, "h": 11, "i": 12,
+ "j": 13, "k": 14, "l": 15, "m": 16, "n": 17, "o": 18, "p": 19, "q": 20,
+ "r": 21, "s": 22, "t": 23, "u": 24, "v": 25, "w": 26, "x": 27, "y": 28, "z": 29,
+ "1": 30, "2": 31, "3": 32, "4": 33, "5": 34, "6": 35, "7": 36, "8": 37, "9": 38, "0": 39,
+ "\n": 40, "\r": 40, "\u{1B}": 41, "\t": 43, " ": 44,
+ "-": 45, "=": 46, "[": 47, "]": 48, "\\": 49, ";": 51, "'": 52,
+ "`": 53, ",": 54, ".": 55, "/": 56,
+ "\u{2019}": 52, "\u{2018}": 52, "\u{2013}": 45, "\u{2014}": 45
+ ]
+
+ private static let shiftedHIDUsage: [String: Int] = [
+ "A": 4, "B": 5, "C": 6, "D": 7, "E": 8, "F": 9, "G": 10, "H": 11, "I": 12,
+ "J": 13, "K": 14, "L": 15, "M": 16, "N": 17, "O": 18, "P": 19, "Q": 20,
+ "R": 21, "S": 22, "T": 23, "U": 24, "V": 25, "W": 26, "X": 27, "Y": 28, "Z": 29,
+ "!": 30, "@": 31, "#": 32, "$": 33, "%": 34, "^": 35, "&": 36, "*": 37, "(": 38, ")": 39,
+ "_": 45, "+": 46, "{": 47, "}": 48, "|": 49, ":": 51, "\"": 52,
+ "~": 53, "<": 54, ">": 55, "?": 56,
+ "\u{201C}": 52, "\u{201D}": 52
+ ]
+
+ private func loadSavedEndpoints() {
+ let data = UserDefaults.standard.data(forKey: Self.savedEndpointsKey)
+ ?? UserDefaults.standard.data(forKey: Self.legacyRecentEndpointsKey)
+ guard let data,
+ let endpoints = try? JSONDecoder().decode([SimDeckEndpoint].self, from: data) else {
+ return
+ }
+ savedEndpoints = uniqued(endpoints).map { endpoint in
+ var saved = endpoint
+ if saved.source == .recent {
+ saved.source = .manual
+ }
+ return saved
+ }
+ persistSavedEndpoints()
+ }
+
+ func saveUserEndpoint(_ endpoint: SimDeckEndpoint) {
+ var saved = endpoint
+ if saved.source == .recent {
+ saved.source = .manual
+ }
+ saved.requiresPairing = false
+ if let existing = savedEndpoints.first(where: { endpointsRepresentSameServer($0, saved) }) {
+ saved = mergedEndpoint(existing, saved)
+ saved.source = .manual
+ saved.requiresPairing = false
+ }
+ savedEndpoints.removeAll { endpointsRepresentSameServer($0, saved) }
+ savedEndpoints.insert(saved, at: 0)
+ savedEndpoints = Array(uniqued(savedEndpoints).prefix(12))
+ persistSavedEndpoints()
+ }
+
+ func renameSavedEndpoint(_ endpoint: SimDeckEndpoint, to name: String) {
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty,
+ let index = savedEndpoints.firstIndex(where: { endpointsRepresentSameServer($0, endpoint) }) else {
+ return
+ }
+ savedEndpoints[index].name = trimmed
+ if var current = self.endpoint, endpointsRepresentSameServer(current, endpoint) {
+ current.name = trimmed
+ self.endpoint = current
+ saveSelectedEndpoint(current)
+ }
+ if var pending = authEndpoint, endpointsRepresentSameServer(pending, endpoint) {
+ pending.name = trimmed
+ authEndpoint = pending
+ }
+ persistSavedEndpoints()
+ }
+
+ func deleteSavedEndpoint(_ endpoint: SimDeckEndpoint) {
+ savedEndpoints.removeAll { endpointsRepresentSameServer($0, endpoint) }
+ if let current = self.endpoint, endpointsRepresentSameServer(current, endpoint) {
+ UserDefaults.standard.removeObject(forKey: Self.selectedEndpointKey)
+ }
+ persistSavedEndpoints()
+ hapticSelection()
+ }
+
+ private func savePairedEndpoints(primary: SimDeckEndpoint, alternates: [SimDeckEndpoint], token: String) {
+ for endpoint in Array(alternates.reversed()) + [primary] {
+ var saved = endpoint
+ saved.token = token
+ saved.requiresPairing = false
+ saveUserEndpoint(saved)
+ }
+ }
+
+ private func endpointWithReusableToken(_ endpoint: SimDeckEndpoint) -> SimDeckEndpoint {
+ guard endpoint.token?.nilIfBlank == nil,
+ let token = reusableToken(for: endpoint) else {
+ return endpoint
+ }
+ var endpoint = endpoint
+ endpoint.token = token
+ endpoint.requiresPairing = false
+ return endpoint
+ }
+
+ private func reusableToken(for endpoint: SimDeckEndpoint) -> String? {
+ let storedEndpoints = savedEndpoints + [self.endpoint, loadSelectedEndpoint()].compactMap(\.self)
+ if let serverID = endpoint.serverID?.nilIfBlank,
+ let token = storedEndpoints
+ .first(where: { $0.serverID == serverID })?
+ .token?
+ .nilIfBlank {
+ return token
+ }
+ if let exactToken = storedEndpoints
+ .first(where: { endpointsRepresentSameServer($0, endpoint) })?
+ .token?
+ .nilIfBlank {
+ return exactToken
+ }
+
+ guard hostCanShareSimDeckToken(endpoint.baseURL.host(percentEncoded: false)) else {
+ return nil
+ }
+ let port = normalizedPort(for: endpoint.baseURL)
+ return storedEndpoints
+ .first { stored in
+ stored.token?.nilIfBlank != nil
+ && normalizedPort(for: stored.baseURL) == port
+ && hostCanShareSimDeckToken(stored.baseURL.host(percentEncoded: false))
+ }?
+ .token?
+ .nilIfBlank
+ }
+
+ private func connectionCandidates(for endpoint: SimDeckEndpoint) -> [SimDeckEndpoint] {
+ let primary = endpointWithReusableToken(endpoint)
+ let alternateEndpoints = preferredAlternateURLs(for: primary).map { url in
+ var alternate = primary
+ alternate.baseURL = url.normalizedSimDeckBaseURL()
+ alternate.source = endpointSource(for: alternate.baseURL)
+ alternate.alternateBaseURLs = ([primary.baseURL] + primary.alternateBaseURLs)
+ .map { $0.normalizedSimDeckBaseURL() }
+ .filter { $0 != alternate.baseURL }
+ return endpointWithReusableToken(alternate)
+ }
+ return uniquedByBaseURL([primary] + alternateEndpoints)
+ }
+
+ private func preferredAlternateURLs(for endpoint: SimDeckEndpoint) -> [URL] {
+ let urls = endpoint.alternateBaseURLs.filter { $0 != endpoint.baseURL }
+ let preferred = urls.sorted {
+ endpointSourceRank(endpointSource(for: $0)) < endpointSourceRank(endpointSource(for: $1))
+ }
+ return preferred
+ }
+
+ private func endpointSource(for url: URL) -> EndpointSource {
+ guard let host = url.host(percentEncoded: false)?.lowercased() else {
+ return .manual
+ }
+ let parts = host.split(separator: ".").compactMap { UInt8($0) }
+ if parts.count == 4 && parts[0] == 100 && (parts[1] & 0b1100_0000) == 0b0100_0000 {
+ return .tailscale
+ }
+ if host.hasSuffix(".local") {
+ return .bonjour
+ }
+ if hostCanShareSimDeckToken(host) {
+ return .lan
+ }
+ return .manual
+ }
+
+ private func endpointSourceRank(_ source: EndpointSource) -> Int {
+ switch source {
+ case .bonjour: 0
+ case .lan: 1
+ case .tailscale: 2
+ case .studioLink: 3
+ case .manual: 4
+ case .recent: 5
+ }
+ }
+
+ private func endpointsRepresentSameServer(_ lhs: SimDeckEndpoint, _ rhs: SimDeckEndpoint) -> Bool {
+ if let lhsID = lhs.serverID?.nilIfBlank,
+ let rhsID = rhs.serverID?.nilIfBlank {
+ return lhsID == rhsID
+ }
+ return lhs.baseURL == rhs.baseURL
+ || lhs.alternateBaseURLs.contains(rhs.baseURL)
+ || rhs.alternateBaseURLs.contains(lhs.baseURL)
+ }
+
+ private func mergedEndpoint(_ lhs: SimDeckEndpoint, _ rhs: SimDeckEndpoint) -> SimDeckEndpoint {
+ let preferred = endpointSourceRank(lhs.source) <= endpointSourceRank(rhs.source) ? lhs : rhs
+ let other = preferred.baseURL == lhs.baseURL ? rhs : lhs
+ var merged = preferred
+ merged.serverID = preferred.serverID ?? other.serverID
+ merged.token = preferred.token ?? other.token
+ merged.preferredSimulatorID = preferred.preferredSimulatorID ?? other.preferredSimulatorID
+ merged.requiresPairing = preferred.requiresPairing && other.requiresPairing
+ merged.alternateBaseURLs = uniquedURLs(
+ [lhs.baseURL, rhs.baseURL] + lhs.alternateBaseURLs + rhs.alternateBaseURLs
+ )
+ .filter { $0 != merged.baseURL }
+ return merged
+ }
+
+ private func uniquedURLs(_ urls: [URL]) -> [URL] {
+ var seen = Set()
+ var result: [URL] = []
+ for url in urls.map({ $0.normalizedSimDeckBaseURL() }) where seen.insert(url).inserted {
+ result.append(url)
+ }
+ return result
+ }
+
+ private func uniquedByBaseURL(_ endpoints: [SimDeckEndpoint]) -> [SimDeckEndpoint] {
+ var seen = Set()
+ var result: [SimDeckEndpoint] = []
+ for endpoint in endpoints where seen.insert(endpoint.baseURL).inserted {
+ result.append(endpoint)
+ }
+ return result
+ }
+
+ private func alternateURLs(from health: HealthResponse, fallbackPort: Int) -> [URL] {
+ guard let advertiseHost = health.advertiseHost?.nilIfBlank else { return [] }
+ var components = URLComponents()
+ components.scheme = "http"
+ components.host = advertiseHost
+ components.port = health.httpPort ?? fallbackPort
+ return components.url.map { [$0] } ?? []
+ }
+
+ private func normalizedPort(for url: URL) -> Int {
+ if let port = url.port {
+ return port
+ }
+ return url.scheme?.lowercased() == "https" ? 443 : 80
+ }
+
+ private func hostCanShareSimDeckToken(_ host: String?) -> Bool {
+ guard let host = host?.lowercased(), !host.isEmpty else {
+ return false
+ }
+ if host == "localhost" || host.hasSuffix(".local") {
+ return true
+ }
+ let parts = host.split(separator: ".").compactMap { UInt8($0) }
+ guard parts.count == 4 else {
+ return false
+ }
+ return parts[0] == 10
+ || parts[0] == 127
+ || (parts[0] == 169 && parts[1] == 254)
+ || (parts[0] == 172 && (16...31).contains(parts[1]))
+ || (parts[0] == 192 && parts[1] == 168)
+ || (parts[0] == 100 && (parts[1] & 0b1100_0000) == 0b0100_0000)
+ }
+
+ private func persistSavedEndpoints() {
+ if let data = try? JSONEncoder().encode(savedEndpoints) {
+ UserDefaults.standard.set(data, forKey: Self.savedEndpointsKey)
+ }
+ }
+
+ private func uniqued(_ endpoints: [SimDeckEndpoint]) -> [SimDeckEndpoint] {
+ var result: [SimDeckEndpoint] = []
+ for endpoint in endpoints {
+ if let index = result.firstIndex(where: { endpointsRepresentSameServer($0, endpoint) }) {
+ result[index] = mergedEndpoint(result[index], endpoint)
+ } else {
+ result.append(endpoint)
+ }
+ }
+ return result
+ }
+
+ private func loadSelectedEndpoint() -> SimDeckEndpoint? {
+ guard let data = UserDefaults.standard.data(forKey: Self.selectedEndpointKey) else {
+ return nil
+ }
+ return try? JSONDecoder().decode(SimDeckEndpoint.self, from: data)
+ }
+
+ private func saveSelectedEndpoint(_ endpoint: SimDeckEndpoint) {
+ if let data = try? JSONEncoder().encode(endpoint) {
+ UserDefaults.standard.set(data, forKey: Self.selectedEndpointKey)
+ }
+ }
+
+ private static func loadStreamConfig() -> StreamConfig {
+ guard let data = UserDefaults.standard.data(forKey: streamConfigKey),
+ let config = try? JSONDecoder().decode(StreamConfig.self, from: data) else {
+ return StreamConfig()
+ }
+ return config
+ }
+
+ private func saveStreamConfig(_ config: StreamConfig) {
+ if let data = try? JSONEncoder().encode(config) {
+ UserDefaults.standard.set(data, forKey: Self.streamConfigKey)
+ }
+ }
+
+ private static func loadHapticsEnabled() -> Bool {
+ UserDefaults.standard.object(forKey: hapticsEnabledKey) as? Bool ?? true
+ }
+
+ private static func loadTouchOverlayVisible() -> Bool {
+ UserDefaults.standard.object(forKey: touchOverlayVisibleKey) as? Bool ?? true
+ }
+
+ func hapticSelection() {
+ guard hapticsEnabled else { return }
+ UISelectionFeedbackGenerator().selectionChanged()
+ }
+
+ func hapticImpact() {
+ guard hapticsEnabled else { return }
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ }
+
+ func hapticSuccess() {
+ guard hapticsEnabled else { return }
+ UINotificationFeedbackGenerator().notificationOccurred(.success)
+ }
+
+ func hapticWarning() {
+ guard hapticsEnabled else { return }
+ UINotificationFeedbackGenerator().notificationOccurred(.warning)
+ }
+}
+
+private extension CGFloat {
+ func clamped(to range: ClosedRange) -> CGFloat {
+ Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
+ }
+}
+
+private extension StreamState {
+ init(peerState: RTCPeerConnectionState) {
+ switch peerState {
+ case .connected:
+ self = .connected
+ case .connecting, .new:
+ self = .connecting
+ case .disconnected, .closed:
+ self = .disconnected
+ case .failed:
+ self = .failed
+ @unknown default:
+ self = .disconnected
+ }
+ }
+}
diff --git a/ios/SimDeckStudio/App/Models.swift b/ios/SimDeckStudio/App/Models.swift
new file mode 100644
index 00000000..0292398e
--- /dev/null
+++ b/ios/SimDeckStudio/App/Models.swift
@@ -0,0 +1,595 @@
+import Foundation
+
+enum EndpointSource: String, Codable, CaseIterable, Sendable {
+ case bonjour
+ case lan
+ case tailscale
+ case manual
+ case studioLink
+ case recent
+
+ var label: String {
+ switch self {
+ case .bonjour: "Bonjour"
+ case .lan: "LAN"
+ case .tailscale: "Tailscale"
+ case .manual: "Manual"
+ case .studioLink: "Studio"
+ case .recent: "Recent"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .bonjour: "dot.radiowaves.left.and.right"
+ case .lan: "network"
+ case .tailscale: "point.3.connected.trianglepath.dotted"
+ case .manual: "link"
+ case .studioLink: "cloud"
+ case .recent: "clock"
+ }
+ }
+}
+
+struct SimDeckEndpoint: Identifiable, Hashable, Codable, Sendable {
+ var id: String { baseURL.absoluteString }
+
+ var name: String
+ var baseURL: URL
+ var source: EndpointSource
+ var token: String?
+ var requiresPairing: Bool
+ var preferredSimulatorID: String?
+ var serverID: String?
+ var alternateBaseURLs: [URL]
+
+ init(
+ name: String,
+ baseURL: URL,
+ source: EndpointSource,
+ token: String? = nil,
+ requiresPairing: Bool = false,
+ preferredSimulatorID: String? = nil,
+ serverID: String? = nil,
+ alternateBaseURLs: [URL] = []
+ ) {
+ let normalizedBaseURL = baseURL.normalizedSimDeckBaseURL()
+ self.name = name
+ self.baseURL = normalizedBaseURL
+ self.source = source
+ self.token = token?.nilIfBlank
+ self.requiresPairing = requiresPairing
+ self.preferredSimulatorID = preferredSimulatorID?.nilIfBlank
+ self.serverID = serverID?.nilIfBlank
+ self.alternateBaseURLs = alternateBaseURLs
+ .map { $0.normalizedSimDeckBaseURL() }
+ .filter { $0 != normalizedBaseURL }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case name
+ case baseURL
+ case source
+ case token
+ case requiresPairing
+ case preferredSimulatorID
+ case serverID
+ case alternateBaseURLs
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.init(
+ name: try container.decode(String.self, forKey: .name),
+ baseURL: try container.decode(URL.self, forKey: .baseURL),
+ source: try container.decode(EndpointSource.self, forKey: .source),
+ token: try container.decodeIfPresent(String.self, forKey: .token),
+ requiresPairing: try container.decodeIfPresent(Bool.self, forKey: .requiresPairing) ?? false,
+ preferredSimulatorID: try container.decodeIfPresent(String.self, forKey: .preferredSimulatorID),
+ serverID: try container.decodeIfPresent(String.self, forKey: .serverID),
+ alternateBaseURLs: try container.decodeIfPresent([URL].self, forKey: .alternateBaseURLs) ?? []
+ )
+ }
+}
+
+struct SimulatorMetadata: Identifiable, Hashable, Decodable, Sendable {
+ var id: String { udid }
+
+ let udid: String
+ let name: String
+ let platform: String?
+ let runtimeIdentifier: String?
+ let runtimeName: String?
+ let deviceTypeIdentifier: String?
+ let deviceTypeName: String?
+ let isBooted: Bool
+ let android: AndroidSimulatorInfo?
+ let privateDisplay: PrivateDisplayInfo?
+
+ var subtitle: String {
+ [runtimeName, deviceTypeName]
+ .compactMap(\.self)
+ .filter { !$0.isEmpty }
+ .joined(separator: " - ")
+ }
+
+ var systemImage: String {
+ let metadata = [
+ platform,
+ runtimeIdentifier,
+ runtimeName,
+ deviceTypeIdentifier,
+ deviceTypeName,
+ name
+ ]
+ .compactMap { $0?.lowercased() }
+ .joined(separator: " ")
+
+ if metadata.contains("apple-tv") || metadata.contains("apple tv") || metadata.contains("tvos") {
+ return "appletv"
+ }
+ if metadata.contains("apple-watch") || metadata.contains("apple watch") || metadata.contains("watchos") {
+ return "applewatch"
+ }
+ if metadata.contains("ipad") {
+ return "ipad"
+ }
+ if metadata.contains("vision") || metadata.contains("xros") {
+ return "visionpro"
+ }
+ if metadata.contains("mac") {
+ return "macbook"
+ }
+ if metadata.contains("android") || metadata.contains("pixel") {
+ return "rectangle.portrait"
+ }
+ return "iphone.gen3"
+ }
+}
+
+struct AndroidSimulatorInfo: Hashable, Decodable, Sendable {
+ let avdName: String?
+ let grpcPort: Int?
+ let serial: String?
+}
+
+struct PrivateDisplayInfo: Hashable, Decodable, Sendable {
+ let displayReady: Bool
+ let displayStatus: String
+ let displayWidth: Int
+ let displayHeight: Int
+}
+
+struct StreamDiagnostics: Hashable, Sendable {
+ var codec: String = ""
+ var width: UInt64 = 0
+ var height: UInt64 = 0
+ var receivedPackets: UInt64 = 0
+ var decodedFrames: UInt64 = 0
+ var renderedFrames: UInt64 = 0
+ var decoderDroppedFrames: UInt64 = 0
+ var presentationDroppedFrames: UInt64 = 0
+ var droppedFrames: UInt64 = 0
+ var packetsLost: UInt64 = 0
+ var latestPacketGapMs: Double = 0
+ var latestFrameGapMs: Double = 0
+ var packetFps: Double = 0
+ var decodedFps: Double = 0
+ var renderedFps: Double = 0
+ var peerConnectionState: String = ""
+ var iceConnectionState: String = ""
+ var iceGatheringState: String = ""
+ var signalingState: String = ""
+ var selectedCandidatePair: String = ""
+ var timestamp = Date()
+
+ init() {}
+
+ init(stats: [String: Any]) {
+ codec = stats["codec"] as? String ?? ""
+ width = StreamDiagnostics.uintValue(stats["width"])
+ height = StreamDiagnostics.uintValue(stats["height"])
+ receivedPackets = StreamDiagnostics.uintValue(stats["receivedPackets"])
+ decodedFrames = StreamDiagnostics.uintValue(stats["decodedFrames"])
+ renderedFrames = StreamDiagnostics.uintValue(stats["renderedFrames"])
+ decoderDroppedFrames = StreamDiagnostics.uintValue(stats["decoderDroppedFrames"])
+ presentationDroppedFrames = StreamDiagnostics.uintValue(stats["presentationDroppedFrames"])
+ droppedFrames = StreamDiagnostics.uintValue(stats["droppedFrames"])
+ if decoderDroppedFrames == 0 {
+ decoderDroppedFrames = droppedFrames
+ }
+ packetsLost = StreamDiagnostics.uintValue(stats["packetsLost"])
+ latestPacketGapMs = StreamDiagnostics.doubleValue(stats["latestPacketGapMs"])
+ latestFrameGapMs = StreamDiagnostics.doubleValue(stats["latestFrameGapMs"])
+ packetFps = StreamDiagnostics.doubleValue(stats["packetFps"])
+ decodedFps = StreamDiagnostics.doubleValue(stats["decodedFps"])
+ renderedFps = StreamDiagnostics.doubleValue(stats["appFps"])
+ peerConnectionState = stats["peerConnectionState"] as? String ?? stats["status"] as? String ?? ""
+ iceConnectionState = stats["iceConnectionState"] as? String ?? ""
+ iceGatheringState = stats["iceGatheringState"] as? String ?? ""
+ signalingState = stats["signalingState"] as? String ?? ""
+ selectedCandidatePair = stats["selectedCandidatePair"] as? String ?? ""
+ timestamp = Date()
+ }
+
+ private static func uintValue(_ value: Any?) -> UInt64 {
+ if let value = value as? UInt64 {
+ return value
+ }
+ if let value = value as? UInt {
+ return UInt64(value)
+ }
+ if let value = value as? Int {
+ return UInt64(max(value, 0))
+ }
+ if let value = value as? NSNumber {
+ return value.uint64Value
+ }
+ return 0
+ }
+
+ private static func doubleValue(_ value: Any?) -> Double {
+ if let value = value as? Double {
+ return value
+ }
+ if let value = value as? NSNumber {
+ return value.doubleValue
+ }
+ return 0
+ }
+}
+
+struct ChromeProfile: Hashable, Decodable, Sendable {
+ let totalWidth: Double
+ let totalHeight: Double
+ let screenX: Double
+ let screenY: Double
+ let screenWidth: Double
+ let screenHeight: Double
+ let contentX: Double?
+ let contentY: Double?
+ let contentWidth: Double?
+ let contentHeight: Double?
+ let cornerRadius: Double
+ let chromeStyle: String?
+ let hasScreenMask: Bool?
+ let buttons: [ChromeButtonProfile]?
+
+ var assetStamp: String {
+ var parts = [
+ totalWidth,
+ totalHeight,
+ screenX,
+ screenY,
+ screenWidth,
+ screenHeight,
+ contentX ?? 0,
+ contentY ?? 0,
+ contentWidth ?? 0,
+ contentHeight ?? 0,
+ cornerRadius
+ ]
+ .map { value in
+ Self.stampValue(value)
+ }
+ parts.append(hasScreenMask == true ? "mask" : "nomask")
+ parts.append(contentsOf: (buttons ?? [])
+ .sorted { $0.name < $1.name }
+ .map(\.assetStamp))
+ return parts.joined(separator: "x")
+ }
+
+ private static func stampValue(_ value: Double) -> String {
+ value.isFinite ? String(Int((value * 1000).rounded())) : "0"
+ }
+}
+
+struct ChromeButtonProfile: Hashable, Decodable, Sendable {
+ let name: String
+ let label: String?
+ let type: String?
+ let imageName: String?
+ let imageDownName: String?
+ let x: Double
+ let y: Double
+ let width: Double
+ let height: Double
+ let anchor: String?
+ let align: String?
+ let usagePage: Int?
+ let usage: Int?
+ let onTop: Bool?
+
+ var assetStamp: String {
+ [
+ sanitized(name),
+ sanitized(type),
+ sanitized(imageName),
+ sanitized(imageDownName),
+ sanitized(anchor),
+ sanitized(align),
+ onTop == true ? "top" : "under",
+ stampValue(x),
+ stampValue(y),
+ stampValue(width),
+ stampValue(height),
+ usagePage.map(String.init) ?? "",
+ usage.map(String.init) ?? ""
+ ].joined(separator: ".")
+ }
+
+ private func stampValue(_ value: Double) -> String {
+ value.isFinite ? String(Int((value * 1000).rounded())) : "0"
+ }
+
+ private func sanitized(_ value: String?) -> String {
+ (value ?? "").map { character in
+ character.isLetter || character.isNumber || character == "_" || character == "-" || character == "."
+ ? character
+ : "_"
+ }
+ .reduce(into: "") { $0.append($1) }
+ }
+}
+
+struct SimulatorsResponse: Decodable, Sendable {
+ let simulators: [SimulatorMetadata]
+}
+
+struct SimulatorDeviceTypeOption: Identifiable, Hashable, Decodable, Sendable {
+ var id: String { identifier }
+
+ let identifier: String
+ let name: String
+ let productFamily: String?
+ let supportedRuntimeIdentifiers: [String]?
+}
+
+struct SimulatorRuntimeOption: Identifiable, Hashable, Decodable, Sendable {
+ var id: String { identifier }
+
+ let identifier: String
+ let name: String
+ let platform: String?
+ let isAvailable: Bool?
+ let supportedDeviceTypeIdentifiers: [String]?
+}
+
+struct AndroidEmulatorDeviceTypeOption: Identifiable, Hashable, Decodable, Sendable {
+ var id: String { identifier }
+
+ let identifier: String
+ let name: String
+ let oem: String?
+ let tag: String?
+}
+
+struct AndroidEmulatorSystemImageOption: Identifiable, Hashable, Decodable, Sendable {
+ var id: String { identifier }
+
+ let identifier: String
+ let name: String
+ let description: String?
+ let apiLevel: Int?
+ let tag: String?
+ let abi: String?
+}
+
+struct AndroidEmulatorCreateOptions: Hashable, Decodable, Sendable {
+ let deviceTypes: [AndroidEmulatorDeviceTypeOption]
+ let systemImages: [AndroidEmulatorSystemImageOption]
+ let unavailableReason: String?
+}
+
+struct SimulatorCreateOptionsResponse: Hashable, Decodable, Sendable {
+ let deviceTypes: [SimulatorDeviceTypeOption]
+ let runtimes: [SimulatorRuntimeOption]
+ let android: AndroidEmulatorCreateOptions?
+}
+
+struct CreatePairedWatchRequest: Encodable, Hashable, Sendable {
+ let name: String
+ let deviceTypeIdentifier: String
+ let runtimeIdentifier: String?
+}
+
+struct CreateSimulatorRequest: Encodable, Hashable, Sendable {
+ let platform: String?
+ let name: String
+ let deviceTypeIdentifier: String
+ let runtimeIdentifier: String?
+ let pairedWatch: CreatePairedWatchRequest?
+}
+
+struct CreateSimulatorResponse: Decodable, Sendable {
+ let ok: Bool
+ let created: CreatedSimulatorInfo
+ let simulator: SimulatorMetadata
+ let pairedWatchSimulator: SimulatorMetadata?
+}
+
+struct CreatedSimulatorInfo: Decodable, Sendable {
+ let udid: String
+ let pairedWatchUDID: String?
+}
+
+struct HealthResponse: Decodable, Sendable {
+ let ok: Bool
+ let serverId: String?
+ let advertiseHost: String?
+ let httpPort: Int?
+ let videoCodec: String?
+ let realtimeStream: Bool?
+ let webRtc: WebRTCConfigurationResponse?
+}
+
+struct WebRTCConfigurationResponse: Decodable, Sendable {
+ let iceServers: [IceServer]?
+ let iceTransportPolicy: String?
+}
+
+struct IceServer: Hashable, Decodable, Sendable {
+ let urls: [String]
+ let username: String?
+ let credential: String?
+
+ enum CodingKeys: String, CodingKey {
+ case urls
+ case username
+ case credential
+ }
+
+ init(urls: [String], username: String? = nil, credential: String? = nil) {
+ self.urls = urls
+ self.username = username
+ self.credential = credential
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ if let urls = try? container.decode([String].self, forKey: .urls) {
+ self.urls = urls
+ } else {
+ self.urls = [try container.decode(String.self, forKey: .urls)]
+ }
+ username = try container.decodeIfPresent(String.self, forKey: .username)
+ credential = try container.decodeIfPresent(String.self, forKey: .credential)
+ }
+}
+
+struct WebRTCVideoMetadata: Decodable, Sendable {
+ let width: Int
+ let height: Int
+}
+
+struct WebRTCAnswerPayload: Decodable, Sendable {
+ let sdp: String
+ let type: String
+ let video: WebRTCVideoMetadata?
+}
+
+enum StreamEncoder: String, CaseIterable, Codable, Hashable, Sendable {
+ case auto
+ case hardware
+ case software
+
+ var label: String {
+ switch self {
+ case .auto: "Auto"
+ case .hardware: "Hardware"
+ case .software: "Software"
+ }
+ }
+}
+
+enum StreamQualityPreset: String, CaseIterable, Codable, Hashable, Sendable {
+ case auto
+ case full
+ case balanced
+ case economy
+ case low
+ case tiny
+
+ var label: String {
+ switch self {
+ case .auto: "Auto"
+ case .full: "Full"
+ case .balanced: "1280"
+ case .economy: "1080"
+ case .low: "720"
+ case .tiny: "540"
+ }
+ }
+
+ var summaryLabel: String {
+ switch self {
+ case .auto: "Auto"
+ case .full: "Full res"
+ case .balanced: "1280px"
+ case .economy: "1080px"
+ case .low: "720px"
+ case .tiny: "540px"
+ }
+ }
+
+ var payloadProfile: String {
+ self == .auto ? StreamQualityPreset.economy.rawValue : rawValue
+ }
+}
+
+struct StreamConfig: Codable, Hashable, Sendable {
+ var encoder: StreamEncoder = .auto
+ var fps: Int = 60
+ var quality: StreamQualityPreset = .full
+
+ var summary: String {
+ "WebRTC / \(quality.summaryLabel) / \(fps) fps"
+ }
+}
+
+struct StreamQualityPayload: Encodable, Sendable {
+ var profile: String
+ var fps: Int
+ var videoCodec: String
+
+ init(config: StreamConfig = StreamConfig()) {
+ profile = config.quality.payloadProfile
+ fps = config.fps
+ videoCodec = config.encoder.rawValue
+ }
+
+ var jsonObject: [String: Any] {
+ [
+ "profile": profile,
+ "fps": fps,
+ "videoCodec": videoCodec
+ ]
+ }
+}
+
+struct WebRTCOfferPayload: Encodable, Sendable {
+ let clientId: String
+ let sdp: String
+ let streamConfig: StreamQualityPayload
+ let type: String
+}
+
+enum AppRoute: Hashable, Sendable {
+ case endpoint(SimDeckEndpoint, autoStart: Bool)
+ case pairing(SimDeckPairingLink, autoStart: Bool)
+}
+
+struct SimDeckPairingLink: Hashable, Sendable {
+ let endpoint: SimDeckEndpoint
+ let pairingCode: String?
+ let alternateEndpoints: [SimDeckEndpoint]
+}
+
+extension URL {
+ func normalizedSimDeckBaseURL() -> URL {
+ guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
+ return self
+ }
+ components.query = nil
+ components.fragment = nil
+ if components.path != "/" {
+ components.path = components.path.trimmingTrailingSlashes()
+ }
+ return components.url ?? self
+ }
+}
+
+extension String {
+ var nilIfBlank: String? {
+ let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ func trimmingTrailingSlashes() -> String {
+ var value = self
+ while value.count > 1 && value.hasSuffix("/") {
+ value.removeLast()
+ }
+ return value
+ }
+}
diff --git a/ios/SimDeckStudio/App/SimDeckStudioApp.swift b/ios/SimDeckStudio/App/SimDeckStudioApp.swift
new file mode 100644
index 00000000..569a734b
--- /dev/null
+++ b/ios/SimDeckStudio/App/SimDeckStudioApp.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+@main
+struct SimDeckStudioApp: App {
+ @State private var model = AppModel()
+ @Environment(\.scenePhase) private var scenePhase
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView(model: model)
+ .onOpenURL { url in
+ model.handle(url: url)
+ }
+ .onChange(of: scenePhase) { _, phase in
+ model.handleScenePhase(phase)
+ }
+ }
+ }
+}
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png
new file mode 100644
index 00000000..077e0f05
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png
new file mode 100644
index 00000000..f0caa8f6
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png
new file mode 100644
index 00000000..b0c5d0b4
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png
new file mode 100644
index 00000000..88a069eb
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png
new file mode 100644
index 00000000..66bec3a9
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png
new file mode 100644
index 00000000..db3b10ef
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png
new file mode 100644
index 00000000..f0caa8f6
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png
new file mode 100644
index 00000000..1d8f5d4f
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png
new file mode 100644
index 00000000..6fe5774a
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png
new file mode 100644
index 00000000..6fe5774a
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png
new file mode 100644
index 00000000..c5ad72dd
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png
new file mode 100644
index 00000000..cadc9b5f
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png
new file mode 100644
index 00000000..ba24d38f
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png
new file mode 100644
index 00000000..9ac46b53
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon.png
new file mode 100644
index 00000000..05ce75b7
Binary files /dev/null and b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ
diff --git a/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..ffaaa8a5
--- /dev/null
+++ b/ios/SimDeckStudio/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,116 @@
+{
+ "images": [
+ {
+ "filename": "AppIcon-20x20@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-20x20@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-29x29@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29x29@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-40x40@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-40x40@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-60x60@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon-60x60@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon-20x20@1x.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-20x20@2x.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-29x29@1x.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29x29@2x.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-40x40@1x.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-40x40@2x.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-76x76@1x.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "76x76"
+ },
+ {
+ "filename": "AppIcon-76x76@2x.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "76x76"
+ },
+ {
+ "filename": "AppIcon-83.5x83.5@2x.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "83.5x83.5"
+ },
+ {
+ "filename": "AppIcon.png",
+ "idiom": "ios-marketing",
+ "scale": "1x",
+ "size": "1024x1024"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/ios/SimDeckStudio/Assets.xcassets/Contents.json b/ios/SimDeckStudio/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..74d6a722
--- /dev/null
+++ b/ios/SimDeckStudio/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/ios/SimDeckStudio/Discovery/SimDeckDiscovery.swift b/ios/SimDeckStudio/Discovery/SimDeckDiscovery.swift
new file mode 100644
index 00000000..fe99b173
--- /dev/null
+++ b/ios/SimDeckStudio/Discovery/SimDeckDiscovery.swift
@@ -0,0 +1,350 @@
+import Darwin
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class SimDeckDiscovery {
+ var endpoints: [SimDeckEndpoint] = []
+ var isScanning = false
+
+ @ObservationIgnored private let bonjour = BonjourDiscovery()
+ @ObservationIgnored private var scanTask: Task?
+ @ObservationIgnored var onEndpoint: ((SimDeckEndpoint) -> Void)?
+
+ init() {
+ bonjour.onEndpoint = { [weak self] endpoint in
+ Task { @MainActor in
+ self?.upsert(endpoint)
+ }
+ }
+ }
+
+ func start() {
+ bonjour.start()
+ refresh()
+ }
+
+ func stop() {
+ bonjour.stop()
+ scanTask?.cancel()
+ scanTask = nil
+ }
+
+ func refresh() {
+ scanTask?.cancel()
+ isScanning = true
+ scanTask = Task {
+ let local = await Self.scanPriorityHosts()
+ guard !Task.isCancelled else { return }
+ await MainActor.run {
+ for endpoint in local {
+ upsert(endpoint)
+ }
+ }
+ let found = await Self.scanLikelyHosts()
+ guard !Task.isCancelled else { return }
+ await MainActor.run {
+ for endpoint in found {
+ upsert(endpoint)
+ }
+ isScanning = false
+ }
+ }
+ }
+
+ func upsert(_ endpoint: SimDeckEndpoint) {
+ let isNewEndpoint: Bool
+ let shouldNotify: Bool
+ if let index = endpoints.firstIndex(where: { Self.sameServer($0, endpoint) }) {
+ let previous = endpoints[index]
+ endpoints[index] = Self.mergedEndpoint(previous, endpoint)
+ isNewEndpoint = false
+ shouldNotify = previous.baseURL != endpoints[index].baseURL
+ || (previous.requiresPairing && !endpoints[index].requiresPairing)
+ } else {
+ endpoints.append(endpoint)
+ isNewEndpoint = true
+ shouldNotify = true
+ }
+ endpoints.sort {
+ if $0.source == $1.source {
+ return $0.name.localizedStandardCompare($1.name) == .orderedAscending
+ }
+ return sourceRank($0.source) < sourceRank($1.source)
+ }
+ if isNewEndpoint || shouldNotify {
+ onEndpoint?(endpoint)
+ }
+ }
+
+ private static func sameServer(_ lhs: SimDeckEndpoint, _ rhs: SimDeckEndpoint) -> Bool {
+ if let lhsID = lhs.serverID?.nilIfBlank,
+ let rhsID = rhs.serverID?.nilIfBlank {
+ return lhsID == rhsID
+ }
+ return lhs.baseURL == rhs.baseURL
+ || lhs.alternateBaseURLs.contains(rhs.baseURL)
+ || rhs.alternateBaseURLs.contains(lhs.baseURL)
+ }
+
+ private static func mergedEndpoint(_ lhs: SimDeckEndpoint, _ rhs: SimDeckEndpoint) -> SimDeckEndpoint {
+ let preferred = preferredEndpoint(lhs, rhs)
+ let other = preferred.baseURL == lhs.baseURL ? rhs : lhs
+ var merged = preferred
+ merged.serverID = preferred.serverID ?? other.serverID
+ merged.token = preferred.token ?? other.token
+ merged.requiresPairing = preferred.requiresPairing && other.requiresPairing
+ merged.preferredSimulatorID = preferred.preferredSimulatorID ?? other.preferredSimulatorID
+ merged.alternateBaseURLs = uniquedURLs(
+ [lhs.baseURL, rhs.baseURL] + lhs.alternateBaseURLs + rhs.alternateBaseURLs
+ )
+ .filter { $0 != merged.baseURL }
+ return merged
+ }
+
+ private static func preferredEndpoint(_ lhs: SimDeckEndpoint, _ rhs: SimDeckEndpoint) -> SimDeckEndpoint {
+ if lhs.requiresPairing != rhs.requiresPairing {
+ return lhs.requiresPairing ? rhs : lhs
+ }
+ if sourceRankValue(lhs.source) != sourceRankValue(rhs.source) {
+ return sourceRankValue(lhs.source) < sourceRankValue(rhs.source) ? lhs : rhs
+ }
+ return lhs
+ }
+
+ private static func uniquedURLs(_ urls: [URL]) -> [URL] {
+ var seen = Set()
+ var result: [URL] = []
+ for url in urls.map({ $0.normalizedSimDeckBaseURL() }) where seen.insert(url).inserted {
+ result.append(url)
+ }
+ return result
+ }
+
+ private func sourceRank(_ source: EndpointSource) -> Int {
+ Self.sourceRankValue(source)
+ }
+
+ private static func sourceRankValue(_ source: EndpointSource) -> Int {
+ switch source {
+ case .bonjour: 0
+ case .lan: 1
+ case .tailscale: 2
+ case .studioLink: 3
+ case .manual: 4
+ case .recent: 5
+ }
+ }
+
+ private static func scanLikelyHosts() async -> [SimDeckEndpoint] {
+ let candidates = IPv4Interface.discoveryCandidates()
+ let ports = [4310, 4311, 4312, 4313, 4314, 4320]
+ return await scan(candidates: candidates, ports: ports)
+ }
+
+ private static func scanPriorityHosts() async -> [SimDeckEndpoint] {
+ for port in [4313, 4310, 4311, 4312, 4314, 4320] {
+ if let endpoint = await probe(host: "127.0.0.1", port: port, source: .manual) {
+ return [endpoint]
+ }
+ }
+ if let endpoint = await probe(host: "localhost", port: 4313, source: .manual) {
+ return [endpoint]
+ }
+ if let endpoint = await probe(host: "simdeck.local", port: 4310, source: .bonjour) {
+ return [endpoint]
+ }
+ return []
+ }
+
+ private static func scan(candidates: [DiscoveryCandidate], ports: [Int]) async -> [SimDeckEndpoint] {
+ var results: [SimDeckEndpoint] = []
+
+ for batch in candidates.chunked(into: 16) {
+ await withTaskGroup(of: SimDeckEndpoint?.self) { group in
+ for candidate in batch {
+ for port in ports {
+ group.addTask {
+ await probe(host: candidate.host, port: port, source: candidate.source)
+ }
+ }
+ }
+ for await endpoint in group {
+ if let endpoint, !results.contains(where: { $0.baseURL == endpoint.baseURL }) {
+ results.append(endpoint)
+ }
+ }
+ }
+ }
+ return results
+ }
+
+ private static func probe(host: String, port: Int, source: EndpointSource) async -> SimDeckEndpoint? {
+ var components = URLComponents()
+ components.scheme = "http"
+ components.host = host
+ components.port = port
+ guard let baseURL = components.url,
+ let healthURL = URL(string: "/api/health", relativeTo: baseURL) else {
+ return nil
+ }
+ var request = URLRequest(url: healthURL)
+ request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
+ request.timeoutInterval = 1.25
+ request.setValue(baseURL.absoluteString.trimmingTrailingSlashes(), forHTTPHeaderField: "Origin")
+ do {
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse else { return nil }
+ if http.statusCode == 401 {
+ let health = try? JSONDecoder().decode(HealthResponse.self, from: data)
+ return SimDeckEndpoint(
+ name: endpointName(for: host),
+ baseURL: baseURL,
+ source: source,
+ requiresPairing: true,
+ serverID: health?.serverId,
+ alternateBaseURLs: alternateURLs(from: health, fallbackPort: port)
+ )
+ }
+ guard http.statusCode == 200,
+ let health = try? JSONDecoder().decode(HealthResponse.self, from: data),
+ health.ok else {
+ return nil
+ }
+ return SimDeckEndpoint(
+ name: endpointName(for: host),
+ baseURL: baseURL,
+ source: source,
+ serverID: health.serverId,
+ alternateBaseURLs: alternateURLs(from: health, fallbackPort: port)
+ )
+ } catch {
+ return nil
+ }
+ }
+
+ private static func alternateURLs(from health: HealthResponse?, fallbackPort: Int) -> [URL] {
+ guard let advertiseHost = health?.advertiseHost?.nilIfBlank else { return [] }
+ var components = URLComponents()
+ components.scheme = "http"
+ components.host = advertiseHost
+ components.port = health?.httpPort ?? fallbackPort
+ return components.url.map { [$0] } ?? []
+ }
+
+ private static func endpointName(for host: String) -> String {
+ if host == "127.0.0.1" || host == "localhost" {
+ return "Local SimDeck"
+ }
+ return "SimDeck \(host)"
+ }
+}
+
+private final class BonjourDiscovery: NSObject, NetServiceBrowserDelegate, NetServiceDelegate {
+ var onEndpoint: (@Sendable (SimDeckEndpoint) -> Void)?
+ private let browser = NetServiceBrowser()
+ private var services: [NetService] = []
+
+ override init() {
+ super.init()
+ browser.delegate = self
+ }
+
+ func start() {
+ browser.searchForServices(ofType: "_simdeck._tcp.", inDomain: "local.")
+ }
+
+ func stop() {
+ browser.stop()
+ services.removeAll()
+ }
+
+ func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
+ services.append(service)
+ service.delegate = self
+ service.resolve(withTimeout: 2)
+ }
+
+ func netServiceDidResolveAddress(_ sender: NetService) {
+ let host = sender.hostName?.trimmingTrailingSlashes() ?? "\(sender.name).local"
+ let txt = NetService.dictionary(fromTXTRecord: sender.txtRecordData() ?? Data())
+ let serverID = txt["sid"].flatMap { String(data: $0, encoding: .utf8) }?.nilIfBlank
+ let advertisedHost = txt["host"].flatMap { String(data: $0, encoding: .utf8) }?.nilIfBlank
+ var components = URLComponents()
+ components.scheme = "http"
+ components.host = host
+ components.port = sender.port
+ guard let url = components.url else { return }
+ var alternateURLs: [URL] = []
+ if let advertisedHost {
+ var advertised = URLComponents()
+ advertised.scheme = "http"
+ advertised.host = advertisedHost
+ advertised.port = sender.port
+ if let advertisedURL = advertised.url {
+ alternateURLs.append(advertisedURL)
+ }
+ }
+ onEndpoint?(
+ SimDeckEndpoint(
+ name: sender.name.isEmpty ? "SimDeck \(host)" : sender.name,
+ baseURL: url,
+ source: .bonjour,
+ serverID: serverID,
+ alternateBaseURLs: alternateURLs
+ )
+ )
+ }
+}
+
+private struct DiscoveryCandidate: Hashable {
+ let host: String
+ let source: EndpointSource
+}
+
+private struct IPv4Interface {
+ let address: [UInt8]
+ let isTailscale: Bool
+
+ static func discoveryCandidates() -> [DiscoveryCandidate] {
+ var candidates: [DiscoveryCandidate] = []
+ var seen = Set()
+ var interfaces: UnsafeMutablePointer?
+ guard getifaddrs(&interfaces) == 0, let first = interfaces else { return candidates }
+ defer { freeifaddrs(interfaces) }
+
+ var cursor: UnsafeMutablePointer? = first
+ while let current = cursor {
+ defer { cursor = current.pointee.ifa_next }
+ let flags = Int32(current.pointee.ifa_flags)
+ guard flags & IFF_UP != 0, flags & IFF_LOOPBACK == 0 else { continue }
+ guard current.pointee.ifa_addr.pointee.sa_family == UInt8(AF_INET) else { continue }
+ let socketAddress = current.pointee.ifa_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
+ let host = IPv4Interface.bytes(from: socketAddress.sin_addr)
+ guard host.count == 4 else { continue }
+ let source: EndpointSource = host[0] == 100 && (host[1] & 0b1100_0000) == 0b0100_0000 ? .tailscale : .lan
+ for last in UInt8(1)...UInt8(254) where last != host[3] {
+ let candidate = DiscoveryCandidate(host: "\(host[0]).\(host[1]).\(host[2]).\(last)", source: source)
+ if seen.insert(candidate).inserted {
+ candidates.append(candidate)
+ }
+ }
+ }
+ return candidates
+ }
+
+ private static func bytes(from address: in_addr) -> [UInt8] {
+ var address = address
+ return withUnsafeBytes(of: &address.s_addr) { Array($0) }
+ }
+}
+
+private extension Array {
+ func chunked(into size: Int) -> [[Element]] {
+ guard size > 0 else { return [self] }
+ return stride(from: 0, to: count, by: size).map {
+ Array(self[$0..
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ SimDeck
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ org.nativescript.simdeck
+ CFBundleURLSchemes
+
+ simdeck
+
+
+
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ ITSAppUsesNonExemptEncryption
+
+ LSApplicationQueriesSchemes
+
+ http
+ https
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSExceptionDomains
+
+ ts.net
+
+ NSIncludesSubdomains
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+
+
+
+ NSBonjourServices
+
+ _simdeck._tcp
+
+ NSCameraUsageDescription
+ SimDeck uses the camera to scan pairing QR codes.
+ NSLocalNetworkUsageDescription
+ SimDeck discovers and connects to simulator streams on your local network.
+ NSMicrophoneUsageDescription
+ SimDeck uses WebRTC to connect to simulator streams. Microphone access is only used for WebRTC features that send audio from this device.
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/ios/SimDeckStudio/Networking/SimDeckAPI.swift b/ios/SimDeckStudio/Networking/SimDeckAPI.swift
new file mode 100644
index 00000000..02c6cbd3
--- /dev/null
+++ b/ios/SimDeckStudio/Networking/SimDeckAPI.swift
@@ -0,0 +1,239 @@
+import Foundation
+import UIKit
+
+enum SimDeckAPIError: LocalizedError {
+ case authRequired
+ case invalidResponse
+ case requestFailed(Int, String)
+
+ var errorDescription: String? {
+ switch self {
+ case .authRequired:
+ "Pairing or an API token is required."
+ case .invalidResponse:
+ "SimDeck returned an invalid response."
+ case let .requestFailed(status, message):
+ "Request failed with status \(status): \(message)"
+ }
+ }
+}
+
+struct SimDeckAPI: Sendable {
+ let endpoint: SimDeckEndpoint
+ var baseURL: URL { endpoint.baseURL }
+
+ func health(timeout: TimeInterval = 5) async throws -> HealthResponse {
+ try await decode(path: "/api/health", timeout: timeout)
+ }
+
+ func simulators() async throws -> [SimulatorMetadata] {
+ let response: SimulatorsResponse = try await decode(path: "/api/simulators")
+ return response.simulators
+ }
+
+ func simulatorCreateOptions() async throws -> SimulatorCreateOptionsResponse {
+ try await decode(path: "/api/simulators/create-options")
+ }
+
+ func createSimulator(_ payload: CreateSimulatorRequest) async throws -> CreateSimulatorResponse {
+ try await decode(path: "/api/simulators", method: "POST", body: payload, timeout: 300)
+ }
+
+ func pair(code: String) async throws -> String? {
+ let payload = ["code": code]
+ let (data, response) = try await requestWithHTTPResponse(
+ path: "/api/pair",
+ method: "POST",
+ body: payload,
+ timeout: 10
+ )
+ let pairResponse = try? JSONDecoder().decode(PairResponse.self, from: data)
+ return pairResponse?.accessToken?.nilIfBlank ?? accessToken(from: response)
+ }
+
+ func bootSimulator(udid: String) async throws {
+ let _: EmptyResponse = try await decode(
+ path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/boot",
+ method: "POST",
+ body: Optional.none,
+ timeout: 300
+ )
+ }
+
+ func postWebRTCOffer(_ offer: WebRTCOfferPayload, udid: String) async throws -> WebRTCAnswerPayload {
+ try await decode(
+ path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/webrtc/offer",
+ method: "POST",
+ body: offer,
+ timeout: 20
+ )
+ }
+
+ func chromeProfile(udid: String) async throws -> ChromeProfile {
+ try await decode(
+ path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/chrome-profile",
+ cachePolicy: .reloadIgnoringLocalAndRemoteCacheData
+ )
+ }
+
+ func chromeImage(udid: String, stamp: String? = nil) async throws -> UIImage {
+ let data = try await request(
+ path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/chrome.png",
+ method: "GET",
+ body: Optional.none,
+ timeout: 10,
+ queryItems: Self.assetQueryItems(stamp: stamp),
+ cachePolicy: .reloadIgnoringLocalAndRemoteCacheData
+ )
+ guard let image = UIImage(data: data) else {
+ throw SimDeckAPIError.invalidResponse
+ }
+ return image
+ }
+
+ func screenMaskImage(udid: String, stamp: String? = nil) async throws -> UIImage {
+ let data = try await request(
+ path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/screen-mask.png",
+ method: "GET",
+ body: Optional.none,
+ timeout: 10,
+ queryItems: Self.assetQueryItems(stamp: stamp),
+ cachePolicy: .reloadIgnoringLocalAndRemoteCacheData
+ )
+ guard let image = UIImage(data: data) else {
+ throw SimDeckAPIError.invalidResponse
+ }
+ return image
+ }
+
+ func postControl(_ payload: some Encodable, path: String) async throws {
+ let _: EmptyResponse = try await decode(path: path, method: "POST", body: payload)
+ }
+
+ private func decode(
+ path: String,
+ method: String = "GET",
+ body: (some Encodable)? = Optional.none,
+ timeout: TimeInterval = 10,
+ queryItems: [URLQueryItem] = [],
+ cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
+ ) async throws -> T {
+ let data = try await request(path: path, method: method, body: body, timeout: timeout, queryItems: queryItems, cachePolicy: cachePolicy)
+ if data.isEmpty, T.self == EmptyResponse.self {
+ return EmptyResponse() as! T
+ }
+ return try JSONDecoder().decode(T.self, from: data)
+ }
+
+ private func request(
+ path: String,
+ method: String,
+ body: (some Encodable)?,
+ timeout: TimeInterval,
+ queryItems: [URLQueryItem] = [],
+ cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
+ ) async throws -> Data {
+ let (data, _) = try await requestWithHTTPResponse(
+ path: path,
+ method: method,
+ body: body,
+ timeout: timeout,
+ queryItems: queryItems,
+ cachePolicy: cachePolicy
+ )
+ return data
+ }
+
+ private func requestWithHTTPResponse(
+ path: String,
+ method: String,
+ body: (some Encodable)?,
+ timeout: TimeInterval,
+ queryItems: [URLQueryItem] = [],
+ cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
+ ) async throws -> (Data, HTTPURLResponse) {
+ var request = URLRequest(url: url(for: path, queryItems: queryItems), cachePolicy: cachePolicy, timeoutInterval: timeout)
+ request.httpMethod = method
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue(originHeaderValue, forHTTPHeaderField: "Origin")
+ if let token = endpoint.token?.nilIfBlank {
+ request.setValue(token, forHTTPHeaderField: "X-SimDeck-Token")
+ }
+ if let body {
+ request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ }
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw SimDeckAPIError.invalidResponse
+ }
+ if httpResponse.statusCode == 401 {
+ throw SimDeckAPIError.authRequired
+ }
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ let message = String(data: data, encoding: .utf8) ?? HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)
+ throw SimDeckAPIError.requestFailed(httpResponse.statusCode, message)
+ }
+ return (data, httpResponse)
+ }
+
+ private func accessToken(from response: HTTPURLResponse) -> String? {
+ let headerFields = response.allHeaderFields.reduce(into: [String: String]()) { result, item in
+ guard let key = item.key as? String else { return }
+ result[key] = String(describing: item.value)
+ }
+ return HTTPCookie
+ .cookies(withResponseHeaderFields: headerFields, for: baseURL)
+ .first { $0.name == "simdeck_token" }?
+ .value
+ .nilIfBlank
+ }
+
+ private var originHeaderValue: String {
+ guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
+ return baseURL.absoluteString
+ }
+ components.path = ""
+ components.query = nil
+ components.fragment = nil
+ return components.url?.absoluteString.trimmingTrailingSlashes() ?? baseURL.absoluteString
+ }
+
+ private func url(for path: String, queryItems: [URLQueryItem] = []) -> URL {
+ guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
+ return baseURL.appendingPathComponent(path)
+ }
+ let prefix = components.path.trimmingTrailingSlashes()
+ let suffix = path.hasPrefix("/") ? path : "/\(path)"
+ components.path = "\(prefix)\(suffix)"
+ if !queryItems.isEmpty {
+ components.queryItems = (components.queryItems ?? []) + queryItems
+ }
+ return components.url ?? baseURL.appendingPathComponent(path)
+ }
+
+ private static func assetQueryItems(stamp: String?) -> [URLQueryItem] {
+ guard let stamp = stamp?.nilIfBlank else { return [] }
+ return [URLQueryItem(name: "stamp", value: stamp)]
+ }
+}
+
+private struct EmptyResponse: Codable {}
+
+private struct PairResponse: Decodable {
+ let ok: Bool
+ let accessToken: String?
+}
+
+private struct AnyEncodable: Encodable {
+ private let encodeValue: (Encoder) throws -> Void
+
+ init(_ value: some Encodable) {
+ encodeValue = value.encode
+ }
+
+ func encode(to encoder: Encoder) throws {
+ try encodeValue(encoder)
+ }
+}
diff --git a/ios/SimDeckStudio/Networking/StudioLinkResolver.swift b/ios/SimDeckStudio/Networking/StudioLinkResolver.swift
new file mode 100644
index 00000000..dc9f770f
--- /dev/null
+++ b/ios/SimDeckStudio/Networking/StudioLinkResolver.swift
@@ -0,0 +1,172 @@
+import Foundation
+
+enum StudioLinkResolver {
+ static func route(for url: URL) -> AppRoute? {
+ if url.scheme?.lowercased() == "simdeck" {
+ if let pairingLink = pairingLinkFromCustomScheme(url) {
+ return .pairing(pairingLink, autoStart: true)
+ }
+ if let endpoint = endpointFromCustomScheme(url) {
+ return .endpoint(endpoint, autoStart: true)
+ }
+ }
+ guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
+ return nil
+ }
+ if let endpoint = endpointFromStudioURL(url) {
+ return .endpoint(endpoint, autoStart: true)
+ }
+ let serverID = queryValue("serverId", in: url) ?? queryValue("sid", in: url) ?? queryValue("s", in: url)
+ return .endpoint(
+ SimDeckEndpoint(
+ name: url.host ?? "SimDeck",
+ baseURL: url,
+ source: source(for: url.host),
+ preferredSimulatorID: queryValue("device", in: url) ?? queryValue("udid", in: url),
+ serverID: serverID
+ ),
+ autoStart: true
+ )
+ }
+
+ static func endpointFromAddress(_ value: String, token: String? = nil) -> SimDeckEndpoint? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ let withScheme = trimmed.contains("://") ? trimmed : "http://\(trimmed)"
+ guard let url = URL(string: withScheme), url.host != nil else { return nil }
+ if let endpoint = endpointFromStudioURL(url) {
+ var endpointWithToken = endpoint
+ endpointWithToken.token = token?.nilIfBlank
+ return endpointWithToken
+ }
+ return SimDeckEndpoint(
+ name: url.host ?? "SimDeck",
+ baseURL: url,
+ source: source(for: url.host),
+ token: token
+ )
+ }
+
+ private static func endpointFromCustomScheme(_ url: URL) -> SimDeckEndpoint? {
+ guard url.scheme?.lowercased() == "simdeck" else { return nil }
+ let serverID = queryValue("serverId", in: url) ?? queryValue("sid", in: url) ?? queryValue("s", in: url)
+ if let rawURL = queryValue("url", in: url) ?? queryValue("u", in: url),
+ var endpoint = endpointFromAddress(rawURL) {
+ if let token = queryValue("token", in: url) {
+ endpoint.token = token
+ }
+ endpoint.preferredSimulatorID = queryValue("device", in: url) ?? queryValue("udid", in: url)
+ endpoint.serverID = serverID
+ return endpoint
+ }
+ guard let host = queryValue("host", in: url) ?? url.host else { return nil }
+ let port = queryValue("port", in: url).flatMap(Int.init)
+ var components = URLComponents()
+ components.scheme = queryValue("scheme", in: url) ?? "http"
+ components.host = host
+ components.port = port
+ guard let baseURL = components.url else { return nil }
+ return SimDeckEndpoint(
+ name: host,
+ baseURL: baseURL,
+ source: source(for: host),
+ token: queryValue("token", in: url),
+ preferredSimulatorID: queryValue("device", in: url) ?? queryValue("udid", in: url),
+ serverID: serverID
+ )
+ }
+
+ private static func pairingLinkFromCustomScheme(_ url: URL) -> SimDeckPairingLink? {
+ guard url.scheme?.lowercased() == "simdeck",
+ ["pair", "pairing"].contains(url.host?.lowercased() ?? "") else {
+ return nil
+ }
+ guard var endpoint = endpointFromCustomScheme(url) else { return nil }
+ let pairingCode = queryValue("code", in: url) ?? queryValue("pairingCode", in: url) ?? queryValue("c", in: url)
+ let serverID = queryValue("serverId", in: url) ?? queryValue("sid", in: url) ?? queryValue("s", in: url)
+ endpoint.serverID = endpoint.serverID ?? serverID
+ if endpoint.token == nil {
+ endpoint.token = queryValue("token", in: url)
+ }
+ let alternateEndpoints = alternateEndpointValues(in: url).compactMap { rawValue -> SimDeckEndpoint? in
+ guard var alternate = endpointFromAddress(rawValue, token: endpoint.token) else { return nil }
+ alternate.preferredSimulatorID = endpoint.preferredSimulatorID
+ alternate.serverID = endpoint.serverID
+ return alternate
+ }
+ .filter { $0.baseURL != endpoint.baseURL }
+ return SimDeckPairingLink(
+ endpoint: endpoint,
+ pairingCode: pairingCode,
+ alternateEndpoints: uniquedEndpoints(alternateEndpoints)
+ )
+ }
+
+ private static func endpointFromStudioURL(_ url: URL) -> SimDeckEndpoint? {
+ let parts = url.pathComponents.filter { $0 != "/" }
+ guard let simulatorIndex = parts.firstIndex(of: "simulator"),
+ parts.indices.contains(simulatorIndex + 1) else {
+ return nil
+ }
+ let previewID = parts[simulatorIndex + 1]
+ guard !previewID.isEmpty,
+ var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+ return nil
+ }
+ components.path = "/api/provider-sessions/\(previewID)/simdeck"
+ components.query = nil
+ components.fragment = nil
+ guard let baseURL = components.url else { return nil }
+ return SimDeckEndpoint(
+ name: "Studio \(previewID)",
+ baseURL: baseURL,
+ source: .studioLink,
+ token: queryValue("simdeckToken", in: url) ?? queryValue("token", in: url),
+ preferredSimulatorID: queryValue("device", in: url) ?? queryValue("udid", in: url),
+ serverID: queryValue("serverId", in: url) ?? queryValue("sid", in: url) ?? queryValue("s", in: url)
+ )
+ }
+
+ private static func queryValue(_ name: String, in url: URL) -> String? {
+ URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first { $0.name == name }?
+ .value?
+ .nilIfBlank
+ }
+
+ private static func queryValues(_ name: String, in url: URL) -> [String] {
+ URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .filter { $0.name == name }
+ .compactMap { $0.value?.nilIfBlank } ?? []
+ }
+
+ private static func alternateEndpointValues(in url: URL) -> [String] {
+ queryValues("alt", in: url)
+ + queryValues("a", in: url)
+ + Array(queryValues("url", in: url).dropFirst())
+ + queryValues("lan", in: url)
+ + queryValues("tailscale", in: url)
+ }
+
+ private static func uniquedEndpoints(_ endpoints: [SimDeckEndpoint]) -> [SimDeckEndpoint] {
+ var seen = Set()
+ var result: [SimDeckEndpoint] = []
+ for endpoint in endpoints where seen.insert(endpoint.baseURL).inserted {
+ result.append(endpoint)
+ }
+ return result
+ }
+
+ private static func source(for host: String?) -> EndpointSource {
+ guard let host, isTailscaleIPv4Host(host) else { return .manual }
+ return .tailscale
+ }
+
+ private static func isTailscaleIPv4Host(_ host: String) -> Bool {
+ let parts = host.split(separator: ".").compactMap { UInt8($0) }
+ guard parts.count == 4 else { return false }
+ return parts[0] == 100 && (parts[1] & 0b1100_0000) == 0b0100_0000
+ }
+}
diff --git a/ios/SimDeckStudio/SimDeckStudio.entitlements b/ios/SimDeckStudio/SimDeckStudio.entitlements
new file mode 100644
index 00000000..28586c6a
--- /dev/null
+++ b/ios/SimDeckStudio/SimDeckStudio.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.developer.associated-domains
+
+ applinks:simdeck.djdev.me
+
+
+
diff --git a/ios/SimDeckStudio/Streaming/WebRTCClient.swift b/ios/SimDeckStudio/Streaming/WebRTCClient.swift
new file mode 100644
index 00000000..72700b0a
--- /dev/null
+++ b/ios/SimDeckStudio/Streaming/WebRTCClient.swift
@@ -0,0 +1,816 @@
+import Foundation
+@preconcurrency import WebRTC
+
+final class WebRTCClient: NSObject {
+ let clientID = "simdeck-ios-\(UUID().uuidString)"
+
+ var onConnectionState: (@Sendable (RTCPeerConnectionState) -> Void)?
+ var onVideoSize: (@Sendable (CGSize) -> Void)?
+ var onMessage: (@Sendable (String) -> Void)?
+ var onDiagnostics: (@Sendable (StreamDiagnostics) -> Void)?
+ var onReconnectNeeded: (@Sendable (String) -> Void)?
+
+ private static let initializeSSL: Void = {
+ RTCInitializeSSL()
+ }()
+
+ private let factory: RTCPeerConnectionFactory
+ private var peerConnection: RTCPeerConnection?
+ private var controlChannel: RTCDataChannel?
+ private var telemetryChannel: RTCDataChannel?
+ private var remoteTrack: RTCVideoTrack?
+ private var renderers: [any RTCVideoRenderer] = []
+ private var pendingControlMessages: [Data] = []
+ private var keepAliveTask: Task?
+ private var statsTask: Task?
+ private var renderWatchdogTask: Task?
+ private var activeSimulatorID: String?
+ private var peerConnectionState = "new"
+ private var iceConnectionState = "new"
+ private var iceGatheringState = "new"
+ private var signalingState = "stable"
+ private var lastDecodedFrames: UInt64 = 0
+ private var lastDecodedFrameAt = Date()
+ private var lastPacketsReceived: UInt64 = 0
+ private var lastPacketReceivedAt = Date()
+ private var lastStatsSampleAt: Date?
+ private var lastStatsDecodedFrames: UInt64 = 0
+ private var lastStatsRenderedFrames: UInt64 = 0
+ private var lastStatsPacketsReceived: UInt64 = 0
+ private var lastStallRecoveryAt = Date.distantPast
+ private var lastReconnectRequestedAt = Date.distantPast
+ private var lastUserActivityAt = Date.distantPast
+ private var renderedFrameCount: UInt64 = 0
+ private var lastRenderedFrameAt = Date()
+ private var isAppForeground = true
+ private var isDisconnecting = false
+
+ override init() {
+ _ = Self.initializeSSL
+ factory = RTCPeerConnectionFactory(
+ encoderFactory: RTCDefaultVideoEncoderFactory(),
+ decoderFactory: RTCDefaultVideoDecoderFactory()
+ )
+ super.init()
+ }
+
+ func connect(
+ api: SimDeckAPI,
+ simulatorID: String,
+ health: HealthResponse,
+ streamConfig: StreamConfig
+ ) async throws -> WebRTCAnswerPayload {
+ disconnect()
+ isDisconnecting = false
+
+ let configuration = RTCConfiguration()
+ configuration.sdpSemantics = .unifiedPlan
+ configuration.bundlePolicy = .maxBundle
+ configuration.rtcpMuxPolicy = .require
+ configuration.continualGatheringPolicy = .gatherContinually
+ configuration.tcpCandidatePolicy = .disabled
+ configuration.enableDscp = true
+ configuration.rtcpVideoReportIntervalMs = 250
+ configuration.iceServers = iceServers(from: health)
+ if health.webRtc?.iceTransportPolicy?.lowercased() == "relay" {
+ configuration.iceTransportPolicy = .relay
+ } else {
+ configuration.iceTransportPolicy = .all
+ }
+
+ let constraints = RTCMediaConstraints(
+ mandatoryConstraints: nil,
+ optionalConstraints: ["DtlsSrtpKeyAgreement": "true"]
+ )
+ guard let peerConnection = factory.peerConnection(with: configuration, constraints: constraints, delegate: self) else {
+ throw SimDeckAPIError.invalidResponse
+ }
+ self.peerConnection = peerConnection
+
+ let transceiverInit = RTCRtpTransceiverInit()
+ transceiverInit.direction = .recvOnly
+ _ = peerConnection.addTransceiver(of: .video, init: transceiverInit)
+
+ let controlConfig = RTCDataChannelConfiguration()
+ controlConfig.isOrdered = true
+ controlChannel = peerConnection.dataChannel(forLabel: "simdeck-control", configuration: controlConfig)
+ controlChannel?.delegate = self
+
+ let telemetryConfig = RTCDataChannelConfiguration()
+ telemetryConfig.isOrdered = false
+ telemetryConfig.maxRetransmits = 0
+ telemetryChannel = peerConnection.dataChannel(forLabel: "simdeck-telemetry", configuration: telemetryConfig)
+ telemetryChannel?.delegate = self
+
+ let offer = try await offer(for: peerConnection)
+ try await setLocalDescription(offer, on: peerConnection)
+ await waitForIceGathering(on: peerConnection, timeout: api.baseURL.isLoopbackOrLocal ? 0.35 : 3.0)
+
+ guard let localDescription = peerConnection.localDescription else {
+ throw SimDeckAPIError.invalidResponse
+ }
+ let payload = WebRTCOfferPayload(
+ clientId: clientID,
+ sdp: localDescription.sdp,
+ streamConfig: StreamQualityPayload(config: streamConfig),
+ type: "offer"
+ )
+ let answer = try await api.postWebRTCOffer(payload, udid: simulatorID)
+ try await setRemoteDescription(
+ RTCSessionDescription(type: .answer, sdp: answer.sdp),
+ on: peerConnection
+ )
+ activeSimulatorID = simulatorID
+ lastDecodedFrames = 0
+ lastPacketsReceived = 0
+ lastDecodedFrameAt = Date()
+ lastPacketReceivedAt = Date()
+ lastStatsSampleAt = nil
+ lastStatsDecodedFrames = 0
+ lastStatsRenderedFrames = 0
+ lastStatsPacketsReceived = 0
+ renderedFrameCount = 0
+ lastRenderedFrameAt = Date()
+ lastStallRecoveryAt = .distantPast
+ lastReconnectRequestedAt = .distantPast
+ lastUserActivityAt = .distantPast
+ isAppForeground = true
+ sendPageVisibilityStats(visible: true, simulatorID: simulatorID)
+ sendStreamControl(foreground: true, forceKeyframe: true, snapshot: true)
+ startKeepAlive()
+ startStatsReporting(simulatorID: simulatorID)
+ startRenderWatchdog()
+ return answer
+ }
+
+ func attachRenderer(_ renderer: any RTCVideoRenderer) {
+ let rendererObject = renderer as AnyObject
+ if renderers.contains(where: { ($0 as AnyObject) === rendererObject }) {
+ return
+ }
+ renderers.append(renderer)
+ remoteTrack?.add(renderer)
+ }
+
+ func detachRenderer(_ renderer: any RTCVideoRenderer) {
+ remoteTrack?.remove(renderer)
+ let rendererObject = renderer as AnyObject
+ renderers.removeAll { ($0 as AnyObject) === rendererObject }
+ }
+
+ func disconnect() {
+ isDisconnecting = true
+ keepAliveTask?.cancel()
+ keepAliveTask = nil
+ statsTask?.cancel()
+ statsTask = nil
+ renderWatchdogTask?.cancel()
+ renderWatchdogTask = nil
+ if let activeSimulatorID {
+ sendPageVisibilityStats(visible: false, simulatorID: activeSimulatorID)
+ }
+ sendStreamControl(foreground: false, forceKeyframe: false, snapshot: false, allowQueue: false)
+ activeSimulatorID = nil
+ for renderer in renderers {
+ remoteTrack?.remove(renderer)
+ }
+ renderers.removeAll()
+ remoteTrack = nil
+ controlChannel?.close()
+ telemetryChannel?.close()
+ controlChannel = nil
+ telemetryChannel = nil
+ pendingControlMessages.removeAll()
+ peerConnection?.close()
+ peerConnection = nil
+ }
+
+ func sendTouch(x: Double, y: Double, phase: String) {
+ markUserActivity()
+ sendJSON(["type": "touch", "x": x, "y": y, "phase": phase])
+ }
+
+ func sendEdgeTouch(x: Double, y: Double, phase: String, edge: String) {
+ markUserActivity()
+ sendJSON(["type": "edgeTouch", "x": x, "y": y, "phase": phase, "edge": edge])
+ }
+
+ @discardableResult
+ func sendKey(keyCode: Int, modifiers: Int) -> Bool {
+ markUserActivity()
+ return sendJSON([
+ "type": "key",
+ "keyCode": keyCode,
+ "modifiers": modifiers
+ ], allowQueue: false)
+ }
+
+ @discardableResult
+ func dismissSimulatorKeyboard() -> Bool {
+ markUserActivity()
+ return sendJSON(["type": "dismissKeyboard"], allowQueue: false)
+ }
+
+ func sendHome() {
+ markUserActivity()
+ sendJSON(["type": "home"])
+ }
+
+ func sendAppSwitcher() {
+ markUserActivity()
+ sendJSON(["type": "appSwitcher"])
+ }
+
+ func sendRotateLeft() {
+ markUserActivity()
+ sendJSON(["type": "rotateLeft"])
+ }
+
+ func sendRotateRight() {
+ markUserActivity()
+ sendJSON(["type": "rotateRight"])
+ }
+
+ @discardableResult
+ func sendToggleAppearance() -> Bool {
+ markUserActivity()
+ return sendJSON(["type": "toggleAppearance"], allowQueue: false)
+ }
+
+ func sendLock() {
+ markUserActivity()
+ pressHardwareButton(button: "power", durationMs: 80)
+ }
+
+ @discardableResult
+ func sendHardwareButton(button: String, phase: String, usagePage: Int?, usage: Int?) -> Bool {
+ markUserActivity()
+ var payload: [String: Any] = [
+ "type": "button",
+ "button": button,
+ "phase": phase
+ ]
+ if let usagePage {
+ payload["usagePage"] = usagePage
+ }
+ if let usage {
+ payload["usage"] = usage
+ }
+ return sendJSON(payload, allowQueue: false)
+ }
+
+ @discardableResult
+ func pressHardwareButton(button: String, durationMs: Int = 80, usagePage: Int? = nil, usage: Int? = nil) -> Bool {
+ markUserActivity()
+ var payload: [String: Any] = [
+ "type": "button",
+ "button": button,
+ "durationMs": durationMs
+ ]
+ if let usagePage {
+ payload["usagePage"] = usagePage
+ }
+ if let usage {
+ payload["usage"] = usage
+ }
+ return sendJSON(payload, allowQueue: false)
+ }
+
+ func requestKeyframe() {
+ markUserActivity()
+ sendStreamControl(foreground: true, forceKeyframe: true, snapshot: true)
+ }
+
+ func applyStreamQuality(_ config: StreamConfig) {
+ sendJSON([
+ "type": "streamQuality",
+ "config": StreamQualityPayload(config: config).jsonObject
+ ])
+ sendStreamControl(foreground: true, forceKeyframe: true, snapshot: true)
+ }
+
+ func recordRenderedFrame(_ frame: RTCVideoFrame?) {
+ guard frame != nil else { return }
+ renderedFrameCount += 1
+ lastRenderedFrameAt = Date()
+ }
+
+ func appDidBecomeActive() {
+ isAppForeground = true
+ startKeepAlive()
+ startRenderWatchdog()
+ if let activeSimulatorID {
+ sendPageVisibilityStats(visible: true, simulatorID: activeSimulatorID)
+ }
+ sendStreamControl(foreground: true, forceKeyframe: true, snapshot: true, allowQueue: false)
+ let now = Date()
+ let staleFrameGap = now.timeIntervalSince(max(lastRenderedFrameAt, lastDecodedFrameAt))
+ if peerConnectionState != "connected" || staleFrameGap > 4 {
+ requestReconnect(reason: "foreground-resume")
+ }
+ }
+
+ func appDidEnterBackground() {
+ isAppForeground = false
+ keepAliveTask?.cancel()
+ keepAliveTask = nil
+ if let activeSimulatorID {
+ sendPageVisibilityStats(visible: false, simulatorID: activeSimulatorID)
+ }
+ sendStreamControl(foreground: false, forceKeyframe: false, snapshot: false, allowQueue: false)
+ }
+
+ private func sendStreamControl(
+ foreground: Bool,
+ forceKeyframe: Bool,
+ snapshot: Bool,
+ allowQueue: Bool = true
+ ) {
+ sendJSON([
+ "type": "streamControl",
+ "clientId": clientID,
+ "foreground": foreground,
+ "forceKeyframe": forceKeyframe,
+ "snapshot": snapshot
+ ], allowQueue: allowQueue)
+ }
+
+ private func markUserActivity() {
+ lastUserActivityAt = Date()
+ }
+
+ @discardableResult
+ private func sendJSON(_ object: [String: Any], allowQueue: Bool = true) -> Bool {
+ guard let data = try? JSONSerialization.data(withJSONObject: object) else { return false }
+ let isMove = Self.isMoveControlMessage(object)
+ if isMove, let controlChannel, controlChannel.readyState == .open, controlChannel.bufferedAmount > 128_000 {
+ return false
+ }
+ if sendControlData(data) {
+ return true
+ }
+ guard allowQueue, !isMove else { return false }
+ pendingControlMessages.append(data)
+ if pendingControlMessages.count > 80 {
+ pendingControlMessages.removeFirst(pendingControlMessages.count - 80)
+ }
+ return false
+ }
+
+ private func requestReconnect(reason: String) {
+ guard isAppForeground, !isDisconnecting else { return }
+ let now = Date()
+ guard now.timeIntervalSince(lastReconnectRequestedAt) > 5 else { return }
+ lastReconnectRequestedAt = now
+ onReconnectNeeded?(reason)
+ }
+
+ private func sendTelemetryJSON(_ object: [String: Any]) {
+ guard telemetryChannel?.readyState == .open,
+ let data = try? JSONSerialization.data(withJSONObject: object) else {
+ return
+ }
+ let buffer = RTCDataBuffer(data: data, isBinary: false)
+ _ = telemetryChannel?.sendData(buffer)
+ }
+
+ @discardableResult
+ private func sendControlData(_ data: Data) -> Bool {
+ guard controlChannel?.readyState == .open else {
+ return false
+ }
+ let buffer = RTCDataBuffer(data: data, isBinary: false)
+ return controlChannel?.sendData(buffer) ?? false
+ }
+
+ private func flushPendingControlMessages() {
+ guard controlChannel?.readyState == .open else { return }
+ let queued = pendingControlMessages
+ pendingControlMessages.removeAll()
+ for data in queued {
+ _ = sendControlData(data)
+ }
+ }
+
+ private func startKeepAlive() {
+ keepAliveTask?.cancel()
+ keepAliveTask = Task { [weak self] in
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(2))
+ guard !Task.isCancelled else { return }
+ self?.sendStreamControl(
+ foreground: true,
+ forceKeyframe: false,
+ snapshot: false
+ )
+ }
+ }
+ }
+
+ private func startStatsReporting(simulatorID: String) {
+ statsTask?.cancel()
+ statsTask = Task { [weak self] in
+ while !Task.isCancelled {
+ self?.collectStats(simulatorID: simulatorID)
+ try? await Task.sleep(for: .seconds(2))
+ }
+ }
+ }
+
+ private func startRenderWatchdog() {
+ renderWatchdogTask?.cancel()
+ renderWatchdogTask = Task { [weak self] in
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .milliseconds(750))
+ guard let self, !Task.isCancelled, self.peerConnection != nil, self.isAppForeground else { return }
+ let now = Date()
+ let packetGap = now.timeIntervalSince(self.lastPacketReceivedAt)
+ let renderedGap = now.timeIntervalSince(self.lastRenderedFrameAt)
+ let decodedGap = now.timeIntervalSince(self.lastDecodedFrameAt)
+ let noFirstFrame = self.renderedFrameCount == 0 && renderedGap > 1.5
+ let hardStall = max(packetGap, max(renderedGap, decodedGap)) > 4
+ if hardStall {
+ self.requestReconnect(reason: "stream-stalled")
+ continue
+ }
+ let recentUserActivity = now.timeIntervalSince(self.lastUserActivityAt) < 8
+ let activeStall = recentUserActivity && (packetGap > 1.5 || max(renderedGap, decodedGap) > 2)
+ guard (noFirstFrame || activeStall),
+ now.timeIntervalSince(self.lastStallRecoveryAt) > 3 else {
+ continue
+ }
+ self.lastStallRecoveryAt = now
+ self.sendStreamControl(foreground: true, forceKeyframe: true, snapshot: true)
+ }
+ }
+ }
+
+ private func collectStats(simulatorID: String) {
+ guard let peerConnection else { return }
+ peerConnection.statistics { [weak self] report in
+ guard let self else { return }
+ let now = Date()
+ var inboundVideo: RTCStatistics?
+ var selectedPair: RTCStatistics?
+ var codecsByID: [String: RTCStatistics] = [:]
+
+ for (_, statistic) in report.statistics {
+ if statistic.type == "codec" {
+ codecsByID[statistic.id] = statistic
+ }
+ if statistic.type == "inbound-rtp", statistic.mediaKind == "video" {
+ inboundVideo = statistic
+ }
+ if statistic.type == "candidate-pair",
+ statistic.stringValue("state") == "succeeded",
+ statistic.boolValue("nominated") == true {
+ selectedPair = statistic
+ }
+ }
+
+ var stats: [String: Any] = [
+ "clientId": self.clientID,
+ "kind": "webrtc",
+ "timestampMs": now.timeIntervalSince1970 * 1000,
+ "udid": simulatorID,
+ "status": self.peerConnectionState,
+ "detail": "receiver-stats",
+ "peerConnectionState": self.peerConnectionState,
+ "iceConnectionState": self.iceConnectionState,
+ "iceGatheringState": self.iceGatheringState,
+ "signalingState": self.signalingState,
+ "clientBundle": Bundle.main.bundleIdentifier ?? "dev.dj.simdeck.studio",
+ "userAgent": "SimDeck Studio iOS"
+ ]
+
+ if let inboundVideo {
+ let codecID = inboundVideo.stringValue("codecId")
+ let codec = codecID.flatMap { codecsByID[$0]?.stringValue("mimeType") }
+ let receivedPackets = inboundVideo.uintValue("packetsReceived") ?? 0
+ let packetsLost = inboundVideo.uintValue("packetsLost") ?? 0
+ let decodedFrames = inboundVideo.uintValue("framesDecoded") ?? inboundVideo.uintValue("framesReceived") ?? 0
+ let droppedFrames = inboundVideo.uintValue("framesDropped") ?? 0
+ if receivedPackets > self.lastPacketsReceived {
+ self.lastPacketsReceived = receivedPackets
+ self.lastPacketReceivedAt = now
+ }
+ if decodedFrames > self.lastDecodedFrames {
+ self.lastDecodedFrames = decodedFrames
+ self.lastDecodedFrameAt = now
+ }
+ let latestPacketGapMs = now.timeIntervalSince(self.lastPacketReceivedAt) * 1000
+ let latestDecodedFrameGapMs = now.timeIntervalSince(self.lastDecodedFrameAt) * 1000
+ let latestRenderedFrameGapMs = now.timeIntervalSince(self.lastRenderedFrameAt) * 1000
+ let latestFrameGapMs = max(latestDecodedFrameGapMs, latestRenderedFrameGapMs)
+ stats["codec"] = codec ?? "video"
+ stats["receivedPackets"] = receivedPackets
+ stats["packetsLost"] = packetsLost
+ stats["decodedFrames"] = decodedFrames
+ stats["decoderDroppedFrames"] = droppedFrames
+ stats["droppedFrames"] = droppedFrames
+ stats["latestPacketGapMs"] = latestPacketGapMs
+ stats["latestFrameGapMs"] = latestFrameGapMs
+ let recentUserActivity = now.timeIntervalSince(self.lastUserActivityAt) < 8
+ let activeStall = recentUserActivity && (latestPacketGapMs > 1_500 || latestFrameGapMs > 2_000)
+ let noFirstFrame = self.renderedFrameCount == 0 && latestFrameGapMs > 1_500
+ if (noFirstFrame || activeStall),
+ now.timeIntervalSince(self.lastStallRecoveryAt) > 3 {
+ self.lastStallRecoveryAt = now
+ self.sendStreamControl(foreground: true, forceKeyframe: true, snapshot: true)
+ }
+ if let width = inboundVideo.uintValue("frameWidth") {
+ stats["width"] = width
+ }
+ if let height = inboundVideo.uintValue("frameHeight") {
+ stats["height"] = height
+ }
+ stats["renderedFrames"] = self.renderedFrameCount
+ if let decodedFps = inboundVideo.doubleValue("framesPerSecond") {
+ stats["decodedFps"] = decodedFps
+ }
+ if let previousSampleAt = self.lastStatsSampleAt {
+ let elapsed = now.timeIntervalSince(previousSampleAt)
+ if elapsed > 0 {
+ stats["packetFps"] = Double(receivedPackets.saturatingDelta(from: self.lastStatsPacketsReceived)) / elapsed
+ stats["decodedFps"] = Double(decodedFrames.saturatingDelta(from: self.lastStatsDecodedFrames)) / elapsed
+ stats["appFps"] = Double(self.renderedFrameCount.saturatingDelta(from: self.lastStatsRenderedFrames)) / elapsed
+ }
+ }
+ self.lastStatsSampleAt = now
+ self.lastStatsPacketsReceived = receivedPackets
+ self.lastStatsDecodedFrames = decodedFrames
+ self.lastStatsRenderedFrames = self.renderedFrameCount
+ }
+ if let selectedPair {
+ let local = selectedPair.stringValue("localCandidateId") ?? "local"
+ let remote = selectedPair.stringValue("remoteCandidateId") ?? "remote"
+ stats["selectedCandidatePair"] = "\(local) -> \(remote)"
+ }
+
+ self.onDiagnostics?(StreamDiagnostics(stats: stats))
+ self.sendTelemetryJSON(["type": "clientStats", "stats": stats])
+ }
+ }
+
+ private static func isMoveControlMessage(_ object: [String: Any]) -> Bool {
+ guard let type = object["type"] as? String,
+ let phase = object["phase"] as? String else {
+ return false
+ }
+ return phase == "moved" && (type == "touch" || type == "edgeTouch" || type == "multiTouch")
+ }
+
+ private func sendPageVisibilityStats(visible: Bool, simulatorID: String) {
+ sendTelemetryJSON([
+ "type": "clientStats",
+ "stats": [
+ "clientId": clientID,
+ "kind": "page",
+ "timestampMs": Date().timeIntervalSince1970 * 1000,
+ "udid": simulatorID,
+ "visibilityState": visible ? "visible" : "hidden",
+ "focused": visible
+ ]
+ ])
+ }
+
+ private func iceServers(from health: HealthResponse) -> [RTCIceServer] {
+ let servers = health.webRtc?.iceServers ?? [IceServer(urls: ["stun:stun.l.google.com:19302"])]
+ return servers.map { server in
+ RTCIceServer(
+ urlStrings: server.urls,
+ username: server.username,
+ credential: server.credential
+ )
+ }
+ }
+
+ private func offer(for peerConnection: RTCPeerConnection) async throws -> RTCSessionDescription {
+ let constraints = RTCMediaConstraints(
+ mandatoryConstraints: [
+ "OfferToReceiveVideo": "true",
+ "OfferToReceiveAudio": "false"
+ ],
+ optionalConstraints: nil
+ )
+ return try await withCheckedThrowingContinuation { continuation in
+ peerConnection.offer(for: constraints) { description, error in
+ if let error {
+ continuation.resume(throwing: error)
+ } else if let description {
+ continuation.resume(returning: description)
+ } else {
+ continuation.resume(throwing: SimDeckAPIError.invalidResponse)
+ }
+ }
+ }
+ }
+
+ private func setLocalDescription(_ description: RTCSessionDescription, on peerConnection: RTCPeerConnection) async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ peerConnection.setLocalDescription(description) { error in
+ if let error {
+ continuation.resume(throwing: error)
+ } else {
+ continuation.resume()
+ }
+ }
+ }
+ }
+
+ private func setRemoteDescription(_ description: RTCSessionDescription, on peerConnection: RTCPeerConnection) async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ peerConnection.setRemoteDescription(description) { error in
+ if let error {
+ continuation.resume(throwing: error)
+ } else {
+ continuation.resume()
+ }
+ }
+ }
+ }
+
+ private func waitForIceGathering(on peerConnection: RTCPeerConnection, timeout: TimeInterval) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while peerConnection.iceGatheringState != .complete && Date() < deadline {
+ try? await Task.sleep(for: .milliseconds(50))
+ }
+ }
+
+ private func attachRemoteTrack(_ track: RTCVideoTrack) {
+ for renderer in renderers {
+ remoteTrack?.remove(renderer)
+ track.add(renderer)
+ }
+ remoteTrack = track
+ }
+}
+
+extension WebRTCClient: RTCPeerConnectionDelegate {
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {
+ signalingState = stateChanged.statsLabel
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
+ if let track = stream.videoTracks.first {
+ attachRemoteTrack(track)
+ }
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
+
+ func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
+ iceConnectionState = newState.statsLabel
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {
+ iceGatheringState = newState.statsLabel
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {}
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {
+ dataChannel.delegate = self
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) {
+ peerConnectionState = newState.statsLabel
+ onConnectionState?(newState)
+ switch newState {
+ case .failed, .closed:
+ requestReconnect(reason: "peer-\(newState.statsLabel)")
+ case .disconnected:
+ Task { [weak self] in
+ try? await Task.sleep(for: .seconds(2))
+ guard let self, self.peerConnectionState == "disconnected" else { return }
+ self.requestReconnect(reason: "peer-disconnected")
+ }
+ default:
+ break
+ }
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didAdd rtpReceiver: RTCRtpReceiver, streams mediaStreams: [RTCMediaStream]) {
+ if let track = rtpReceiver.track as? RTCVideoTrack {
+ attachRemoteTrack(track)
+ }
+ }
+}
+
+private extension RTCStatistics {
+ var mediaKind: String? {
+ stringValue("kind") ?? stringValue("mediaType")
+ }
+
+ func stringValue(_ key: String) -> String? {
+ values[key] as? String
+ }
+
+ func boolValue(_ key: String) -> Bool? {
+ if let value = values[key] as? NSNumber {
+ return value.boolValue
+ }
+ return values[key] as? Bool
+ }
+
+ func uintValue(_ key: String) -> UInt64? {
+ if let value = values[key] as? NSNumber {
+ return value.uint64Value
+ }
+ return nil
+ }
+
+ func doubleValue(_ key: String) -> Double? {
+ if let value = values[key] as? NSNumber {
+ return value.doubleValue
+ }
+ return nil
+ }
+}
+
+private extension UInt64 {
+ func saturatingDelta(from previous: UInt64) -> UInt64 {
+ self >= previous ? self - previous : 0
+ }
+}
+
+private extension RTCPeerConnectionState {
+ var statsLabel: String {
+ switch self {
+ case .new: "new"
+ case .connecting: "connecting"
+ case .connected: "connected"
+ case .disconnected: "disconnected"
+ case .failed: "failed"
+ case .closed: "closed"
+ @unknown default: "unknown"
+ }
+ }
+}
+
+private extension RTCIceConnectionState {
+ var statsLabel: String {
+ switch self {
+ case .new: "new"
+ case .checking: "checking"
+ case .connected: "connected"
+ case .completed: "completed"
+ case .failed: "failed"
+ case .disconnected: "disconnected"
+ case .closed: "closed"
+ case .count: "count"
+ @unknown default: "unknown"
+ }
+ }
+}
+
+private extension RTCIceGatheringState {
+ var statsLabel: String {
+ switch self {
+ case .new: "new"
+ case .gathering: "gathering"
+ case .complete: "complete"
+ @unknown default: "unknown"
+ }
+ }
+}
+
+private extension RTCSignalingState {
+ var statsLabel: String {
+ switch self {
+ case .stable: "stable"
+ case .haveLocalOffer: "have-local-offer"
+ case .haveLocalPrAnswer: "have-local-pranswer"
+ case .haveRemoteOffer: "have-remote-offer"
+ case .haveRemotePrAnswer: "have-remote-pranswer"
+ case .closed: "closed"
+ @unknown default: "unknown"
+ }
+ }
+}
+
+extension WebRTCClient: RTCDataChannelDelegate {
+ func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) {
+ if dataChannel.readyState == .open, dataChannel.label == "simdeck-control" {
+ flushPendingControlMessages()
+ sendStreamControl(foreground: true, forceKeyframe: true, snapshot: true)
+ startKeepAlive()
+ }
+ }
+
+ func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
+ guard !buffer.isBinary, let text = String(data: buffer.data, encoding: .utf8) else { return }
+ onMessage?(text)
+ }
+}
+
+extension URL {
+ var isLoopbackOrLocal: Bool {
+ guard let host = host(percentEncoded: false)?.lowercased() else { return false }
+ return host == "localhost" || host == "127.0.0.1" || host == "::1" || host.hasSuffix(".local")
+ }
+}
diff --git a/ios/SimDeckStudio/Streaming/WebRTCVideoView.swift b/ios/SimDeckStudio/Streaming/WebRTCVideoView.swift
new file mode 100644
index 00000000..b9e832a1
--- /dev/null
+++ b/ios/SimDeckStudio/Streaming/WebRTCVideoView.swift
@@ -0,0 +1,154 @@
+import CoreImage
+import SwiftUI
+import UIKit
+@preconcurrency import WebRTC
+
+struct WebRTCVideoView: UIViewRepresentable {
+ let client: WebRTCClient?
+ let onVideoSize: (CGSize) -> Void
+ let onFrameRendered: () -> Void
+ let onFrameSnapshot: (UIImage) -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(
+ client: client,
+ onVideoSize: onVideoSize,
+ onFrameRendered: onFrameRendered,
+ onFrameSnapshot: onFrameSnapshot
+ )
+ }
+
+ func makeUIView(context: Context) -> RTCMTLVideoView {
+ let view = RTCMTLVideoView(frame: .zero)
+ view.videoContentMode = .scaleAspectFit
+ view.backgroundColor = .black
+ context.coordinator.attach(view)
+ return view
+ }
+
+ func updateUIView(_ uiView: RTCMTLVideoView, context: Context) {
+ context.coordinator.onVideoSize = onVideoSize
+ context.coordinator.onFrameRendered = onFrameRendered
+ context.coordinator.onFrameSnapshot = onFrameSnapshot
+ if context.coordinator.client !== client {
+ context.coordinator.detach(uiView)
+ context.coordinator.client = client
+ context.coordinator.attach(uiView)
+ }
+ }
+
+ static func dismantleUIView(_ uiView: RTCMTLVideoView, coordinator: Coordinator) {
+ coordinator.detach(uiView)
+ }
+
+ final class Coordinator: NSObject, RTCVideoRenderer, RTCVideoViewDelegate {
+ var client: WebRTCClient?
+ var onVideoSize: (CGSize) -> Void
+ var onFrameRendered: () -> Void
+ var onFrameSnapshot: (UIImage) -> Void
+ private var lastReportedSize = CGSize.zero
+ private var hasReportedRenderedFrame = false
+ private var lastSnapshotAt = Date.distantPast
+ private static let snapshotInterval: TimeInterval = 0.75
+ private static let snapshotContext = CIContext(options: [.priorityRequestLow: true])
+
+ init(
+ client: WebRTCClient?,
+ onVideoSize: @escaping (CGSize) -> Void,
+ onFrameRendered: @escaping () -> Void,
+ onFrameSnapshot: @escaping (UIImage) -> Void
+ ) {
+ self.client = client
+ self.onVideoSize = onVideoSize
+ self.onFrameRendered = onFrameRendered
+ self.onFrameSnapshot = onFrameSnapshot
+ }
+
+ func attach(_ view: RTCMTLVideoView) {
+ view.delegate = self
+ client?.attachRenderer(view)
+ client?.attachRenderer(self)
+ }
+
+ func detach(_ view: RTCMTLVideoView) {
+ view.delegate = nil
+ client?.detachRenderer(view)
+ client?.detachRenderer(self)
+ }
+
+ func setSize(_ size: CGSize) {
+ reportVideoSize(size)
+ }
+
+ func renderFrame(_ frame: RTCVideoFrame?) {
+ client?.recordRenderedFrame(frame)
+ guard let frame else { return }
+ if !hasReportedRenderedFrame {
+ hasReportedRenderedFrame = true
+ Task { @MainActor in
+ onFrameRendered()
+ }
+ }
+ captureSnapshotIfNeeded(from: frame)
+ }
+
+ func videoView(_ videoView: any RTCVideoRenderer, didChangeVideoSize size: CGSize) {
+ reportVideoSize(size)
+ }
+
+ private func reportVideoSize(_ size: CGSize) {
+ guard size != lastReportedSize else { return }
+ lastReportedSize = size
+ Task { @MainActor in
+ onVideoSize(size)
+ }
+ }
+
+ private func captureSnapshotIfNeeded(from frame: RTCVideoFrame) {
+ let now = Date()
+ guard now.timeIntervalSince(lastSnapshotAt) >= Self.snapshotInterval else { return }
+ lastSnapshotAt = now
+
+ guard let image = Self.image(from: frame) else { return }
+ Task { @MainActor in
+ onFrameSnapshot(image)
+ }
+ }
+
+ private static func image(from frame: RTCVideoFrame) -> UIImage? {
+ guard let buffer = frame.buffer as? RTCCVPixelBuffer else { return nil }
+ var image = CIImage(cvPixelBuffer: buffer.pixelBuffer)
+ if buffer.requiresCropping() {
+ image = image.cropped(to: CGRect(
+ x: CGFloat(buffer.cropX),
+ y: CGFloat(buffer.cropY),
+ width: CGFloat(buffer.cropWidth),
+ height: CGFloat(buffer.cropHeight)
+ ))
+ }
+ guard let cgImage = snapshotContext.createCGImage(image, from: image.extent) else { return nil }
+ return UIImage(cgImage: cgImage, scale: 1, orientation: frame.uiImageOrientation)
+ }
+ }
+}
+
+private extension RTCVideoRotation {
+ var uiImageOrientation: UIImage.Orientation {
+ switch rawValue {
+ case 90:
+ return .right
+ case 180:
+ return .down
+ case 270:
+ return .left
+ default:
+ return .up
+ }
+ }
+}
+
+private extension RTCVideoFrame {
+ var uiImageOrientation: UIImage.Orientation {
+ rotation.uiImageOrientation
+ }
+}
diff --git a/ios/SimDeckStudio/Views/ContentView.swift b/ios/SimDeckStudio/Views/ContentView.swift
new file mode 100644
index 00000000..56706c72
--- /dev/null
+++ b/ios/SimDeckStudio/Views/ContentView.swift
@@ -0,0 +1,1293 @@
+@preconcurrency import AVFoundation
+import SwiftUI
+
+struct ContentView: View {
+ @Bindable var model: AppModel
+ @State private var searchText = ""
+ @State private var searchExpanded = false
+
+ var body: some View {
+ navigationContent(usesSearchAccessory: true)
+ .task {
+ model.start()
+ }
+ }
+
+ private func navigationContent(usesSearchAccessory: Bool) -> some View {
+ NavigationSplitView {
+ SidebarView(
+ model: model,
+ searchText: $searchText,
+ searchExpanded: $searchExpanded,
+ usesSearchAccessory: usesSearchAccessory
+ )
+ } detail: {
+ SimulatorStreamView(model: model)
+ }
+ }
+}
+
+private struct SidebarView: View {
+ @Bindable var model: AppModel
+ @Binding var searchText: String
+ @Binding var searchExpanded: Bool
+ let usesSearchAccessory: Bool
+ @State private var presentedSheet: SidebarSheet?
+
+ private var filteredSimulators: [SimulatorMetadata] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !query.isEmpty else { return model.simulators }
+ return model.simulators.filter { simulator in
+ simulator.name.localizedCaseInsensitiveContains(query)
+ || simulator.subtitle.localizedCaseInsensitiveContains(query)
+ || simulator.udid.localizedCaseInsensitiveContains(query)
+ }
+ }
+
+ var body: some View {
+ sidebarContent
+ .sheet(item: $presentedSheet) { sheet in
+ switch sheet {
+ case .servers:
+ ServerSelectionSheet(model: model)
+ case .connect:
+ ConnectServerSheet(model: model)
+ case .pair:
+ PairServerSheet(model: model)
+ case .settings:
+ SettingsSheet(model: model)
+ case .newSimulator:
+ NewSimulatorSheet(model: model)
+ }
+ }
+ .onChange(of: model.authEndpoint?.id) { _, endpointID in
+ if endpointID != nil {
+ presentedSheet = .pair
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var sidebarContent: some View {
+ if usesSearchAccessory {
+ sidebarList
+ .safeAreaInset(edge: .bottom, spacing: 0) {
+ SimulatorSearchDock(
+ model: model,
+ text: $searchText,
+ isExpanded: $searchExpanded
+ ) {
+ presentedSheet = .newSimulator
+ }
+ }
+ } else {
+ sidebarList
+ }
+ }
+
+ private var sidebarList: some View {
+ List(selection: simulatorSelection) {
+ ForEach(filteredSimulators) { simulator in
+ SimulatorRow(simulator: simulator)
+ .tag(simulator.udid)
+ }
+ }
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ .overlay {
+ if model.isBusy && model.simulators.isEmpty {
+ ProgressView()
+ } else if model.endpoint == nil {
+ ContentUnavailableView("Select a Server", systemImage: "server.rack")
+ } else if model.authEndpoint != nil {
+ VStack(spacing: 16) {
+ ContentUnavailableView("Pair Server", systemImage: "lock")
+ Button {
+ presentedSheet = .pair
+ } label: {
+ Label("Pair", systemImage: "checkmark.seal")
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ } else if filteredSimulators.isEmpty {
+ ContentUnavailableView(
+ searchText.isEmpty ? "No Simulators" : "No Results",
+ systemImage: searchText.isEmpty ? "iphone.slash" : "magnifyingglass"
+ )
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button {
+ model.hapticSelection()
+ presentedSheet = .settings
+ } label: {
+ Label("Settings", systemImage: "gearshape")
+ }
+ }
+ ToolbarItem(placement: .principal) {
+ ServerTitleButton(model: model) {
+ model.hapticSelection()
+ presentedSheet = .servers
+ }
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ model.hapticSelection()
+ presentedSheet = .connect
+ } label: {
+ Label("Connect", systemImage: "personalhotspot")
+ }
+ }
+ }
+ }
+
+ private var simulatorSelection: Binding {
+ Binding {
+ model.selectedSimulatorID
+ } set: { udid in
+ model.selectSimulator(udid)
+ }
+ }
+}
+
+private enum SidebarSheet: Identifiable {
+ case servers
+ case connect
+ case pair
+ case settings
+ case newSimulator
+
+ var id: Self { self }
+}
+
+private struct ServerTitleButton: View {
+ @Bindable var model: AppModel
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 10) {
+ Circle()
+ .fill(titleColor)
+ .frame(width: 7, height: 7)
+ Spacer(minLength: 4)
+ VStack(alignment: .center, spacing: 1) {
+ Text(model.selectedEndpointTitle)
+ .font(.headline)
+ .lineLimit(1)
+ .multilineTextAlignment(.center)
+ Text(model.selectedEndpointSubtitle)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .multilineTextAlignment(.center)
+ }
+ Spacer(minLength: 4)
+ Image(systemName: "chevron.down")
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.leading, 14)
+ .padding(.trailing, 12)
+ .padding(.vertical, 5)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .frame(minWidth: 170, maxWidth: 240)
+ .frame(height: 42)
+ .modifier(GlassCapsuleModifier(interactive: true))
+ }
+
+ private var titleColor: Color {
+ if model.authEndpoint != nil {
+ return .orange
+ }
+ return model.endpoint == nil ? .secondary : .green
+ }
+}
+
+private struct ServerSelectionSheet: View {
+ @Bindable var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var renamingEndpoint: SimDeckEndpoint?
+ @State private var renameText = ""
+
+ var body: some View {
+ NavigationStack {
+ List {
+ if model.savedEndpoints.isEmpty && model.automaticEndpoints.isEmpty {
+ ContentUnavailableView("No Servers", systemImage: "server.rack")
+ } else {
+ if !model.savedEndpoints.isEmpty {
+ Section("Saved") {
+ ForEach(model.savedEndpoints) { endpoint in
+ serverButton(endpoint, saveEndpoint: false)
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ model.deleteSavedEndpoint(endpoint)
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ Button {
+ beginRenaming(endpoint)
+ } label: {
+ Label("Rename", systemImage: "pencil")
+ }
+ }
+ .contextMenu {
+ Button {
+ beginRenaming(endpoint)
+ } label: {
+ Label("Rename", systemImage: "pencil")
+ }
+ Button(role: .destructive) {
+ model.deleteSavedEndpoint(endpoint)
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ }
+ }
+
+ if !model.automaticEndpoints.isEmpty {
+ Section("Auto-Detected") {
+ ForEach(model.automaticEndpoints) { endpoint in
+ serverButton(endpoint, saveEndpoint: false)
+ }
+ }
+ }
+ }
+ }
+ .alert("Rename Server", isPresented: renameAlertBinding) {
+ TextField("Name", text: $renameText)
+ Button("Cancel", role: .cancel) {
+ renamingEndpoint = nil
+ }
+ Button("Save") {
+ if let renamingEndpoint {
+ model.renameSavedEndpoint(renamingEndpoint, to: renameText)
+ }
+ renamingEndpoint = nil
+ }
+ .disabled(renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ .navigationTitle("SimDeck Servers")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") {
+ model.hapticSelection()
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ model.hapticSelection()
+ model.discovery.refresh()
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .disabled(model.discovery.isScanning)
+ }
+ }
+ }
+ .presentationDetents([.medium, .large])
+ }
+
+ private func serverButton(_ endpoint: SimDeckEndpoint, saveEndpoint: Bool) -> some View {
+ Button {
+ model.hapticSelection()
+ Task {
+ if await model.connect(endpoint, autoStart: false, saveEndpoint: saveEndpoint) {
+ dismiss()
+ }
+ }
+ } label: {
+ HStack(spacing: 12) {
+ EndpointRow(endpoint: endpoint)
+ Spacer()
+ if model.endpoint?.baseURL == endpoint.baseURL {
+ Image(systemName: "checkmark")
+ .font(.headline)
+ .foregroundStyle(.tint)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var renameAlertBinding: Binding {
+ Binding {
+ renamingEndpoint != nil
+ } set: { isPresented in
+ if !isPresented {
+ renamingEndpoint = nil
+ }
+ }
+ }
+
+ private func beginRenaming(_ endpoint: SimDeckEndpoint) {
+ model.hapticSelection()
+ renamingEndpoint = endpoint
+ renameText = endpoint.name
+ }
+}
+
+private struct PairServerSheet: View {
+ @Bindable var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var isScanning = false
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ if let endpoint = model.authEndpoint {
+ Section("Server") {
+ EndpointRow(endpoint: endpoint)
+ }
+ }
+
+ Section("Pair") {
+ TextField("Pairing Code", text: $model.pairingCode)
+ .keyboardType(.numberPad)
+ Button {
+ model.hapticSelection()
+ isScanning = true
+ } label: {
+ Label("Scan QR Code", systemImage: "qrcode.viewfinder")
+ }
+ Button {
+ model.hapticSelection()
+ Task {
+ if await model.pair() {
+ dismiss()
+ }
+ }
+ } label: {
+ Label("Pair", systemImage: "checkmark.seal")
+ }
+ .disabled(model.pairingCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+
+ Section("Token") {
+ SecureField("Token", text: $model.manualToken)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Button {
+ model.hapticSelection()
+ isScanning = true
+ } label: {
+ Label("Scan Pairing QR", systemImage: "qrcode.viewfinder")
+ }
+ Button {
+ model.hapticSelection()
+ Task {
+ if await model.useToken() {
+ dismiss()
+ }
+ }
+ } label: {
+ Label("Use Token", systemImage: "key")
+ }
+ .disabled(model.manualToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+
+ if !model.status.isEmpty {
+ Section {
+ Text(model.status)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle("Pair Server")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") {
+ model.hapticSelection()
+ dismiss()
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $isScanning) {
+ QRCodeScannerSheet(model: model)
+ }
+ .presentationDetents([.medium, .large])
+ }
+}
+
+private struct SettingsSheet: View {
+ @Bindable var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ Toggle("Haptics", isOn: $model.hapticsEnabled)
+ .onChange(of: model.hapticsEnabled) { _, enabled in
+ if enabled {
+ model.hapticSuccess()
+ }
+ }
+ }
+ }
+ .navigationTitle("Settings")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") {
+ model.hapticSelection()
+ dismiss()
+ }
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ }
+}
+
+private struct NewSimulatorSheet: View {
+ @Bindable var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var options: SimulatorCreateOptionsResponse?
+ @State private var platform: CreationPlatform = .ios
+ @State private var name = ""
+ @State private var nameDirty = false
+ @State private var deviceTypeIdentifier = ""
+ @State private var runtimeIdentifier = ""
+ @State private var pairedWatch = false
+ @State private var watchName = ""
+ @State private var watchNameDirty = false
+ @State private var watchDeviceTypeIdentifier = ""
+ @State private var watchRuntimeIdentifier = ""
+ @State private var androidName = ""
+ @State private var androidNameDirty = false
+ @State private var androidDeviceTypeIdentifier = ""
+ @State private var androidSystemImageIdentifier = ""
+ @State private var isLoading = false
+ @State private var isCreating = false
+ @State private var error = ""
+
+ private var runtimeOptions: [SimulatorRuntimeOption] {
+ compatibleRuntimes(deviceTypeIdentifier, options: options)
+ }
+
+ private var watchRuntimeOptions: [SimulatorRuntimeOption] {
+ compatibleRuntimes(watchDeviceTypeIdentifier, options: options)
+ }
+
+ private var watchDeviceTypes: [SimulatorDeviceTypeOption] {
+ (options?.deviceTypes ?? []).filter {
+ isWatchDeviceType($0) && !compatibleRuntimes($0.identifier, options: options).isEmpty
+ }
+ }
+
+ private var selectedDeviceType: SimulatorDeviceTypeOption? {
+ options?.deviceTypes.first { $0.identifier == deviceTypeIdentifier }
+ }
+
+ private var selectedAndroidDeviceType: AndroidEmulatorDeviceTypeOption? {
+ options?.android?.deviceTypes.first { $0.identifier == androidDeviceTypeIdentifier }
+ }
+
+ private var selectedAndroidSystemImage: AndroidEmulatorSystemImageOption? {
+ options?.android?.systemImages.first { $0.identifier == androidSystemImageIdentifier }
+ }
+
+ private var pairedWatchAvailable: Bool {
+ guard let selectedDeviceType else { return false }
+ return isPhoneDeviceType(selectedDeviceType) && !watchDeviceTypes.isEmpty && !watchRuntimeOptions.isEmpty
+ }
+
+ private var canCreate: Bool {
+ switch platform {
+ case .ios:
+ let baseReady = !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && !deviceTypeIdentifier.isEmpty
+ && !runtimeIdentifier.isEmpty
+ let watchReady = !pairedWatch || (
+ !watchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && !watchDeviceTypeIdentifier.isEmpty
+ && !watchRuntimeIdentifier.isEmpty
+ )
+ return baseReady && watchReady
+ case .android:
+ return !(options?.android?.unavailableReason?.isEmpty == false)
+ && !androidName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && !androidDeviceTypeIdentifier.isEmpty
+ && !androidSystemImageIdentifier.isEmpty
+ }
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Picker("Platform", selection: $platform) {
+ ForEach(CreationPlatform.allCases) { platform in
+ Text(platform.label).tag(platform)
+ }
+ }
+ .pickerStyle(.segmented)
+ .onChange(of: platform) { _, _ in
+ model.hapticSelection()
+ error = ""
+ if platform == .android {
+ pairedWatch = false
+ }
+ }
+
+ if isLoading {
+ Section {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ }
+ } else if platform == .android {
+ androidFields
+ } else {
+ iosFields
+ if pairedWatchAvailable {
+ Section {
+ Toggle("Paired Apple Watch", isOn: $pairedWatch)
+ .onChange(of: pairedWatch) { _, _ in model.hapticSelection() }
+ }
+ }
+ if pairedWatch {
+ watchFields
+ }
+ }
+
+ if !error.isEmpty {
+ Section {
+ Text(error)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ }
+ } else if !model.status.isEmpty {
+ Section {
+ Text(model.status)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle("New Simulator")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ model.hapticSelection()
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(isCreating ? "Creating" : "Create") {
+ Task { await create() }
+ }
+ .disabled(isLoading || isCreating || !canCreate)
+ }
+ }
+ .task {
+ await loadOptions()
+ }
+ }
+ .presentationDetents([.large])
+ }
+
+ private var iosFields: some View {
+ Section("iOS Simulator") {
+ TextField("Simulator Name", text: $name)
+ .onChange(of: name) { _, _ in nameDirty = true }
+ Picker("Device Type", selection: $deviceTypeIdentifier) {
+ ForEach(options?.deviceTypes.filter { !isWatchDeviceType($0) } ?? []) { deviceType in
+ Text(deviceType.name).tag(deviceType.identifier)
+ }
+ }
+ .onChange(of: deviceTypeIdentifier) { _, identifier in
+ model.hapticSelection()
+ let deviceType = options?.deviceTypes.first { $0.identifier == identifier }
+ runtimeIdentifier = chooseCompatibleRuntime(identifier, options: options)?.identifier ?? ""
+ if !nameDirty {
+ name = deviceType?.name ?? ""
+ }
+ }
+ Picker("OS Version", selection: $runtimeIdentifier) {
+ ForEach(runtimeOptions) { runtime in
+ Text(runtime.name).tag(runtime.identifier)
+ }
+ }
+ .onChange(of: runtimeIdentifier) { _, _ in model.hapticSelection() }
+ }
+ }
+
+ private var watchFields: some View {
+ Section("Apple Watch") {
+ TextField("Watch Name", text: $watchName)
+ .onChange(of: watchName) { _, _ in watchNameDirty = true }
+ Picker("Device Type", selection: $watchDeviceTypeIdentifier) {
+ ForEach(watchDeviceTypes) { deviceType in
+ Text(deviceType.name).tag(deviceType.identifier)
+ }
+ }
+ .onChange(of: watchDeviceTypeIdentifier) { _, identifier in
+ model.hapticSelection()
+ let deviceType = options?.deviceTypes.first { $0.identifier == identifier }
+ watchRuntimeIdentifier = chooseCompatibleRuntime(identifier, options: options)?.identifier ?? ""
+ if !watchNameDirty {
+ watchName = deviceType?.name ?? ""
+ }
+ }
+ Picker("OS Version", selection: $watchRuntimeIdentifier) {
+ ForEach(watchRuntimeOptions) { runtime in
+ Text(runtime.name).tag(runtime.identifier)
+ }
+ }
+ .onChange(of: watchRuntimeIdentifier) { _, _ in model.hapticSelection() }
+ }
+ }
+
+ private var androidFields: some View {
+ Section("Android Emulator") {
+ if let unavailableReason = options?.android?.unavailableReason {
+ Text(unavailableReason)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ TextField("Emulator Name", text: $androidName)
+ .onChange(of: androidName) { _, _ in androidNameDirty = true }
+ Picker("Device Profile", selection: $androidDeviceTypeIdentifier) {
+ ForEach(options?.android?.deviceTypes ?? []) { deviceType in
+ Text(deviceType.name).tag(deviceType.identifier)
+ }
+ }
+ .onChange(of: androidDeviceTypeIdentifier) { _, identifier in
+ model.hapticSelection()
+ let deviceType = options?.android?.deviceTypes.first { $0.identifier == identifier }
+ if !androidNameDirty, let deviceType {
+ androidName = defaultAndroidName(deviceType: deviceType, systemImage: selectedAndroidSystemImage)
+ }
+ }
+ Picker("System Image", selection: $androidSystemImageIdentifier) {
+ ForEach(options?.android?.systemImages ?? []) { systemImage in
+ Text(systemImage.name).tag(systemImage.identifier)
+ }
+ }
+ .onChange(of: androidSystemImageIdentifier) { _, identifier in
+ model.hapticSelection()
+ let systemImage = options?.android?.systemImages.first { $0.identifier == identifier }
+ if !androidNameDirty, let selectedAndroidDeviceType {
+ androidName = defaultAndroidName(deviceType: selectedAndroidDeviceType, systemImage: systemImage)
+ }
+ }
+ }
+ }
+
+ private func loadOptions() async {
+ guard options == nil, let endpoint = model.endpoint else { return }
+ isLoading = true
+ error = ""
+ defer { isLoading = false }
+ do {
+ let loadedOptions = try await SimDeckAPI(endpoint: endpoint).simulatorCreateOptions()
+ options = loadedOptions
+ applyDefaults(from: loadedOptions)
+ } catch {
+ self.error = error.localizedDescription
+ model.hapticWarning()
+ }
+ }
+
+ private func applyDefaults(from options: SimulatorCreateOptionsResponse) {
+ platform = model.selectedSimulator?.platform == "android-emulator" ? .android : .ios
+ let initialDeviceType = chooseInitialDeviceType(
+ options.deviceTypes,
+ selectedDeviceTypeIdentifier: model.selectedSimulator?.deviceTypeIdentifier
+ )
+ deviceTypeIdentifier = initialDeviceType?.identifier ?? ""
+ runtimeIdentifier = chooseCompatibleRuntime(
+ initialDeviceType?.identifier ?? "",
+ options: options,
+ preferredIdentifier: model.selectedSimulator?.runtimeIdentifier
+ )?.identifier ?? ""
+ name = initialDeviceType?.name ?? ""
+ nameDirty = false
+
+ let initialWatchDeviceType = chooseInitialWatchDeviceType(options)
+ watchDeviceTypeIdentifier = initialWatchDeviceType?.identifier ?? ""
+ watchRuntimeIdentifier = chooseCompatibleRuntime(
+ initialWatchDeviceType?.identifier ?? "",
+ options: options
+ )?.identifier ?? ""
+ watchName = initialWatchDeviceType?.name ?? ""
+ watchNameDirty = false
+ pairedWatch = false
+
+ let initialAndroidDeviceType = chooseInitialAndroidDeviceType(
+ options,
+ preferredName: model.selectedSimulator?.android?.avdName
+ )
+ let initialAndroidSystemImage = options.android?.systemImages.first
+ androidDeviceTypeIdentifier = initialAndroidDeviceType?.identifier ?? ""
+ androidSystemImageIdentifier = initialAndroidSystemImage?.identifier ?? ""
+ if let initialAndroidDeviceType {
+ androidName = defaultAndroidName(deviceType: initialAndroidDeviceType, systemImage: initialAndroidSystemImage)
+ }
+ androidNameDirty = false
+ }
+
+ private func create() async {
+ guard canCreate else { return }
+ model.hapticSelection()
+ isCreating = true
+ error = ""
+ defer { isCreating = false }
+ let request = CreateSimulatorRequest(
+ platform: platform.rawValue,
+ name: platform == .android
+ ? androidName.trimmingCharacters(in: .whitespacesAndNewlines)
+ : name.trimmingCharacters(in: .whitespacesAndNewlines),
+ deviceTypeIdentifier: platform == .android ? androidDeviceTypeIdentifier : deviceTypeIdentifier,
+ runtimeIdentifier: platform == .android ? androidSystemImageIdentifier : runtimeIdentifier,
+ pairedWatch: platform == .ios && pairedWatch
+ ? CreatePairedWatchRequest(
+ name: watchName.trimmingCharacters(in: .whitespacesAndNewlines),
+ deviceTypeIdentifier: watchDeviceTypeIdentifier,
+ runtimeIdentifier: watchRuntimeIdentifier
+ )
+ : nil
+ )
+ if await model.createSimulator(request) {
+ dismiss()
+ } else {
+ error = model.status
+ }
+ }
+}
+
+private enum CreationPlatform: String, CaseIterable, Identifiable {
+ case ios
+ case android
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .ios: "iOS"
+ case .android: "Android"
+ }
+ }
+}
+
+private func chooseInitialDeviceType(
+ _ deviceTypes: [SimulatorDeviceTypeOption],
+ selectedDeviceTypeIdentifier: String?
+) -> SimulatorDeviceTypeOption? {
+ deviceTypes.first { $0.identifier == selectedDeviceTypeIdentifier }
+ ?? deviceTypes.first(where: isPhoneDeviceType)
+ ?? deviceTypes.first { !isWatchDeviceType($0) }
+ ?? deviceTypes.first
+}
+
+private func chooseInitialWatchDeviceType(_ options: SimulatorCreateOptionsResponse) -> SimulatorDeviceTypeOption? {
+ options.deviceTypes.first {
+ isWatchDeviceType($0) && !compatibleRuntimes($0.identifier, options: options).isEmpty
+ }
+}
+
+private func chooseInitialAndroidDeviceType(
+ _ options: SimulatorCreateOptionsResponse,
+ preferredName: String?
+) -> AndroidEmulatorDeviceTypeOption? {
+ let deviceTypes = options.android?.deviceTypes ?? []
+ return deviceTypes.first { $0.identifier == preferredName }
+ ?? deviceTypes.first { $0.identifier == "pixel_8" }
+ ?? deviceTypes.first { $0.identifier.hasPrefix("pixel_") }
+ ?? deviceTypes.first
+}
+
+private func chooseCompatibleRuntime(
+ _ deviceTypeIdentifier: String,
+ options: SimulatorCreateOptionsResponse?,
+ preferredIdentifier: String? = nil
+) -> SimulatorRuntimeOption? {
+ let runtimes = compatibleRuntimes(deviceTypeIdentifier, options: options)
+ return runtimes.first { $0.identifier == preferredIdentifier } ?? runtimes.first
+}
+
+private func compatibleRuntimes(
+ _ deviceTypeIdentifier: String,
+ options: SimulatorCreateOptionsResponse?
+) -> [SimulatorRuntimeOption] {
+ guard !deviceTypeIdentifier.isEmpty, let options else { return [] }
+ let deviceType = options.deviceTypes.first { $0.identifier == deviceTypeIdentifier }
+ return options.runtimes.filter { runtime in
+ if runtime.isAvailable == false {
+ return false
+ }
+ return runtime.supportedDeviceTypeIdentifiers?.contains(deviceTypeIdentifier) == true
+ || deviceType?.supportedRuntimeIdentifiers?.contains(runtime.identifier) == true
+ }
+}
+
+private func isPhoneDeviceType(_ deviceType: SimulatorDeviceTypeOption) -> Bool {
+ (deviceType.productFamily ?? "").lowercased() == "iphone"
+}
+
+private func isWatchDeviceType(_ deviceType: SimulatorDeviceTypeOption) -> Bool {
+ (deviceType.productFamily ?? "").lowercased().contains("watch")
+}
+
+private func defaultAndroidName(
+ deviceType: AndroidEmulatorDeviceTypeOption,
+ systemImage: AndroidEmulatorSystemImageOption?
+) -> String {
+ let apiSuffix = systemImage?.apiLevel.map { "_API_\($0)" } ?? ""
+ let raw = "\(deviceType.name)\(apiSuffix)"
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_.-"))
+ let sanitized = raw.map { character in
+ character.unicodeScalars.allSatisfy { allowed.contains($0) } ? String(character) : "_"
+ }.joined()
+ return sanitized
+ .replacingOccurrences(of: "_+", with: "_", options: .regularExpression)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "_"))
+}
+
+private struct ConnectServerSheet: View {
+ @Bindable var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var isScanning = false
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Server") {
+ TextField("Host or Studio URL", text: $model.manualAddress)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+ SecureField("Token", text: $model.manualToken)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Button {
+ model.hapticSelection()
+ isScanning = true
+ } label: {
+ Label("Scan Pairing QR", systemImage: "qrcode.viewfinder")
+ }
+ Button {
+ model.hapticSelection()
+ Task {
+ if await model.connectManual() {
+ dismiss()
+ }
+ }
+ } label: {
+ Label("Connect", systemImage: "link")
+ }
+ }
+
+ if model.authEndpoint != nil {
+ Section("Pair") {
+ TextField("Pairing Code", text: $model.pairingCode)
+ .keyboardType(.numberPad)
+ Button {
+ model.hapticSelection()
+ isScanning = true
+ } label: {
+ Label("Scan QR Code", systemImage: "qrcode.viewfinder")
+ }
+ Button {
+ model.hapticSelection()
+ Task {
+ if await model.pair() {
+ dismiss()
+ }
+ }
+ } label: {
+ Label("Pair", systemImage: "checkmark.seal")
+ }
+ Button {
+ model.hapticSelection()
+ Task {
+ if await model.useToken() {
+ dismiss()
+ }
+ }
+ } label: {
+ Label("Use Token", systemImage: "key")
+ }
+ }
+ }
+
+ if !model.status.isEmpty {
+ Section {
+ Text(model.status)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle("Connect")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") {
+ model.hapticSelection()
+ dismiss()
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $isScanning) {
+ QRCodeScannerSheet(model: model)
+ }
+ .presentationDetents([.medium, .large])
+ }
+}
+
+private struct SimulatorSearchDock: View {
+ @Bindable var model: AppModel
+ @Binding var text: String
+ @Binding var isExpanded: Bool
+ let onCreateSimulator: () -> Void
+ @FocusState private var isFocused: Bool
+
+ private var isSearchBarVisible: Bool {
+ isExpanded || !text.isEmpty
+ }
+
+ var body: some View {
+ GeometryReader { proxy in
+ let width = isSearchBarVisible
+ ? max(48, proxy.size.width - 88)
+ : 48.0
+
+ HStack(spacing: 8) {
+ searchControl(width: width)
+
+ Button {
+ model.hapticSelection()
+ onCreateSimulator()
+ } label: {
+ Label("New Simulator", systemImage: "plus")
+ .labelStyle(.iconOnly)
+ .foregroundStyle(.primary)
+ .frame(width: 48, height: 48)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .modifier(GlassCircleModifier(interactive: true))
+ .disabled(model.endpoint == nil || model.authEndpoint != nil)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .zIndex(1)
+ }
+ .frame(height: 64)
+ .animation(.snappy(duration: 0.24), value: isSearchBarVisible)
+ .onChange(of: isExpanded) { _, expanded in
+ guard expanded else { return }
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 80_000_000)
+ isFocused = true
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func searchControl(width: CGFloat) -> some View {
+ HStack(spacing: 8) {
+ if isSearchBarVisible {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.secondary)
+
+ TextField("Search Simulators", text: $text)
+ .focused($isFocused)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .submitLabel(.search)
+ .transition(.opacity)
+
+ Button {
+ model.hapticSelection()
+ if text.isEmpty {
+ isFocused = false
+ withAnimation(.snappy(duration: 0.24)) {
+ isExpanded = false
+ }
+ } else {
+ text = ""
+ }
+ } label: {
+ Label(text.isEmpty ? "Close Search" : "Clear", systemImage: "xmark.circle.fill")
+ }
+ .labelStyle(.iconOnly)
+ .foregroundStyle(.secondary)
+ .buttonStyle(.plain)
+ .transition(.opacity)
+ } else {
+ Button {
+ model.hapticSelection()
+ withAnimation(.snappy(duration: 0.24)) {
+ isExpanded = true
+ }
+ } label: {
+ Label("Search", systemImage: "magnifyingglass")
+ .labelStyle(.iconOnly)
+ .foregroundStyle(.primary)
+ .frame(width: 48, height: 48)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, isSearchBarVisible ? 14 : 0)
+ .frame(width: width, height: 48)
+ .contentShape(Capsule())
+ .modifier(GlassCapsuleModifier(interactive: true))
+ }
+}
+
+private struct GlassCapsuleModifier: ViewModifier {
+ let interactive: Bool
+
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ if interactive {
+ content.glassEffect(.regular.interactive(), in: .capsule)
+ } else {
+ content.glassEffect(.regular, in: .capsule)
+ }
+ } else {
+ content.background(.ultraThinMaterial, in: Capsule())
+ }
+ }
+}
+
+private struct GlassCircleModifier: ViewModifier {
+ let interactive: Bool
+
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ if interactive {
+ content.glassEffect(.regular.interactive(), in: .circle)
+ } else {
+ content.glassEffect(.regular, in: .circle)
+ }
+ } else {
+ content.background(.ultraThinMaterial, in: Circle())
+ }
+ }
+}
+
+private struct QRCodeScannerSheet: View {
+ @Bindable var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ QRCodeScannerView { value in
+ model.handleScannedPairingPayload(value)
+ dismiss()
+ }
+ .ignoresSafeArea(edges: .bottom)
+ .navigationTitle("Scan Pairing QR")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ model.hapticSelection()
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct QRCodeScannerView: UIViewControllerRepresentable {
+ let onScan: (String) -> Void
+
+ func makeUIViewController(context: Context) -> QRCodeScannerViewController {
+ QRCodeScannerViewController(onScan: onScan)
+ }
+
+ func updateUIViewController(_ uiViewController: QRCodeScannerViewController, context: Context) {}
+}
+
+private final class QRCodeScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
+ private let onScan: (String) -> Void
+ private let session = AVCaptureSession()
+ private var previewLayer: AVCaptureVideoPreviewLayer?
+ private var didScan = false
+ private let messageLabel = UILabel()
+
+ init(onScan: @escaping (String) -> Void) {
+ self.onScan = onScan
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ return nil
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .black
+ configureMessageLabel()
+ requestCameraAccess()
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+ previewLayer?.frame = view.bounds
+ messageLabel.frame = CGRect(
+ x: 24,
+ y: view.safeAreaInsets.top + 24,
+ width: view.bounds.width - 48,
+ height: 64
+ )
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ stopSession()
+ }
+
+ func metadataOutput(
+ _ output: AVCaptureMetadataOutput,
+ didOutput metadataObjects: [AVMetadataObject],
+ from connection: AVCaptureConnection
+ ) {
+ guard !didScan,
+ let object = metadataObjects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first(where: { $0.type == .qr }),
+ let value = object.stringValue?.nilIfBlank else {
+ return
+ }
+ didScan = true
+ stopSession()
+ onScan(value)
+ }
+
+ private func configureMessageLabel() {
+ messageLabel.text = "Scan the QR from simdeck pair"
+ messageLabel.textAlignment = .center
+ messageLabel.textColor = .white
+ messageLabel.font = .preferredFont(forTextStyle: .headline)
+ messageLabel.backgroundColor = UIColor.black.withAlphaComponent(0.45)
+ messageLabel.layer.cornerRadius = 14
+ messageLabel.clipsToBounds = true
+ view.addSubview(messageLabel)
+ }
+
+ private func requestCameraAccess() {
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ configureSession()
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
+ DispatchQueue.main.async {
+ granted ? self?.configureSession() : self?.showCameraDenied()
+ }
+ }
+ default:
+ showCameraDenied()
+ }
+ }
+
+ private func configureSession() {
+ guard previewLayer == nil else { return }
+ guard let device = AVCaptureDevice.default(for: .video),
+ let input = try? AVCaptureDeviceInput(device: device),
+ session.canAddInput(input) else {
+ showScannerUnavailable()
+ return
+ }
+ session.addInput(input)
+
+ let output = AVCaptureMetadataOutput()
+ guard session.canAddOutput(output) else {
+ showScannerUnavailable()
+ return
+ }
+ session.addOutput(output)
+ output.setMetadataObjectsDelegate(self, queue: .main)
+ output.metadataObjectTypes = [.qr]
+
+ let previewLayer = AVCaptureVideoPreviewLayer(session: session)
+ previewLayer.videoGravity = .resizeAspectFill
+ previewLayer.frame = view.bounds
+ view.layer.insertSublayer(previewLayer, at: 0)
+ self.previewLayer = previewLayer
+
+ DispatchQueue.global(qos: .userInitiated).async { [session] in
+ session.startRunning()
+ }
+ }
+
+ private func stopSession() {
+ guard session.isRunning else { return }
+ DispatchQueue.global(qos: .userInitiated).async { [session] in
+ session.stopRunning()
+ }
+ }
+
+ private func showCameraDenied() {
+ messageLabel.text = "Camera access is needed to scan pairing QR codes."
+ }
+
+ private func showScannerUnavailable() {
+ messageLabel.text = "QR scanning is unavailable on this device."
+ }
+}
+
+private struct EndpointRow: View {
+ let endpoint: SimDeckEndpoint
+
+ var body: some View {
+ Label {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(endpoint.name)
+ .lineLimit(1)
+ Text(endpoint.baseURL.absoluteString)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ } icon: {
+ Image(systemName: endpoint.source.systemImage)
+ .foregroundStyle(endpoint.requiresPairing ? .orange : .blue)
+ }
+ }
+}
+
+struct SimulatorRow: View {
+ let simulator: SimulatorMetadata
+
+ var body: some View {
+ Label {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(simulator.name)
+ .lineLimit(1)
+ if !simulator.subtitle.isEmpty {
+ Text(simulator.subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ } icon: {
+ Image(systemName: simulator.systemImage)
+ .foregroundStyle(simulator.isBooted ? .green : .secondary)
+ }
+ }
+}
diff --git a/ios/SimDeckStudio/Views/SimulatorStreamView.swift b/ios/SimDeckStudio/Views/SimulatorStreamView.swift
new file mode 100644
index 00000000..b6131b79
--- /dev/null
+++ b/ios/SimDeckStudio/Views/SimulatorStreamView.swift
@@ -0,0 +1,1370 @@
+import SwiftUI
+import UIKit
+
+struct SimulatorStreamView: View {
+ @Bindable var model: AppModel
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var activeTouchKind: StreamTouchKind?
+ @State private var activeTouchIndicatorID: UUID?
+ @State private var touchIndicators: [StreamTouchIndicator] = []
+ @State private var touchOverlayRemovalTask: Task?
+ @State private var presentedSheet: StreamSheet?
+ @State private var keyboardCaptureActive = false
+ @State private var keyboardHeight: CGFloat = 0
+
+ var body: some View {
+ ZStack {
+ if model.selectedSimulator == nil {
+ ContentUnavailableView("No Simulator", systemImage: "iphone.slash")
+ } else {
+ streamViewport
+ }
+
+ KeyboardCaptureView(
+ isActive: $keyboardCaptureActive,
+ onText: { model.sendKeyboardText($0) },
+ onDelete: { model.sendKeyboardBackspace() }
+ )
+ .frame(width: 1, height: 1)
+ .opacity(0.01)
+ .accessibilityHidden(true)
+ }
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ StreamTitleButton(model: model) {
+ model.hapticSelection()
+ presentedSheet = .simulators
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Menu {
+ Section("Stream") {
+ Text(model.streamConfig.summary)
+ Menu("Encoder") {
+ ForEach(StreamEncoder.allCases, id: \.self) { encoder in
+ Button {
+ model.setStreamEncoder(encoder)
+ } label: {
+ if model.streamConfig.encoder == encoder {
+ Label(encoder.label, systemImage: "checkmark")
+ } else {
+ Text(encoder.label)
+ }
+ }
+ }
+ }
+ Menu("Frame Rate") {
+ ForEach([15, 30, 60, 120], id: \.self) { fps in
+ Button {
+ model.setStreamFPS(fps)
+ } label: {
+ if model.streamConfig.fps == fps {
+ Label("\(fps) fps", systemImage: "checkmark")
+ } else {
+ Text("\(fps) fps")
+ }
+ }
+ }
+ }
+ Menu("Resolution") {
+ ForEach(StreamQualityPreset.allCases, id: \.self) { quality in
+ Button {
+ model.setStreamQuality(quality)
+ } label: {
+ if model.streamConfig.quality == quality {
+ Label(quality.label, systemImage: "checkmark")
+ } else {
+ Text(quality.label)
+ }
+ }
+ }
+ }
+ }
+ Section("Interaction") {
+ Toggle(isOn: Binding(
+ get: { model.touchOverlayVisible },
+ set: { model.setTouchOverlayVisible($0) }
+ )) {
+ Label("Show Touch Overlay", systemImage: "hand.tap")
+ }
+ Button {
+ model.hapticSelection()
+ presentedSheet = .debugInfo
+ } label: {
+ Label("Debug Info", systemImage: "info.circle")
+ }
+ }
+ Divider()
+ Button {
+ model.hapticSelection()
+ Task { await model.refreshSimulators() }
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ Button {
+ if model.selectedSimulator?.isBooted == true {
+ model.hapticSelection()
+ Task { await model.startStream() }
+ } else {
+ Task { await model.bootSelectedSimulator() }
+ }
+ } label: {
+ Label(model.selectedSimulator?.isBooted == true ? "Start Stream" : "Boot", systemImage: "play.circle")
+ }
+ .disabled(model.selectedSimulatorID == nil || model.endpoint == nil)
+ Button {
+ model.stopStream()
+ } label: {
+ Label("Stop", systemImage: "stop.circle")
+ }
+ .disabled(!model.canStopStream)
+ } label: {
+ Label("Stream Settings", systemImage: "gearshape")
+ }
+ }
+ }
+ .sheet(item: $presentedSheet) { sheet in
+ switch sheet {
+ case .simulators:
+ StreamSimulatorSelectionSheet(model: model)
+ case .debugInfo:
+ StreamDebugInfoSheet(model: model)
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ if model.selectedSimulator?.isBooted == true {
+ StreamControlBar(model: model, keyboardCaptureActive: $keyboardCaptureActive)
+ }
+ }
+ .onChange(of: model.selectedSimulatorID) { _, _ in
+ keyboardCaptureActive = false
+ clearTouchOverlay()
+ }
+ .onChange(of: model.selectedSimulator?.isBooted == true) { _, isBooted in
+ if !isBooted {
+ keyboardCaptureActive = false
+ clearTouchOverlay()
+ }
+ }
+ .onChange(of: model.touchOverlayVisible) { _, isVisible in
+ if !isVisible {
+ clearTouchOverlay()
+ }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
+ updateKeyboardHeight(notification)
+ }
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { notification in
+ updateKeyboardHeight(notification)
+ }
+ }
+
+ private var streamViewport: some View {
+ GeometryReader { proxy in
+ let layout = DeviceViewportLayout(
+ chromeProfile: model.chromeProfile,
+ videoSize: model.videoSize,
+ availableSize: proxy.size
+ )
+ let displayToken = model.streamDisplayToken
+ let screenMaskImage = model.chromeProfile?.hasScreenMask == true ? model.chromeScreenMask : nil
+
+ ZStack(alignment: .topLeading) {
+ streamBackground
+
+ Rectangle()
+ .fill(.black)
+ .frame(width: layout.screenBackingFrame.width, height: layout.screenBackingFrame.height)
+ .clippedToSimulatorScreen(cornerRadius: layout.screenBackingCornerRadius, maskImage: nil)
+ .position(x: layout.screenBackingFrame.midX, y: layout.screenBackingFrame.midY)
+
+ if showsCachedStreamFrame, let lastStreamFrame = model.lastStreamFrame {
+ CachedStreamFrameView(
+ image: lastStreamFrame,
+ cornerRadius: layout.screenCornerRadius + 1,
+ maskImage: screenMaskImage
+ )
+ .frame(width: layout.videoFrame.width, height: layout.videoFrame.height)
+ .position(x: layout.videoFrame.midX, y: layout.videoFrame.midY)
+ .transition(.opacity)
+ }
+
+ if model.selectedSimulator?.isBooted == true, model.currentStreamClient != nil {
+ WebRTCVideoView(
+ client: model.currentStreamClient,
+ onVideoSize: { size in
+ model.videoSize = size
+ },
+ onFrameRendered: {
+ model.markStreamFrameRendered(displayToken: displayToken)
+ },
+ onFrameSnapshot: { image in
+ model.updateLastStreamFrame(image, displayToken: displayToken)
+ }
+ )
+ .id(displayToken)
+ .frame(width: layout.videoFrame.width, height: layout.videoFrame.height)
+ .clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius + 1, maskImage: screenMaskImage)
+ .position(x: layout.videoFrame.midX, y: layout.videoFrame.midY)
+ .opacity(model.hasCurrentStreamFrame ? 1 : 0)
+ }
+
+ if let chromeImage = model.chromeImage, layout.usesChrome {
+ Image(uiImage: chromeImage)
+ .resizable()
+ .interpolation(.high)
+ .frame(width: layout.shellFrame.width, height: layout.shellFrame.height)
+ .position(x: layout.shellFrame.midX, y: layout.shellFrame.midY)
+ .shadow(color: .black.opacity(0.22), radius: 18, y: 10)
+ .allowsHitTesting(false)
+ }
+
+ if model.selectedSimulator?.isBooted == true,
+ let chromeProfile = model.chromeProfile,
+ layout.usesChrome {
+ HardwareButtonLayer(model: model, chromeProfile: chromeProfile, layout: layout)
+ }
+
+ if model.selectedSimulator?.isBooted == true,
+ model.touchOverlayVisible,
+ !touchIndicators.isEmpty {
+ TouchInteractionOverlay(indicators: touchIndicators)
+ .frame(width: layout.screenFrame.width, height: layout.screenFrame.height)
+ .clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius, maskImage: screenMaskImage)
+ .position(x: layout.screenFrame.midX, y: layout.screenFrame.midY)
+ .allowsHitTesting(false)
+ .transition(.opacity)
+ }
+
+ if let simulator = model.selectedSimulator, !simulator.isBooted {
+ BootSimulatorOverlay(model: model, simulator: simulator)
+ .frame(width: layout.screenFrame.width, height: layout.screenFrame.height)
+ .clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius, maskImage: screenMaskImage)
+ .position(x: layout.screenFrame.midX, y: layout.screenFrame.midY)
+ }
+
+ if showsFirstFrameSpinner {
+ StreamFirstFrameLoadingOverlay()
+ .frame(width: layout.screenFrame.width, height: layout.screenFrame.height)
+ .clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius, maskImage: screenMaskImage)
+ .position(x: layout.screenFrame.midX, y: layout.screenFrame.midY)
+ .transition(.opacity)
+ }
+
+ if showsRetryOverlay {
+ StreamRetryOverlay(model: model)
+ .frame(width: layout.screenFrame.width, height: layout.screenFrame.height)
+ .clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius, maskImage: screenMaskImage)
+ .position(x: layout.screenFrame.midX, y: layout.screenFrame.midY)
+ .transition(.opacity)
+ }
+ }
+ .contentShape(Rectangle())
+ .streamTouchGesture(model.selectedSimulator?.isBooted == true, gesture: touchGesture(in: layout.screenFrame))
+ .animation(.snappy(duration: 0.3), value: keyboardCaptureActive)
+ .animation(.smooth(duration: 0.28), value: keyboardHeight)
+ }
+ .background(streamBackground)
+ }
+
+ private var streamBackground: Color {
+ colorScheme == .dark ? Color(.systemBackground) : Color(.secondarySystemGroupedBackground)
+ }
+
+ private var showsFirstFrameSpinner: Bool {
+ guard model.selectedSimulator?.isBooted == true else { return false }
+ return model.streamState == .connecting
+ || (model.currentStreamClient != nil && !model.hasCurrentStreamFrame)
+ }
+
+ private var showsCachedStreamFrame: Bool {
+ guard model.selectedSimulator?.isBooted == true else { return false }
+ return model.lastStreamFrame != nil && !model.hasCurrentStreamFrame
+ }
+
+ private var showsRetryOverlay: Bool {
+ guard model.selectedSimulator?.isBooted == true else { return false }
+ return model.streamState == .failed || model.streamState == .disconnected
+ }
+
+ private func touchGesture(in screenFrame: CGRect) -> some Gesture {
+ DragGesture(minimumDistance: 0)
+ .onChanged { value in
+ if activeTouchKind == nil {
+ guard screenFrame.contains(value.startLocation),
+ let point = model.normalizedTouchPoint(location: value.startLocation, in: screenFrame) else {
+ return
+ }
+ activeTouchKind = point.y >= 0.93 ? .bottomEdge : .single
+ sendActiveTouch(location: value.location, in: screenFrame, phase: "began")
+ return
+ }
+ sendActiveTouch(location: value.location, in: screenFrame, phase: "moved")
+ }
+ .onEnded { value in
+ sendActiveTouch(location: value.location, in: screenFrame, phase: "ended")
+ activeTouchKind = nil
+ }
+ }
+
+ private func sendActiveTouch(location: CGPoint, in screenFrame: CGRect, phase: String) {
+ updateTouchOverlay(location: location, in: screenFrame, phase: phase)
+ switch activeTouchKind {
+ case .bottomEdge:
+ model.sendEdgeTouch(location: location, in: screenFrame, phase: phase, edge: "bottom")
+ case .single:
+ model.sendTouch(location: location, in: screenFrame, phase: phase)
+ case nil:
+ break
+ }
+ }
+
+ private func updateTouchOverlay(location: CGPoint, in screenFrame: CGRect, phase: String) {
+ guard model.touchOverlayVisible else {
+ clearTouchOverlay()
+ return
+ }
+
+ let clampedLocation = clampedTouchPoint(location, in: screenFrame)
+ switch phase {
+ case "began":
+ touchOverlayRemovalTask?.cancel()
+ let id = UUID()
+ activeTouchIndicatorID = id
+ withAnimation(.snappy(duration: 0.12)) {
+ touchIndicators = [
+ StreamTouchIndicator(id: id, start: clampedLocation, current: clampedLocation, isEnding: false)
+ ]
+ }
+ case "moved":
+ guard let activeTouchIndicatorID,
+ let index = touchIndicators.firstIndex(where: { $0.id == activeTouchIndicatorID }) else {
+ return
+ }
+ touchIndicators[index].current = clampedLocation
+ case "ended":
+ guard let activeTouchIndicatorID,
+ let index = touchIndicators.firstIndex(where: { $0.id == activeTouchIndicatorID }) else {
+ return
+ }
+ let endingID = activeTouchIndicatorID
+ touchIndicators[index].current = clampedLocation
+ touchIndicators[index].isEnding = true
+ self.activeTouchIndicatorID = nil
+ touchOverlayRemovalTask?.cancel()
+ touchOverlayRemovalTask = Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(240))
+ withAnimation(.easeOut(duration: 0.16)) {
+ touchIndicators.removeAll { $0.id == endingID }
+ }
+ }
+ default:
+ break
+ }
+ }
+
+ private func clampedTouchPoint(_ location: CGPoint, in screenFrame: CGRect) -> CGPoint {
+ CGPoint(
+ x: min(max(location.x - screenFrame.minX, 0), screenFrame.width),
+ y: min(max(location.y - screenFrame.minY, 0), screenFrame.height)
+ )
+ }
+
+ private func clearTouchOverlay() {
+ touchOverlayRemovalTask?.cancel()
+ touchOverlayRemovalTask = nil
+ activeTouchIndicatorID = nil
+ touchIndicators = []
+ }
+
+ private func updateKeyboardHeight(_ notification: Notification) {
+ let endFrame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue ?? .zero
+ let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.28
+ let height = notification.name == UIResponder.keyboardWillHideNotification
+ ? 0
+ : max(0, UIScreen.main.bounds.height - endFrame.minY)
+ withAnimation(.easeOut(duration: duration)) {
+ keyboardHeight = height
+ }
+ if height <= 1 {
+ keyboardCaptureActive = false
+ }
+ }
+}
+
+private enum StreamTouchKind {
+ case single
+ case bottomEdge
+}
+
+private enum StreamSheet: Identifiable {
+ case simulators
+ case debugInfo
+
+ var id: Self { self }
+}
+
+private struct StreamTouchIndicator: Identifiable, Equatable {
+ let id: UUID
+ var start: CGPoint
+ var current: CGPoint
+ var isEnding: Bool
+}
+
+private struct TouchInteractionOverlay: View {
+ let indicators: [StreamTouchIndicator]
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ ForEach(indicators) { indicator in
+ Path { path in
+ path.move(to: indicator.start)
+ path.addLine(to: indicator.current)
+ }
+ .stroke(.white.opacity(indicator.isEnding ? 0.25 : 0.62), style: StrokeStyle(lineWidth: 4, lineCap: .round))
+ .shadow(color: .black.opacity(0.28), radius: 4)
+
+ Circle()
+ .fill(.white.opacity(indicator.isEnding ? 0.18 : 0.36))
+ .stroke(.white.opacity(indicator.isEnding ? 0.36 : 0.86), lineWidth: 2)
+ .frame(width: indicator.isEnding ? 34 : 42, height: indicator.isEnding ? 34 : 42)
+ .position(x: indicator.current.x, y: indicator.current.y)
+ .shadow(color: .black.opacity(0.3), radius: 7)
+ .scaleEffect(indicator.isEnding ? 0.82 : 1)
+ }
+ }
+ .compositingGroup()
+ .accessibilityHidden(true)
+ }
+}
+
+private struct BootSimulatorOverlay: View {
+ @Bindable var model: AppModel
+ let simulator: SimulatorMetadata
+
+ var body: some View {
+ ZStack {
+ Color.black.opacity(0.08)
+ Button {
+ Task { await model.bootSelectedSimulator() }
+ } label: {
+ ZStack {
+ if model.isSelectedSimulatorBooting {
+ ProgressView()
+ .controlSize(.regular)
+ .tint(.white)
+ } else {
+ Image(systemName: "play.fill")
+ .font(.title2.weight(.semibold))
+ .foregroundStyle(.white)
+ .offset(x: 2)
+ }
+ }
+ .frame(width: 72, height: 72)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .disabled(model.isSelectedSimulatorBooting)
+ .modifier(StreamGlassCircleModifier(interactive: !model.isSelectedSimulatorBooting))
+ .accessibilityLabel(model.isSelectedSimulatorBooting ? "Booting \(simulator.name)" : "Boot \(simulator.name)")
+ }
+ }
+}
+
+private struct StreamFirstFrameLoadingOverlay: View {
+ var body: some View {
+ ZStack {
+ Color.clear
+ ProgressView()
+ .controlSize(.small)
+ .tint(.white)
+ }
+ .allowsHitTesting(false)
+ .accessibilityLabel("Loading stream")
+ }
+}
+
+private struct CachedStreamFrameView: View {
+ let image: UIImage
+ let cornerRadius: CGFloat
+ let maskImage: UIImage?
+
+ var body: some View {
+ Image(uiImage: image)
+ .resizable()
+ .scaledToFill()
+ .saturation(0.82)
+ .brightness(-0.08)
+ .overlay(Color.black.opacity(0.28))
+ .clippedToSimulatorScreen(cornerRadius: cornerRadius, maskImage: maskImage)
+ .shadow(color: .black.opacity(0.34), radius: 16, y: 8)
+ .clipped()
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+}
+
+private struct StreamRetryOverlay: View {
+ @Bindable var model: AppModel
+
+ var body: some View {
+ ZStack {
+ Color.black.opacity(0.06)
+ VStack(spacing: 10) {
+ Button {
+ model.retryStream()
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(.white)
+ .frame(width: 52, height: 52)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .modifier(StreamGlassCircleModifier(interactive: true))
+ .accessibilityLabel("Retry Stream")
+
+ Text("Retry")
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.white.opacity(0.88))
+ }
+ }
+ }
+}
+
+private struct StreamTitleButton: View {
+ @Bindable var model: AppModel
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 10) {
+ Circle()
+ .fill(statusColor)
+ .frame(width: 7, height: 7)
+ Spacer(minLength: 4)
+ VStack(alignment: .center, spacing: 1) {
+ Text(model.selectedSimulator?.name ?? "Select Simulator")
+ .font(.headline)
+ .lineLimit(1)
+ .multilineTextAlignment(.center)
+ Text(model.streamNavigationSubtitle)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .multilineTextAlignment(.center)
+ }
+ Spacer(minLength: 4)
+ Image(systemName: "chevron.down")
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.leading, 14)
+ .padding(.trailing, 12)
+ .padding(.vertical, 5)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .frame(minWidth: 190, maxWidth: 260)
+ .frame(height: 42)
+ .modifier(StreamGlassCapsuleModifier(interactive: true))
+ .accessibilityElement(children: .combine)
+ }
+
+ private var statusColor: Color {
+ if let selectedSimulator = model.selectedSimulator, !selectedSimulator.isBooted {
+ return model.isSelectedSimulatorBooting ? .orange : .secondary
+ }
+ switch model.streamState {
+ case .connected:
+ return .green
+ case .connecting:
+ return .orange
+ case .failed:
+ return .red
+ case .disconnected, .idle:
+ return .secondary
+ }
+ }
+}
+
+private struct StreamSimulatorSelectionSheet: View {
+ @Bindable var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(model.simulators) { simulator in
+ Button {
+ model.hapticSelection()
+ model.selectSimulator(simulator.udid)
+ dismiss()
+ } label: {
+ HStack(spacing: 12) {
+ SimulatorRow(simulator: simulator)
+ Spacer()
+ if model.selectedSimulatorID == simulator.udid {
+ Image(systemName: "checkmark")
+ .font(.headline)
+ .foregroundStyle(.tint)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .navigationTitle("Simulators")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") {
+ model.hapticSelection()
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ model.hapticSelection()
+ Task { await model.refreshSimulators() }
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ }
+ }
+ }
+ .presentationDetents([.medium, .large])
+ }
+}
+
+private struct StreamDebugInfoSheet: View {
+ @Bindable var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section("Stream") {
+ DebugInfoRow("State", value: model.streamState.rawValue)
+ DebugInfoRow("FPS", value: formattedDecimal(model.streamDiagnostics.renderedFps))
+ DebugInfoRow("Decoded FPS", value: formattedDecimal(model.streamDiagnostics.decodedFps))
+ DebugInfoRow("Packet FPS", value: formattedDecimal(model.streamDiagnostics.packetFps))
+ DebugInfoRow("Resolution", value: resolution)
+ DebugInfoRow("Path", value: "webrtc")
+ DebugInfoRow("Config", value: model.streamConfig.summary)
+ DebugInfoRow("Codec", value: model.streamDiagnostics.codec.nilIfBlank ?? "-")
+ }
+
+ Section("Frames") {
+ DebugInfoRow("Packets", value: "\(model.streamDiagnostics.receivedPackets)")
+ DebugInfoRow("Packet Loss", value: "\(model.streamDiagnostics.packetsLost)")
+ DebugInfoRow("Decoded", value: "\(model.streamDiagnostics.decodedFrames)")
+ DebugInfoRow("Rendered", value: "\(model.streamDiagnostics.renderedFrames)")
+ DebugInfoRow("Decode Drops", value: "\(model.streamDiagnostics.decoderDroppedFrames)")
+ DebugInfoRow("Present Drops", value: "\(model.streamDiagnostics.presentationDroppedFrames)")
+ DebugInfoRow("Frame Gap", value: formattedMilliseconds(model.streamDiagnostics.latestFrameGapMs))
+ DebugInfoRow("Packet Gap", value: formattedMilliseconds(model.streamDiagnostics.latestPacketGapMs))
+ }
+
+ Section("Connection") {
+ DebugInfoRow("Peer", value: model.streamDiagnostics.peerConnectionState.nilIfBlank ?? "-")
+ DebugInfoRow("ICE", value: model.streamDiagnostics.iceConnectionState.nilIfBlank ?? "-")
+ DebugInfoRow("Gathering", value: model.streamDiagnostics.iceGatheringState.nilIfBlank ?? "-")
+ DebugInfoRow("Signaling", value: model.streamDiagnostics.signalingState.nilIfBlank ?? "-")
+ DebugInfoRow("Reconnects", value: "\(model.streamReconnects)")
+ DebugInfoRow("Reconnect Reason", value: model.streamReconnectReason.nilIfBlank ?? "-")
+ DebugInfoRow("Candidate Pair", value: model.streamDiagnostics.selectedCandidatePair.nilIfBlank ?? "-")
+ }
+
+ Section("Target") {
+ DebugInfoRow("Server", value: model.endpoint?.baseURL.absoluteString ?? "-")
+ DebugInfoRow("Simulator", value: model.selectedSimulator?.name ?? "-")
+ DebugInfoRow("UDID", value: model.selectedSimulatorID ?? "-")
+ DebugInfoRow("Updated", value: model.streamDiagnostics.timestamp.formatted(date: .omitted, time: .standard))
+ }
+ }
+ .navigationTitle("Debug Info")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") {
+ model.hapticSelection()
+ dismiss()
+ }
+ }
+ }
+ }
+ .presentationDetents([.medium, .large])
+ }
+
+ private var resolution: String {
+ let diagnostics = model.streamDiagnostics
+ if diagnostics.width > 0, diagnostics.height > 0 {
+ return "\(diagnostics.width)x\(diagnostics.height)"
+ }
+ if model.videoSize.width > 0, model.videoSize.height > 0 {
+ return "\(Int(model.videoSize.width))x\(Int(model.videoSize.height))"
+ }
+ return "-"
+ }
+
+ private func formattedDecimal(_ value: Double) -> String {
+ guard value.isFinite else { return "0.0" }
+ return value.formatted(.number.precision(.fractionLength(1)))
+ }
+
+ private func formattedMilliseconds(_ value: Double) -> String {
+ guard value.isFinite, value > 0 else { return "-" }
+ return "\(value.formatted(.number.precision(.fractionLength(1)))) ms"
+ }
+}
+
+private struct DebugInfoRow: View {
+ let title: LocalizedStringKey
+ let value: String
+
+ init(_ title: LocalizedStringKey, value: String) {
+ self.title = title
+ self.value = value
+ }
+
+ var body: some View {
+ LabeledContent(title) {
+ Text(value)
+ .fontDesign(.monospaced)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.trailing)
+ .textSelection(.enabled)
+ }
+ }
+}
+
+private struct StreamBadge: View {
+ let state: StreamState
+ let size: CGSize
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(color)
+ .frame(width: 8, height: 8)
+ Text(label)
+ .font(.caption)
+ .monospacedDigit()
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(.thinMaterial, in: Capsule())
+ }
+
+ private var label: String {
+ if size.width > 0, size.height > 0 {
+ "\(state.rawValue) \(Int(size.width))x\(Int(size.height))"
+ } else {
+ state.rawValue
+ }
+ }
+
+ private var color: Color {
+ switch state {
+ case .connected: .green
+ case .connecting: .orange
+ case .failed: .red
+ case .disconnected: .secondary
+ case .idle: .secondary
+ }
+ }
+}
+
+private struct StreamControlBar: View {
+ @Bindable var model: AppModel
+ @Binding var keyboardCaptureActive: Bool
+
+ var body: some View {
+ if #available(iOS 26.0, *) {
+ LiquidGlassStreamControlBar(model: model, keyboardCaptureActive: $keyboardCaptureActive)
+ } else {
+ LegacyStreamControlBar(model: model, keyboardCaptureActive: $keyboardCaptureActive)
+ }
+ }
+}
+
+@available(iOS 26.0, *)
+private struct LiquidGlassStreamControlBar: View {
+ @Bindable var model: AppModel
+ @Binding var keyboardCaptureActive: Bool
+
+ var body: some View {
+ GlassEffectContainer(spacing: 14) {
+ StreamControlButtons(model: model, keyboardCaptureActive: $keyboardCaptureActive)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+}
+
+private struct LegacyStreamControlBar: View {
+ @Bindable var model: AppModel
+ @Binding var keyboardCaptureActive: Bool
+
+ var body: some View {
+ StreamControlButtons(model: model, keyboardCaptureActive: $keyboardCaptureActive)
+ .buttonStyle(StreamToolbarButtonStyle())
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+}
+
+private struct StreamControlButtons: View {
+ @Bindable var model: AppModel
+ @Binding var keyboardCaptureActive: Bool
+
+ var body: some View {
+ HStack(spacing: 8) {
+ StreamHardwareControlButton("Home", systemImage: "house", buttonName: "home", model: model)
+
+ StreamControlButton("Switcher", systemImage: "square.on.square") { model.sendAppSwitcher() }
+
+ Spacer(minLength: 4)
+
+ StreamControlButton("Appearance", systemImage: "circle.lefthalf.filled") { model.toggleAppearance() }
+
+ StreamControlButton("Rotate Right", systemImage: "rotate.right") { model.rotateRight() }
+
+ Spacer(minLength: 4)
+
+ StreamHardwareControlButton("Lock", systemImage: "lock", buttonName: "power", model: model)
+
+ StreamKeyboardControlButton(model: model, isActive: $keyboardCaptureActive)
+ }
+ }
+}
+
+private struct StreamControlButton: View {
+ let title: LocalizedStringKey
+ let systemImage: String
+ let action: () -> Void
+
+ init(_ title: LocalizedStringKey, systemImage: String, action: @escaping () -> Void) {
+ self.title = title
+ self.systemImage = systemImage
+ self.action = action
+ }
+
+ var body: some View {
+ Button(action: action) {
+ StreamControlIconLabel(title: title, systemImage: systemImage)
+ }
+ .buttonStyle(.plain)
+ .buttonBorderShape(.circle)
+ }
+}
+
+private struct StreamHardwareControlButton: View {
+ let title: LocalizedStringKey
+ let systemImage: String
+ let buttonName: String
+ @Bindable var model: AppModel
+ @State private var isPressed = false
+
+ init(_ title: LocalizedStringKey, systemImage: String, buttonName: String, model: AppModel) {
+ self.title = title
+ self.systemImage = systemImage
+ self.buttonName = buttonName
+ self.model = model
+ }
+
+ var body: some View {
+ StreamControlIconLabel(title: title, systemImage: systemImage)
+ .opacity(isPressed ? 0.45 : 1)
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { _ in pressDown() }
+ .onEnded { _ in pressUp() }
+ )
+ .onDisappear {
+ pressUp()
+ }
+ .accessibilityLabel(title)
+ .accessibilityAddTraits(.isButton)
+ .accessibilityAction {
+ model.tapHardwareButton(named: buttonName)
+ }
+ }
+
+ private func pressDown() {
+ guard !isPressed else { return }
+ isPressed = true
+ model.sendHardwareButton(named: buttonName, phase: .down)
+ }
+
+ private func pressUp() {
+ guard isPressed else { return }
+ isPressed = false
+ model.sendHardwareButton(named: buttonName, phase: .up)
+ }
+}
+
+private struct StreamKeyboardControlButton: View {
+ @Bindable var model: AppModel
+ @Binding var isActive: Bool
+
+ var body: some View {
+ Button {
+ model.hapticSelection()
+ withAnimation(.snappy(duration: 0.25)) {
+ isActive.toggle()
+ }
+ if !isActive {
+ model.dismissSimulatorKeyboard()
+ }
+ } label: {
+ StreamControlIconLabel(title: "Keyboard", systemImage: "keyboard")
+ .opacity(isActive ? 1 : 0.86)
+ .scaleEffect(isActive ? 1.04 : 1)
+ }
+ .buttonStyle(.plain)
+ .buttonBorderShape(.circle)
+ .accessibilityLabel("Keyboard")
+ .accessibilityValue(isActive ? "Active" : "Inactive")
+ }
+}
+
+private struct StreamControlIconLabel: View {
+ let title: LocalizedStringKey
+ let systemImage: String
+
+ @ViewBuilder
+ var body: some View {
+ let content = Label(title, systemImage: systemImage)
+ .labelStyle(.iconOnly)
+ .font(.body)
+ .foregroundStyle(.primary)
+ .frame(width: 44, height: 44)
+ .contentShape(Circle())
+ if #available(iOS 26.0, *) {
+ content
+ .glassEffect(.regular.interactive(), in: .circle)
+ } else {
+ content
+ .background(.ultraThinMaterial, in: Circle())
+ }
+ }
+}
+
+private struct HardwareButtonLayer: View {
+ @Bindable var model: AppModel
+ let chromeProfile: ChromeProfile
+ let layout: DeviceViewportLayout
+
+ var body: some View {
+ ForEach(chromeProfile.buttons ?? [], id: \.self) { button in
+ if let buttonName = button.hardwareWireName, button.width > 0, button.height > 0 {
+ HardwareButtonHitArea(
+ model: model,
+ button: button,
+ buttonName: buttonName,
+ frame: layout.chromeButtonFrame(button)
+ )
+ }
+ }
+ }
+}
+
+private struct HardwareButtonHitArea: View {
+ @Bindable var model: AppModel
+ let button: ChromeButtonProfile
+ let buttonName: String
+ let frame: CGRect
+ @State private var isPressed = false
+
+ var body: some View {
+ Color.clear
+ .frame(width: hitFrame.width, height: hitFrame.height)
+ .contentShape(Rectangle())
+ .position(x: hitFrame.midX, y: hitFrame.midY)
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { _ in pressDown() }
+ .onEnded { _ in pressUp() }
+ )
+ .onDisappear {
+ pressUp()
+ }
+ .accessibilityLabel(Text(button.label ?? button.name))
+ .accessibilityAddTraits(.isButton)
+ .accessibilityAction {
+ model.tapHardwareButton(named: buttonName, usagePage: button.usagePage, usage: button.usage)
+ }
+ }
+
+ private var hitFrame: CGRect {
+ let minimumTarget: CGFloat = 34
+ let width = max(frame.width, minimumTarget)
+ let height = max(frame.height, minimumTarget)
+ return CGRect(
+ x: frame.midX - width / 2,
+ y: frame.midY - height / 2,
+ width: width,
+ height: height
+ )
+ }
+
+ private func pressDown() {
+ guard !isPressed else { return }
+ isPressed = true
+ model.sendHardwareButton(
+ named: buttonName,
+ phase: .down,
+ usagePage: button.usagePage,
+ usage: button.usage
+ )
+ }
+
+ private func pressUp() {
+ guard isPressed else { return }
+ isPressed = false
+ model.sendHardwareButton(
+ named: buttonName,
+ phase: .up,
+ usagePage: button.usagePage,
+ usage: button.usage
+ )
+ }
+}
+
+private struct KeyboardCaptureView: UIViewRepresentable {
+ @Binding var isActive: Bool
+ let onText: (String) -> Void
+ let onDelete: () -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(isActive: $isActive)
+ }
+
+ func makeUIView(context: Context) -> KeyboardCaptureTextView {
+ let view = KeyboardCaptureTextView()
+ view.delegate = context.coordinator
+ view.backgroundColor = .clear
+ view.tintColor = .clear
+ view.textColor = .clear
+ view.isScrollEnabled = false
+ view.autocorrectionType = .no
+ view.autocapitalizationType = .none
+ view.spellCheckingType = .no
+ view.smartDashesType = .no
+ view.smartInsertDeleteType = .no
+ view.smartQuotesType = .no
+ view.keyboardType = .default
+ view.returnKeyType = .default
+ view.textContentType = nil
+ view.textContainerInset = .zero
+ view.textContainer.lineFragmentPadding = 0
+ view.inputAssistantItem.leadingBarButtonGroups = []
+ view.inputAssistantItem.trailingBarButtonGroups = []
+ return view
+ }
+
+ func updateUIView(_ view: KeyboardCaptureTextView, context: Context) {
+ view.onText = onText
+ view.onDelete = onDelete
+ if isActive, !view.isFirstResponder {
+ DispatchQueue.main.async {
+ view.becomeFirstResponder()
+ }
+ } else if !isActive, view.isFirstResponder {
+ DispatchQueue.main.async {
+ view.resignFirstResponder()
+ }
+ }
+ }
+
+ final class Coordinator: NSObject, UITextViewDelegate {
+ var isActive: Binding
+
+ init(isActive: Binding) {
+ self.isActive = isActive
+ }
+
+ func textViewDidEndEditing(_ textView: UITextView) {
+ isActive.wrappedValue = false
+ }
+ }
+}
+
+private final class KeyboardCaptureTextView: UITextView {
+ var onText: ((String) -> Void)?
+ var onDelete: (() -> Void)?
+
+ override var canBecomeFirstResponder: Bool {
+ true
+ }
+
+ override var hasText: Bool {
+ true
+ }
+
+ override func insertText(_ text: String) {
+ onText?(text)
+ }
+
+ override func deleteBackward() {
+ onDelete?()
+ }
+
+ override func paste(_ sender: Any?) {
+ guard let text = UIPasteboard.general.string, !text.isEmpty else { return }
+ onText?(text)
+ }
+}
+
+private struct StreamGlassCapsuleModifier: ViewModifier {
+ let interactive: Bool
+
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ if interactive {
+ content.glassEffect(.regular.interactive(), in: .capsule)
+ } else {
+ content.glassEffect(.regular, in: .capsule)
+ }
+ } else {
+ content.background(.ultraThinMaterial, in: Capsule())
+ }
+ }
+}
+
+private struct StreamGlassCircleModifier: ViewModifier {
+ let interactive: Bool
+
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ if interactive {
+ content.glassEffect(.regular.interactive(), in: .circle)
+ } else {
+ content.glassEffect(.regular, in: .circle)
+ }
+ } else {
+ content.background(.ultraThinMaterial, in: Circle())
+ }
+ }
+}
+
+private struct StreamToolbarButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .foregroundStyle(.primary)
+ .frame(width: 44, height: 44)
+ .contentShape(Rectangle())
+ .opacity(configuration.isPressed ? 0.45 : 1)
+ }
+}
+
+private struct DeviceViewportLayout {
+ let shellFrame: CGRect
+ let screenFrame: CGRect
+ let screenBackingFrame: CGRect
+ let videoFrame: CGRect
+ let screenCornerRadius: CGFloat
+ let screenBackingCornerRadius: CGFloat
+ let usesChrome: Bool
+ private let chromeCoordinateScale: CGFloat
+
+ init(chromeProfile: ChromeProfile?, videoSize: CGSize, availableSize: CGSize) {
+ let viewport = CGRect(origin: .zero, size: availableSize)
+ .insetBy(dx: min(20, availableSize.width * 0.045), dy: 16)
+
+ if let chromeProfile,
+ chromeProfile.totalWidth > 0,
+ chromeProfile.totalHeight > 0,
+ chromeProfile.screenWidth > 0,
+ chromeProfile.screenHeight > 0,
+ viewport.width > 0,
+ viewport.height > 0 {
+ let profileSize = CGSize(width: CGFloat(chromeProfile.totalWidth), height: CGFloat(chromeProfile.totalHeight))
+ let shell = profileSize.aspectFit(in: viewport)
+ let scale = shell.width / profileSize.width
+ let backingRect = Self.chromeBackingRect(profile: chromeProfile)
+ let contentRect = Self.chromeContentRect(profile: chromeProfile) ?? backingRect
+ shellFrame = shell
+ chromeCoordinateScale = scale
+ screenFrame = CGRect(
+ x: shell.minX + contentRect.minX * scale,
+ y: shell.minY + contentRect.minY * scale,
+ width: contentRect.width * scale,
+ height: contentRect.height * scale
+ )
+ screenBackingFrame = CGRect(
+ x: shell.minX + backingRect.minX * scale,
+ y: shell.minY + backingRect.minY * scale,
+ width: backingRect.width * scale,
+ height: backingRect.height * scale
+ )
+ videoFrame = screenFrame
+ screenCornerRadius = Self.screenCornerRadius(
+ profile: chromeProfile,
+ profileScreenRect: contentRect,
+ scale: scale
+ )
+ screenBackingCornerRadius = Self.screenCornerRadius(
+ profile: chromeProfile,
+ profileScreenRect: backingRect,
+ scale: scale
+ )
+ usesChrome = true
+ return
+ }
+
+ let fallbackSize = videoSize.width > 0 && videoSize.height > 0
+ ? videoSize
+ : CGSize(width: 440, height: 956)
+ let screen = fallbackSize.aspectFit(in: viewport)
+ shellFrame = screen
+ screenFrame = screen
+ screenBackingFrame = screen
+ videoFrame = screen
+ screenCornerRadius = min(44, screen.width * 0.14)
+ screenBackingCornerRadius = screenCornerRadius
+ usesChrome = false
+ chromeCoordinateScale = 1
+ }
+
+ func chromeButtonFrame(_ button: ChromeButtonProfile) -> CGRect {
+ guard usesChrome else { return .zero }
+ return CGRect(
+ x: shellFrame.minX + CGFloat(button.x) * chromeCoordinateScale,
+ y: shellFrame.minY + CGFloat(button.y) * chromeCoordinateScale,
+ width: CGFloat(button.width) * chromeCoordinateScale,
+ height: CGFloat(button.height) * chromeCoordinateScale
+ )
+ }
+
+ private static func chromeBackingRect(profile: ChromeProfile) -> CGRect {
+ CGRect(
+ x: CGFloat(profile.screenX),
+ y: CGFloat(profile.screenY),
+ width: CGFloat(profile.screenWidth),
+ height: CGFloat(profile.screenHeight)
+ )
+ }
+
+ private static func chromeContentRect(profile: ChromeProfile) -> CGRect? {
+ guard let contentX = profile.contentX,
+ let contentY = profile.contentY,
+ let contentWidth = profile.contentWidth,
+ let contentHeight = profile.contentHeight,
+ contentWidth > 0,
+ contentHeight > 0 else {
+ return nil
+ }
+ return CGRect(
+ x: CGFloat(contentX),
+ y: CGFloat(contentY),
+ width: CGFloat(contentWidth),
+ height: CGFloat(contentHeight)
+ )
+ }
+
+ private static func screenCornerRadius(profile: ChromeProfile, profileScreenRect: CGRect, scale: CGFloat) -> CGFloat {
+ let fullScreen = CGRect(
+ x: CGFloat(profile.screenX),
+ y: CGFloat(profile.screenY),
+ width: CGFloat(profile.screenWidth),
+ height: CGFloat(profile.screenHeight)
+ )
+ guard abs(profileScreenRect.minX - fullScreen.minX) <= 0.5,
+ abs(profileScreenRect.minY - fullScreen.minY) <= 0.5,
+ abs(profileScreenRect.maxX - fullScreen.maxX) <= 0.5,
+ abs(profileScreenRect.maxY - fullScreen.maxY) <= 0.5 else {
+ if let contentX = profile.contentX,
+ let contentY = profile.contentY,
+ let contentWidth = profile.contentWidth,
+ let contentHeight = profile.contentHeight,
+ abs(profileScreenRect.minX - CGFloat(contentX)) <= 0.5,
+ abs(profileScreenRect.minY - CGFloat(contentY)) <= 0.5,
+ abs(profileScreenRect.width - CGFloat(contentWidth)) <= 0.5,
+ abs(profileScreenRect.height - CGFloat(contentHeight)) <= 0.5 {
+ return min(
+ profileScreenRect.width * scale / 2,
+ profileScreenRect.height * scale / 2,
+ CGFloat(profile.cornerRadius) * scale
+ )
+ }
+ return 0
+ }
+ return min(
+ profileScreenRect.width * scale / 2,
+ profileScreenRect.height * scale / 2,
+ CGFloat(profile.cornerRadius) * scale
+ )
+ }
+}
+
+private extension ChromeButtonProfile {
+ var hardwareWireName: String? {
+ switch name.lowercased() {
+ case "action":
+ "action"
+ case "digital-crown", "crown":
+ "digital-crown"
+ case "home":
+ "home"
+ case "left-side-button":
+ "left-side-button"
+ case "lock", "power":
+ "power"
+ case "mute":
+ "mute"
+ case "side-button":
+ "side-button"
+ case "volume-down":
+ "volume-down"
+ case "volume-up":
+ "volume-up"
+ default:
+ nil
+ }
+ }
+}
+
+private extension CGSize {
+ func aspectFit(in rect: CGRect) -> CGRect {
+ guard width > 0, height > 0, rect.width > 0, rect.height > 0 else {
+ return .zero
+ }
+ let scale = min(rect.width / width, rect.height / height)
+ let fittedSize = CGSize(width: width * scale, height: height * scale)
+ return CGRect(
+ x: rect.midX - fittedSize.width / 2,
+ y: rect.midY - fittedSize.height / 2,
+ width: fittedSize.width,
+ height: fittedSize.height
+ )
+ }
+}
+
+private extension View {
+ @ViewBuilder
+ func clippedToSimulatorScreen(cornerRadius: CGFloat, maskImage: UIImage?) -> some View {
+ if let maskImage {
+ self.mask(
+ Image(uiImage: maskImage)
+ .resizable()
+ .scaledToFill()
+ )
+ } else {
+ self.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
+ }
+ }
+
+ @ViewBuilder
+ func streamTouchGesture(_ enabled: Bool, gesture: G) -> some View {
+ if enabled {
+ self.gesture(gesture)
+ } else {
+ self
+ }
+ }
+}
diff --git a/ios/Vendor/WebRTC.xcframework/Info.plist b/ios/Vendor/WebRTC.xcframework/Info.plist
new file mode 100644
index 00000000..ffc98c42
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/Info.plist
@@ -0,0 +1,40 @@
+
+
+
+
+ AvailableLibraries
+
+
+ LibraryIdentifier
+ ios-arm64
+ LibraryPath
+ WebRTC.framework
+ SupportedArchitectures
+
+ arm64
+
+ SupportedPlatform
+ ios
+
+
+ LibraryIdentifier
+ ios-x86_64_arm64-simulator
+ LibraryPath
+ WebRTC.framework
+ SupportedArchitectures
+
+ arm64
+ x86_64
+
+ SupportedPlatform
+ ios
+ SupportedPlatformVariant
+ simulator
+
+
+ CFBundlePackageType
+ XFWK
+ XCFrameworkFormatVersion
+ 1.0
+
+
diff --git a/ios/Vendor/WebRTC.xcframework/LICENSE b/ios/Vendor/WebRTC.xcframework/LICENSE
new file mode 100644
index 00000000..4c41b7b2
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/LICENSE
@@ -0,0 +1,29 @@
+Copyright (c) 2011, The WebRTC project authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+ * Neither the name of Google nor the names of its contributors may
+ be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioDevice.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioDevice.h
new file mode 100644
index 00000000..c28076ca
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioDevice.h
@@ -0,0 +1,335 @@
+/*
+ * Copyright 2022 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+typedef OSStatus (^RTC_OBJC_TYPE(RTCAudioDeviceGetPlayoutDataBlock))(
+ AudioUnitRenderActionFlags *_Nonnull actionFlags,
+ const AudioTimeStamp *_Nonnull timestamp,
+ NSInteger inputBusNumber,
+ UInt32 frameCount,
+ AudioBufferList *_Nonnull outputData);
+
+typedef OSStatus (^RTC_OBJC_TYPE(RTCAudioDeviceRenderRecordedDataBlock))(
+ AudioUnitRenderActionFlags *_Nonnull actionFlags,
+ const AudioTimeStamp *_Nonnull timestamp,
+ NSInteger inputBusNumber,
+ UInt32 frameCount,
+ AudioBufferList *_Nonnull inputData,
+ void *_Nullable renderContext);
+
+typedef OSStatus (^RTC_OBJC_TYPE(RTCAudioDeviceDeliverRecordedDataBlock))(
+ AudioUnitRenderActionFlags *_Nonnull actionFlags,
+ const AudioTimeStamp *_Nonnull timestamp,
+ NSInteger inputBusNumber,
+ UInt32 frameCount,
+ const AudioBufferList *_Nullable inputData,
+ void *_Nullable renderContext,
+ NS_NOESCAPE RTC_OBJC_TYPE(
+ RTCAudioDeviceRenderRecordedDataBlock) _Nullable renderBlock);
+
+/**
+ * Delegate object provided by native ADM during RTCAudioDevice initialization.
+ * Provides blocks to poll playback audio samples from native ADM and to feed
+ * recorded audio samples into native ADM.
+ */
+RTC_OBJC_EXPORT @protocol RTC_OBJC_TYPE
+(RTCAudioDeviceDelegate)
+ /**
+ * Implementation of RTCAudioSource should call this block to feed recorded
+ * PCM (16-bit integer) into native ADM. Stereo data is expected to be
+ * interleaved starting with the left channel. Either `inputData` with
+ * pre-filled audio data must be provided during block call or `renderBlock`
+ * must be provided which must fill provided audio buffer with recorded
+ * samples.
+ *
+ * NOTE: Implementation of RTCAudioDevice is expected to call the block on
+ * the same thread until `notifyAudioInterrupted` is called. When
+ * `notifyAudioInterrupted` is called implementation can call the block on a
+ * different thread.
+ */
+ @property(readonly, nonnull)
+ RTC_OBJC_TYPE(RTCAudioDeviceDeliverRecordedDataBlock)
+ deliverRecordedData;
+
+/**
+ * Provides input sample rate preference as it preferred by native ADM.
+ */
+@property(readonly) double preferredInputSampleRate;
+
+/**
+ * Provides input IO buffer duration preference as it preferred by native ADM.
+ */
+@property(readonly) NSTimeInterval preferredInputIOBufferDuration;
+
+/**
+ * Provides output sample rate preference as it preferred by native ADM.
+ */
+@property(readonly) double preferredOutputSampleRate;
+
+/**
+ * Provides output IO buffer duration preference as it preferred by native ADM.
+ */
+@property(readonly) NSTimeInterval preferredOutputIOBufferDuration;
+
+/**
+ * Implementation of RTCAudioDevice should call this block to request PCM
+ * (16-bit integer) from native ADM to play. Stereo data is interleaved starting
+ * with the left channel.
+ *
+ * NOTE: Implementation of RTCAudioDevice is expected to invoke of this block on
+ * the same thread until `notifyAudioInterrupted` is called. When
+ * `notifyAudioInterrupted` is called implementation can call the block from a
+ * different thread.
+ */
+@property(readonly, nonnull) RTC_OBJC_TYPE(RTCAudioDeviceGetPlayoutDataBlock)
+ getPlayoutData;
+
+/**
+ * Notifies native ADM that some of the audio input parameters of RTCAudioDevice
+ * like samle rate and/or IO buffer duration and/or IO latency had possibly
+ * changed. Native ADM will adjust its audio input buffer to match current
+ * parameters of audio device.
+ *
+ * NOTE: Must be called within block executed via `dispatchAsync` or
+ * `dispatchSync`.
+ */
+- (void)notifyAudioInputParametersChange;
+
+/**
+ * Notifies native ADM that some of the audio output parameters of
+ * RTCAudioDevice like samle rate and/or IO buffer duration and/or IO latency
+ * had possibly changed. Native ADM will adjust its audio output buffer to match
+ * current parameters of audio device.
+ *
+ * NOTE: Must be called within block executed via `dispatchAsync` or
+ * `dispatchSync`.
+ */
+- (void)notifyAudioOutputParametersChange;
+
+/**
+ * Notifies native ADM that audio input is interrupted and further audio playout
+ * and recording might happen on a different thread.
+ *
+ * NOTE: Must be called within block executed via `dispatchAsync` or
+ * `dispatchSync`.
+ */
+- (void)notifyAudioInputInterrupted;
+
+/**
+ * Notifies native ADM that audio output is interrupted and further audio
+ * playout and recording might happen on a different thread.
+ *
+ * NOTE: Must be called within block executed via `dispatchAsync` or
+ * `dispatchSync`.
+ */
+- (void)notifyAudioOutputInterrupted;
+
+/**
+ * Asynchronously execute block of code within the context of
+ * thread which owns native ADM.
+ *
+ * NOTE: Intended to be used to invoke `notifyAudioInputParametersChange`,
+ * `notifyAudioOutputParametersChange`, `notifyAudioInputInterrupted`,
+ * `notifyAudioOutputInterrupted` on native ADM thread.
+ * Also could be used by `RTCAudioDevice` implementation to tie
+ * mutations of underlying audio objects (AVAudioEngine, AudioUnit, etc)
+ * to the native ADM thread. Could be useful to handle events like audio route
+ * change, which could lead to audio parameters change.
+ */
+- (void)dispatchAsync:(dispatch_block_t)block;
+
+/**
+ * Synchronously execute block of code within the context of
+ * thread which owns native ADM. Allows reentrancy.
+ *
+ * NOTE: Intended to be used to invoke `notifyAudioInputParametersChange`,
+ * `notifyAudioOutputParametersChange`, `notifyAudioInputInterrupted`,
+ * `notifyAudioOutputInterrupted` on native ADM thread and make sure
+ * aforementioned is completed before `dispatchSync` returns. Could be useful
+ * when implementation of `RTCAudioDevice` tie mutation to underlying audio
+ * objects (AVAudioEngine, AudioUnit, etc) to own thread to satisfy requirement
+ * that native ADM audio parameters must be kept in sync with current audio
+ * parameters before audio is actually played or recorded.
+ */
+- (void)dispatchSync:(dispatch_block_t)block;
+
+@end
+
+/**
+ * Protocol to abstract platform specific ways to implement playback and
+ * recording.
+ *
+ * NOTE: All the members of protocol are called by native ADM from the same
+ * thread between calls to `initializeWithDelegate` and `terminate`. NOTE:
+ * Implementation is fully responsible for configuring application's
+ * AVAudioSession. An example implementation of RTCAudioDevice:
+ * https://github.com/mstyura/RTCAudioDevice
+ * TODO(yura.yaroshevich): Implement custom RTCAudioDevice for AppRTCMobile demo
+ * app.
+ */
+RTC_OBJC_EXPORT @protocol RTC_OBJC_TYPE
+(RTCAudioDevice)
+
+ /**
+ * Indicates current sample rate of audio recording. Changes to this
+ * property must be notified back to native ADM via
+ * `-[RTCAudioDeviceDelegate notifyAudioParametersChange]`.
+ */
+ @property(readonly) double deviceInputSampleRate;
+
+/**
+ * Indicates current size of record buffer. Changes to this property
+ * must be notified back to native ADM via `-[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) NSTimeInterval inputIOBufferDuration;
+
+/**
+ * Indicates current number of recorded audio channels. Changes to this property
+ * must be notified back to native ADM via `-[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) NSInteger inputNumberOfChannels;
+
+/**
+ * Indicates current input latency
+ */
+@property(readonly) NSTimeInterval inputLatency;
+
+/**
+ * Indicates current sample rate of audio playback. Changes to this property
+ * must be notified back to native ADM via `-[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) double deviceOutputSampleRate;
+
+/**
+ * Indicates current size of playback buffer. Changes to this property
+ * must be notified back to native ADM via `-[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) NSTimeInterval outputIOBufferDuration;
+
+/**
+ * Indicates current number of playback audio channels. Changes to this property
+ * must be notified back to WebRTC via `[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) NSInteger outputNumberOfChannels;
+
+/**
+ * Indicates current output latency
+ */
+@property(readonly) NSTimeInterval outputLatency;
+
+/**
+ * Indicates if invocation of `initializeWithDelegate` required before usage of
+ * RTCAudioDevice. YES indicates that `initializeWithDelegate` was called
+ * earlier without subsequent call to `terminate`. NO indicates that either
+ * `initializeWithDelegate` not called or `terminate` called.
+ */
+@property(readonly) BOOL isInitialized;
+
+/**
+ * Initializes RTCAudioDevice with RTCAudioDeviceDelegate.
+ * Implementation must return YES if RTCAudioDevice initialized successfully and
+ * NO otherwise.
+ */
+- (BOOL)initializeWithDelegate:
+ (id)delegate;
+
+/**
+ * De-initializes RTCAudioDevice. Implementation should forget about `delegate`
+ * provided in `initializeWithDelegate`.
+ */
+- (BOOL)terminateDevice;
+
+/**
+ * Property to indicate if `initializePlayout` call required before invocation
+ * of `startPlayout`. YES indicates that `initializePlayout` was successfully
+ * invoked earlier or not necessary, NO indicates that `initializePlayout`
+ * invocation required.
+ */
+@property(readonly) BOOL isPlayoutInitialized;
+
+/**
+ * Prepares RTCAudioDevice to play audio.
+ * Called by native ADM before invocation of `startPlayout`.
+ * Implementation is expected to return YES in case of successful playout
+ * initialization and NO otherwise.
+ */
+- (BOOL)initializePlayout;
+
+/**
+ * Property to indicate if RTCAudioDevice should be playing according to
+ * earlier calls of `startPlayout` and `stopPlayout`.
+ */
+@property(readonly) BOOL isPlaying;
+
+/**
+ * Method is called when native ADM wants to play audio.
+ * Implementation is expected to return YES if playback start request
+ * successfully handled and NO otherwise.
+ */
+- (BOOL)startPlayout;
+
+/**
+ * Method is called when native ADM no longer needs to play audio.
+ * Implementation is expected to return YES if playback stop request
+ * successfully handled and NO otherwise.
+ */
+- (BOOL)stopPlayout;
+
+/**
+ * Property to indicate if `initializeRecording` call required before usage of
+ * `startRecording`. YES indicates that `initializeRecording` was successfully
+ * invoked earlier or not necessary, NO indicates that `initializeRecording`
+ * invocation required.
+ */
+@property(readonly) BOOL isRecordingInitialized;
+
+/**
+ * Prepares RTCAudioDevice to record audio.
+ * Called by native ADM before invocation of `startRecording`.
+ * Implementation may use this method to prepare resources required to record
+ * audio. Implementation is expected to return YES in case of successful record
+ * initialization and NO otherwise.
+ */
+- (BOOL)initializeRecording;
+
+/**
+ * Property to indicate if RTCAudioDevice should record audio according to
+ * earlier calls to `startRecording` and `stopRecording`.
+ */
+@property(readonly) BOOL isRecording;
+
+/**
+ * Method is called when native ADM wants to record audio.
+ * Implementation is expected to return YES if recording start request
+ * successfully handled and NO otherwise.
+ */
+- (BOOL)startRecording;
+
+/**
+ * Method is called when native ADM no longer needs to record audio.
+ * Implementation is expected to return YES if recording stop request
+ * successfully handled and NO otherwise.
+ */
+- (BOOL)stopRecording;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSession.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSession.h
new file mode 100644
index 00000000..08ecabfa
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSession.h
@@ -0,0 +1,289 @@
+/*
+ * Copyright 2016 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+extern NSString *const kRTCAudioSessionErrorDomain;
+/** Method that requires lock was called without lock. */
+extern NSInteger const kRTCAudioSessionErrorLockRequired;
+/** Unknown configuration error occurred. */
+extern NSInteger const kRTCAudioSessionErrorConfiguration;
+
+@class RTC_OBJC_TYPE(RTCAudioSession);
+@class RTC_OBJC_TYPE(RTCAudioSessionConfiguration);
+
+// Surfaces AVAudioSession events. WebRTC will listen directly for notifications
+// from AVAudioSession and handle them before calling these delegate methods,
+// at which point applications can perform additional processing if required.
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCAudioSessionDelegate)
+
+ @optional
+/** Called on a system notification thread when AVAudioSession starts an
+ * interruption event.
+ */
+- (void)audioSessionDidBeginInterruption:
+ (RTC_OBJC_TYPE(RTCAudioSession) *)session;
+
+/** Called on a system notification thread when AVAudioSession ends an
+ * interruption event.
+ */
+- (void)audioSessionDidEndInterruption:(RTC_OBJC_TYPE(RTCAudioSession) *)session
+ shouldResumeSession:(BOOL)shouldResumeSession;
+
+/** Called on a system notification thread when AVAudioSession changes the
+ * route.
+ */
+- (void)audioSessionDidChangeRoute:(RTC_OBJC_TYPE(RTCAudioSession) *)session
+ reason:(AVAudioSessionRouteChangeReason)reason
+ previousRoute:
+ (AVAudioSessionRouteDescription *)previousRoute;
+
+/** Called on a system notification thread when AVAudioSession media server
+ * terminates.
+ */
+- (void)audioSessionMediaServerTerminated:
+ (RTC_OBJC_TYPE(RTCAudioSession) *)session;
+
+/** Called on a system notification thread when AVAudioSession media server
+ * restarts.
+ */
+- (void)audioSessionMediaServerReset:(RTC_OBJC_TYPE(RTCAudioSession) *)session;
+
+// TODO(tkchin): Maybe handle SilenceSecondaryAudioHintNotification.
+
+- (void)audioSession:(RTC_OBJC_TYPE(RTCAudioSession) *)session
+ didChangeCanPlayOrRecord:(BOOL)canPlayOrRecord;
+
+/** Called on a WebRTC thread when the audio device is notified to begin
+ * playback or recording.
+ */
+- (void)audioSessionDidStartPlayOrRecord:
+ (RTC_OBJC_TYPE(RTCAudioSession) *)session;
+
+/** Called on a WebRTC thread when the audio device is notified to stop
+ * playback or recording.
+ */
+- (void)audioSessionDidStopPlayOrRecord:
+ (RTC_OBJC_TYPE(RTCAudioSession) *)session;
+
+/** Called when the AVAudioSession output volume value changes. */
+- (void)audioSession:(RTC_OBJC_TYPE(RTCAudioSession) *)audioSession
+ didChangeOutputVolume:(float)outputVolume;
+
+/** Called when the audio device detects a playout glitch. The argument is the
+ * number of glitches detected so far in the current audio playout session.
+ */
+- (void)audioSession:(RTC_OBJC_TYPE(RTCAudioSession) *)audioSession
+ didDetectPlayoutGlitch:(int64_t)totalNumberOfGlitches;
+
+/** Called when the audio session is about to change the active state.
+ */
+- (void)audioSession:(RTC_OBJC_TYPE(RTCAudioSession) *)audioSession
+ willSetActive:(BOOL)active;
+
+/** Called after the audio session sucessfully changed the active state.
+ */
+- (void)audioSession:(RTC_OBJC_TYPE(RTCAudioSession) *)audioSession
+ didSetActive:(BOOL)active;
+
+/** Called after the audio session failed to change the active state.
+ */
+- (void)audioSession:(RTC_OBJC_TYPE(RTCAudioSession) *)audioSession
+ failedToSetActive:(BOOL)active
+ error:(NSError *)error;
+
+- (void)audioSession:(RTC_OBJC_TYPE(RTCAudioSession) *)audioSession
+ audioUnitStartFailedWithError:(NSError *)error;
+
+@end
+
+/** This is a protocol used to inform RTCAudioSession when the audio session
+ * activation state has changed outside of RTCAudioSession. The current known
+ * use case of this is when CallKit activates the audio session for the
+ * application
+ */
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCAudioSessionActivationDelegate)
+
+ /** Called when the audio session is activated outside of the app by iOS. */
+ - (void)audioSessionDidActivate : (AVAudioSession *)session;
+
+/** Called when the audio session is deactivated outside of the app by iOS. */
+- (void)audioSessionDidDeactivate:(AVAudioSession *)session;
+
+@end
+
+/** Proxy class for AVAudioSession that adds a locking mechanism similar to
+ * AVCaptureDevice. This is used to that interleaving configurations between
+ * WebRTC and the application layer are avoided.
+ *
+ * RTCAudioSession also coordinates activation so that the audio session is
+ * activated only once. See `setActive:error:`.
+ */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCAudioSession) : NSObject
+
+/** Convenience property to access the AVAudioSession singleton. Callers should
+ * not call setters on AVAudioSession directly, but other method invocations
+ * are fine.
+ */
+@property(nonatomic, readonly) AVAudioSession *session;
+
+/** Our best guess at whether the session is active based on results of calls to
+ * AVAudioSession.
+ */
+@property(nonatomic, readonly) BOOL isActive;
+
+/** If YES, WebRTC will not initialize the audio unit automatically when an
+ * audio track is ready for playout or recording. Instead, applications should
+ * call setIsAudioEnabled. If NO, WebRTC will initialize the audio unit
+ * as soon as an audio track is ready for playout or recording.
+ */
+@property(nonatomic, assign) BOOL useManualAudio;
+
+/** This property is only effective if useManualAudio is YES.
+ * Represents permission for WebRTC to initialize the VoIP audio unit.
+ * When set to NO, if the VoIP audio unit used by WebRTC is active, it will be
+ * stopped and uninitialized. This will stop incoming and outgoing audio.
+ * When set to YES, WebRTC will initialize and start the audio unit when it is
+ * needed (e.g. due to establishing an audio connection).
+ * This property was introduced to work around an issue where if an AVPlayer is
+ * playing audio while the VoIP audio unit is initialized, its audio would be
+ * either cut off completely or played at a reduced volume. By preventing
+ * the audio unit from being initialized until after the audio has completed,
+ * we are able to prevent the abrupt cutoff.
+ */
+@property(nonatomic, assign) BOOL isAudioEnabled;
+
+// Proxy properties.
+@property(readonly) NSString *category;
+@property(readonly) AVAudioSessionCategoryOptions categoryOptions;
+@property(readonly) NSString *mode;
+@property(readonly) BOOL secondaryAudioShouldBeSilencedHint;
+@property(readonly) AVAudioSessionRouteDescription *currentRoute;
+@property(readonly) NSInteger maximumInputNumberOfChannels;
+@property(readonly) NSInteger maximumOutputNumberOfChannels;
+@property(readonly) float inputGain;
+@property(readonly) BOOL inputGainSettable;
+@property(readonly) BOOL inputAvailable;
+@property(readonly, nullable)
+ NSArray *inputDataSources;
+@property(readonly, nullable)
+ AVAudioSessionDataSourceDescription *inputDataSource;
+@property(readonly, nullable)
+ NSArray *outputDataSources;
+@property(readonly, nullable)
+ AVAudioSessionDataSourceDescription *outputDataSource;
+@property(readonly) double sampleRate;
+@property(readonly) double preferredSampleRate;
+@property(readonly) NSInteger inputNumberOfChannels;
+@property(readonly) NSInteger outputNumberOfChannels;
+@property(readonly) float outputVolume;
+@property(readonly) NSTimeInterval inputLatency;
+@property(readonly) NSTimeInterval outputLatency;
+@property(readonly) NSTimeInterval IOBufferDuration;
+@property(readonly) NSTimeInterval preferredIOBufferDuration;
+
+/**
+ When YES, calls to -setConfiguration:error: and -setConfiguration:active:error:
+ ignore errors in configuring the audio session's "preferred" attributes (e.g.
+ preferredInputNumberOfChannels). Typically, configurations to preferred
+ attributes are optimizations, and ignoring this type of configuration error
+ allows code flow to continue along the happy path when these optimization are
+ not available. The default value of this property is NO.
+ */
+@property(nonatomic) BOOL ignoresPreferredAttributeConfigurationErrors;
+
+/** Default constructor. */
++ (instancetype)sharedInstance;
+- (instancetype)init NS_UNAVAILABLE;
+
+/** Adds a delegate, which is held weakly. */
+- (void)addDelegate:(id)delegate;
+/** Removes an added delegate. */
+- (void)removeDelegate:(id)delegate;
+
+/** Request exclusive access to the audio session for configuration. This call
+ * will block if the lock is held by another object.
+ */
+- (void)lockForConfiguration;
+/** Relinquishes exclusive access to the audio session. */
+- (void)unlockForConfiguration;
+
+/** If `active`, activates the audio session if it isn't already active.
+ * Successful calls must be balanced with a setActive:NO when activation is no
+ * longer required. If not `active`, deactivates the audio session if one is
+ * active and this is the last balanced call. When deactivating, the
+ * AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation option is passed to
+ * AVAudioSession.
+ */
+- (BOOL)setActive:(BOOL)active error:(NSError **)outError;
+
+// The following methods are proxies for the associated methods on
+// AVAudioSession. `lockForConfiguration` must be called before using them
+// otherwise they will fail with kRTCAudioSessionErrorLockRequired.
+
+- (BOOL)setCategory:(AVAudioSessionCategory)category
+ mode:(AVAudioSessionMode)mode
+ options:(AVAudioSessionCategoryOptions)options
+ error:(NSError **)outError;
+- (BOOL)setCategory:(AVAudioSessionCategory)category
+ withOptions:(AVAudioSessionCategoryOptions)options
+ error:(NSError **)outError;
+- (BOOL)setMode:(AVAudioSessionMode)mode error:(NSError **)outError;
+- (BOOL)setInputGain:(float)gain error:(NSError **)outError;
+- (BOOL)setPreferredSampleRate:(double)sampleRate error:(NSError **)outError;
+- (BOOL)setPreferredIOBufferDuration:(NSTimeInterval)duration
+ error:(NSError **)outError;
+- (BOOL)setPreferredInputNumberOfChannels:(NSInteger)count
+ error:(NSError **)outError;
+- (BOOL)setPreferredOutputNumberOfChannels:(NSInteger)count
+ error:(NSError **)outError;
+- (BOOL)overrideOutputAudioPort:(AVAudioSessionPortOverride)portOverride
+ error:(NSError **)outError;
+- (BOOL)setPreferredInput:(AVAudioSessionPortDescription *)inPort
+ error:(NSError **)outError;
+- (BOOL)setInputDataSource:(AVAudioSessionDataSourceDescription *)dataSource
+ error:(NSError **)outError;
+- (BOOL)setOutputDataSource:(AVAudioSessionDataSourceDescription *)dataSource
+ error:(NSError **)outError;
+@end
+
+@interface RTC_OBJC_TYPE (RTCAudioSession)
+(Configuration)
+
+ /** Applies the configuration to the current session. Attempts to set all
+ * properties even if previous ones fail. Only the last error will be
+ * returned.
+ * `lockForConfiguration` must be called first.
+ */
+ - (BOOL)setConfiguration
+ : (RTC_OBJC_TYPE(RTCAudioSessionConfiguration) *)configuration error
+ : (NSError **)outError;
+
+/** Convenience method that calls both setConfiguration and setActive.
+ * `lockForConfiguration` must be called first.
+ */
+- (BOOL)setConfiguration:
+ (RTC_OBJC_TYPE(RTCAudioSessionConfiguration) *)configuration
+ active:(BOOL)active
+ error:(NSError **)outError;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSessionConfiguration.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSessionConfiguration.h
new file mode 100644
index 00000000..b937f160
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSessionConfiguration.h
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2016 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_EXTERN const int kRTCAudioSessionPreferredNumberOfChannels;
+RTC_EXTERN const double kRTCAudioSessionHighPerformanceSampleRate;
+RTC_EXTERN const double kRTCAudioSessionHighPerformanceIOBufferDuration;
+
+// Struct to hold configuration values.
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCAudioSessionConfiguration) : NSObject
+
+@property(nonatomic, strong) NSString *category;
+@property(nonatomic, assign) AVAudioSessionCategoryOptions categoryOptions;
+@property(nonatomic, strong) NSString *mode;
+@property(nonatomic, assign) double sampleRate;
+@property(nonatomic, assign) NSTimeInterval ioBufferDuration;
+@property(nonatomic, assign) NSInteger inputNumberOfChannels;
+@property(nonatomic, assign) NSInteger outputNumberOfChannels;
+
+/** Initializes configuration to defaults. */
+- (instancetype)init NS_DESIGNATED_INITIALIZER;
+
+/** Returns the current configuration of the audio session. */
++ (instancetype)currentConfiguration;
+/** Returns the configuration that WebRTC needs. */
++ (instancetype)webRTCConfiguration;
+/** Provide a way to override the default configuration. */
++ (void)setWebRTCConfiguration:
+ (RTC_OBJC_TYPE(RTCAudioSessionConfiguration) *)configuration;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSource.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSource.h
new file mode 100644
index 00000000..784864a7
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioSource.h
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2016 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCAudioSource) : RTC_OBJC_TYPE(RTCMediaSource)
+
+- (instancetype)init NS_UNAVAILABLE;
+
+// Sets the volume for the RTCMediaSource. `volume` is a gain value in the range
+// [0, 10].
+// Temporary fix to be able to modify volume of remote audio tracks.
+// TODO(kthelgason): Property stays here temporarily until a proper volume-api
+// is available on the surface exposed by webrtc.
+@property(nonatomic, assign) double volume;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioTrack.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioTrack.h
new file mode 100644
index 00000000..3c6d1dcf
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCAudioTrack.h
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class RTC_OBJC_TYPE(RTCAudioSource);
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCAudioTrack) : RTC_OBJC_TYPE(RTCMediaStreamTrack)
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/** The audio source for this audio track. */
+@property(nonatomic, readonly) RTC_OBJC_TYPE(RTCAudioSource) * source;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCVPixelBuffer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCVPixelBuffer.h
new file mode 100644
index 00000000..dd112b4e
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCVPixelBuffer.h
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** RTCVideoFrameBuffer containing a CVPixelBufferRef */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCCVPixelBuffer) : NSObject
+
+@property(nonatomic, readonly) CVPixelBufferRef pixelBuffer;
+@property(nonatomic, readonly) int cropX;
+@property(nonatomic, readonly) int cropY;
+@property(nonatomic, readonly) int cropWidth;
+@property(nonatomic, readonly) int cropHeight;
+
++ (NSSet *)supportedPixelFormats;
+
+- (instancetype)initWithPixelBuffer:(CVPixelBufferRef)pixelBuffer;
+- (instancetype)initWithPixelBuffer:(CVPixelBufferRef)pixelBuffer
+ adaptedWidth:(int)adaptedWidth
+ adaptedHeight:(int)adaptedHeight
+ cropWidth:(int)cropWidth
+ cropHeight:(int)cropHeight
+ cropX:(int)cropX
+ cropY:(int)cropY;
+
+- (BOOL)requiresCropping;
+- (BOOL)requiresScalingToWidth:(int)width height:(int)height;
+- (int)bufferSizeForCroppingAndScalingToWidth:(int)width height:(int)height;
+
+/** The minimum size of the `tmpBuffer` must be the number of bytes returned
+ * from the bufferSizeForCroppingAndScalingToWidth:height: method. If that size
+ * is 0, the `tmpBuffer` may be nil.
+ */
+- (BOOL)cropAndScaleTo:(CVPixelBufferRef)outputPixelBuffer
+ withTempBuffer:(nullable uint8_t *)tmpBuffer;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCallbackLogger.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCallbackLogger.h
new file mode 100644
index 00000000..7e2745b6
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCallbackLogger.h
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+typedef void (^RTCCallbackLoggerMessageHandler)(NSString *message);
+typedef void (^RTCCallbackLoggerMessageAndSeverityHandler)(
+ NSString *message, RTCLoggingSeverity severity);
+
+// This class intercepts WebRTC logs and forwards them to a registered block.
+// This class is not threadsafe.
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCCallbackLogger) : NSObject
+
+// The severity level to capture. The default is kRTCLoggingSeverityInfo.
+@property(nonatomic, assign) RTCLoggingSeverity severity;
+
+// The callback handler will be called on the same thread that does the
+// logging, so if the logging callback can be slow it may be a good idea
+// to implement dispatching to some other queue.
+- (void)start:(nullable RTCCallbackLoggerMessageHandler)handler;
+- (void)startWithMessageAndSeverityHandler:
+ (nullable RTCCallbackLoggerMessageAndSeverityHandler)handler;
+
+- (void)stop;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCameraPreviewView.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCameraPreviewView.h
new file mode 100644
index 00000000..710f2e79
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCameraPreviewView.h
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2015 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+#import
+
+@class AVCaptureSession;
+
+/** RTCCameraPreviewView is a view that renders local video from an
+ * AVCaptureSession.
+ */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCCameraPreviewView) : UIView
+
+/** The capture session being rendered in the view. Capture session
+ * is assigned to AVCaptureVideoPreviewLayer async in the same
+ * queue that the AVCaptureSession is started/stopped.
+ */
+@property(nonatomic, strong) AVCaptureSession* captureSession;
+
+@end
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCameraVideoCapturer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCameraVideoCapturer.h
new file mode 100644
index 00000000..de69c5d2
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCameraVideoCapturer.h
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+// Camera capture that implements RTCVideoCapturer. Delivers frames to a
+// RTCVideoCapturerDelegate (usually RTCVideoSource).
+NS_EXTENSION_UNAVAILABLE_IOS("Camera not available in app extensions.")
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCCameraVideoCapturer) : RTC_OBJC_TYPE(RTCVideoCapturer)
+
+// Capture session that is used for capturing. Valid from initialization to dealloc.
+@property(readonly, nonatomic) AVCaptureSession *captureSession;
+
+// Returns list of available capture devices that support video capture.
++ (NSArray *)captureDevices;
+// Returns list of formats that are supported by this class for this device.
++ (NSArray *)supportedFormatsForDevice:
+ (AVCaptureDevice *)device;
+
+// Returns the most efficient supported output pixel format for this capturer.
+- (FourCharCode)preferredOutputPixelFormat;
+
+// Starts the capture session asynchronously and notifies callback on
+// completion. The device will capture video in the format given in the `format`
+// parameter. If the pixel format in `format` is supported by the WebRTC
+// pipeline, the same pixel format will be used for the output. Otherwise, the
+// format returned by `preferredOutputPixelFormat` will be used.
+- (void)startCaptureWithDevice:(AVCaptureDevice *)device
+ format:(AVCaptureDeviceFormat *)format
+ fps:(NSInteger)fps
+ completionHandler:
+ (nullable void (^)(NSError *_Nullable))completionHandler;
+// Stops the capture session asynchronously and notifies callback on completion.
+- (void)stopCaptureWithCompletionHandler:
+ (nullable void (^)(void))completionHandler;
+
+// Starts the capture session asynchronously.
+- (void)startCaptureWithDevice:(AVCaptureDevice *)device
+ format:(AVCaptureDeviceFormat *)format
+ fps:(NSInteger)fps;
+// Stops the capture session asynchronously.
+- (void)stopCapture;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCertificate.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCertificate.h
new file mode 100644
index 00000000..e300febb
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCertificate.h
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCCertificate) : NSObject
+
+/** Private key in PEM. */
+@property(nonatomic, readonly, copy) NSString *private_key;
+
+/** Public key in an x509 cert encoded in PEM. */
+@property(nonatomic, readonly, copy) NSString *certificate;
+
+/**
+ * Initialize an RTCCertificate with PEM strings for private_key and
+ * certificate.
+ */
+- (instancetype)initWithPrivateKey:(NSString *)private_key
+ certificate:(NSString *)certificate
+ NS_DESIGNATED_INITIALIZER;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/** Generate a new certificate for 're' use.
+ *
+ * Optional dictionary of parameters. Defaults to KeyType ECDSA if none are
+ * provided.
+ * - name: "ECDSA" or "RSASSA-PKCS1-v1_5"
+ */
++ (nullable RTC_OBJC_TYPE(RTCCertificate) *)generateCertificateWithParams:
+ (NSDictionary *)params;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCodecSpecificInfo.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCodecSpecificInfo.h
new file mode 100644
index 00000000..39f7c183
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCodecSpecificInfo.h
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Implement this protocol to pass codec specific info from the encoder.
+ * Corresponds to webrtc::CodecSpecificInfo.
+ */
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCCodecSpecificInfo) @end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCodecSpecificInfoH264.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCodecSpecificInfoH264.h
new file mode 100644
index 00000000..b6f34a54
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCodecSpecificInfoH264.h
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+/** Class for H264 specific config. */
+typedef NS_ENUM(NSUInteger, RTCH264PacketizationMode) {
+ RTCH264PacketizationModeNonInterleaved =
+ 0, // Mode 1 - STAP-A, FU-A is allowed
+ RTCH264PacketizationModeSingleNalUnit // Mode 0 - only single NALU allowed
+};
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCCodecSpecificInfoH264) : NSObject
+
+@property(nonatomic, assign) RTCH264PacketizationMode packetizationMode;
+
+@end
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCConfiguration.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCConfiguration.h
new file mode 100644
index 00000000..21f12fcd
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCConfiguration.h
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+#import
+
+@class RTC_OBJC_TYPE(RTCIceServer);
+
+/**
+ * Represents the ice transport policy. This exposes the same states in C++,
+ * which include one more state than what exists in the W3C spec.
+ */
+typedef NS_ENUM(NSInteger, RTCIceTransportPolicy) {
+ RTCIceTransportPolicyNone,
+ RTCIceTransportPolicyRelay,
+ RTCIceTransportPolicyNoHost,
+ RTCIceTransportPolicyAll
+};
+
+/** Represents the bundle policy. */
+typedef NS_ENUM(NSInteger, RTCBundlePolicy) {
+ RTCBundlePolicyBalanced,
+ RTCBundlePolicyMaxCompat,
+ RTCBundlePolicyMaxBundle
+};
+
+/** Represents the rtcp mux policy. */
+typedef NS_ENUM(NSInteger, RTCRtcpMuxPolicy) {
+ RTCRtcpMuxPolicyNegotiate,
+ RTCRtcpMuxPolicyRequire
+};
+
+/** Represents the tcp candidate policy. */
+typedef NS_ENUM(NSInteger, RTCTcpCandidatePolicy) {
+ RTCTcpCandidatePolicyEnabled,
+ RTCTcpCandidatePolicyDisabled
+};
+
+/** Represents the candidate network policy. */
+typedef NS_ENUM(NSInteger, RTCCandidateNetworkPolicy) {
+ RTCCandidateNetworkPolicyAll,
+ RTCCandidateNetworkPolicyLowCost
+};
+
+/** Represents the continual gathering policy. */
+typedef NS_ENUM(NSInteger, RTCContinualGatheringPolicy) {
+ RTCContinualGatheringPolicyGatherOnce,
+ RTCContinualGatheringPolicyGatherContinually
+};
+
+/** Represents the encryption key type. */
+typedef NS_ENUM(NSInteger, RTCEncryptionKeyType) {
+ RTCEncryptionKeyTypeRSA,
+ RTCEncryptionKeyTypeECDSA,
+};
+
+/** Represents the chosen SDP semantics for the RTCPeerConnection. */
+typedef NS_ENUM(NSInteger, RTCSdpSemantics) {
+ // TODO(https://crbug.com/webrtc/13528): Remove support for Plan B.
+ RTCSdpSemanticsPlanB,
+ RTCSdpSemanticsUnifiedPlan,
+};
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCConfiguration) : NSObject
+
+/** If true, allows DSCP codes to be set on outgoing packets, configured using
+ * networkPriority field of RTCRtpEncodingParameters. Defaults to false.
+ */
+@property(nonatomic, assign) BOOL enableDscp;
+
+/** An array of Ice Servers available to be used by ICE. */
+@property(nonatomic, copy) NSArray *iceServers;
+
+/** An RTCCertificate for 're' use. */
+@property(nonatomic, nullable) RTC_OBJC_TYPE(RTCCertificate) * certificate;
+
+/** Which candidates the ICE agent is allowed to use. The W3C calls it
+ * `iceTransportPolicy`, while in C++ it is called `type`. */
+@property(nonatomic, assign) RTCIceTransportPolicy iceTransportPolicy;
+
+/** The media-bundling policy to use when gathering ICE candidates. */
+@property(nonatomic, assign) RTCBundlePolicy bundlePolicy;
+
+/** The rtcp-mux policy to use when gathering ICE candidates. */
+@property(nonatomic, assign) RTCRtcpMuxPolicy rtcpMuxPolicy;
+@property(nonatomic, assign) RTCTcpCandidatePolicy tcpCandidatePolicy;
+@property(nonatomic, assign) RTCCandidateNetworkPolicy candidateNetworkPolicy;
+@property(nonatomic, assign)
+ RTCContinualGatheringPolicy continualGatheringPolicy;
+
+/** If set to YES, don't gather IPv6 ICE candidates on Wi-Fi.
+ * Only intended to be used on specific devices. Certain phones disable IPv6
+ * when the screen is turned off and it would be better to just disable the
+ * IPv6 ICE candidates on Wi-Fi in those cases.
+ * Default is NO.
+ */
+@property(nonatomic, assign) BOOL disableIPV6OnWiFi;
+
+/** By default, the PeerConnection will use a limited number of IPv6 network
+ * interfaces, in order to avoid too many ICE candidate pairs being created
+ * and delaying ICE completion.
+ *
+ * Can be set to INT_MAX to effectively disable the limit.
+ */
+@property(nonatomic, assign) int maxIPv6Networks;
+
+/** Exclude link-local network interfaces
+ * from considertaion for gathering ICE candidates.
+ * Defaults to NO.
+ */
+@property(nonatomic, assign) BOOL disableLinkLocalNetworks;
+
+@property(nonatomic, assign) int audioJitterBufferMaxPackets;
+@property(nonatomic, assign) BOOL audioJitterBufferFastAccelerate;
+@property(nonatomic, assign) int iceConnectionReceivingTimeout;
+@property(nonatomic, assign) int iceBackupCandidatePairPingInterval;
+
+/** Key type used to generate SSL identity. Default is ECDSA. */
+@property(nonatomic, assign) RTCEncryptionKeyType keyType;
+
+/** ICE candidate pool size as defined in JSEP. Default is 0. */
+@property(nonatomic, assign) int iceCandidatePoolSize;
+
+/** Prune turn ports on the same network to the same turn server.
+ * Default is NO.
+ */
+@property(nonatomic, assign) BOOL shouldPruneTurnPorts;
+
+/** If set to YES, this means the ICE transport should presume TURN-to-TURN
+ * candidate pairs will succeed, even before a binding response is received.
+ */
+@property(nonatomic, assign) BOOL shouldPresumeWritableWhenFullyRelayed;
+
+/* This flag is only effective when `continualGatheringPolicy` is
+ * RTCContinualGatheringPolicyGatherContinually.
+ *
+ * If YES, after the ICE transport type is changed such that new types of
+ * ICE candidates are allowed by the new transport type, e.g. from
+ * RTCIceTransportPolicyRelay to RTCIceTransportPolicyAll, candidates that
+ * have been gathered by the ICE transport but not matching the previous
+ * transport type and as a result not observed by PeerConnectionDelegateAdapter,
+ * will be surfaced to the delegate.
+ */
+@property(nonatomic, assign)
+ BOOL shouldSurfaceIceCandidatesOnIceTransportTypeChanged;
+
+/** If set to non-nil, controls the minimal interval between consecutive ICE
+ * check packets.
+ */
+@property(nonatomic, copy, nullable) NSNumber *iceCheckMinInterval;
+
+/**
+ * Configure the SDP semantics used by this PeerConnection. By default, this
+ * is RTCSdpSemanticsUnifiedPlan which is compliant to the WebRTC 1.0
+ * specification. It is possible to overrwite this to the deprecated
+ * RTCSdpSemanticsPlanB SDP format, but note that RTCSdpSemanticsPlanB will be
+ * deleted at some future date, see https://crbug.com/webrtc/13528.
+ *
+ * RTCSdpSemanticsUnifiedPlan will cause RTCPeerConnection to create offers and
+ * answers with multiple m= sections where each m= section maps to one
+ * RTCRtpSender and one RTCRtpReceiver (an RTCRtpTransceiver), either both audio
+ * or both video. This will also cause RTCPeerConnection to ignore all but the
+ * first a=ssrc lines that form a Plan B stream.
+ *
+ * RTCSdpSemanticsPlanB will cause RTCPeerConnection to create offers and
+ * answers with at most one audio and one video m= section with multiple
+ * RTCRtpSenders and RTCRtpReceivers specified as multiple a=ssrc lines within
+ * the section. This will also cause RTCPeerConnection to ignore all but the
+ * first m= section of the same media type.
+ */
+@property(nonatomic, assign) RTCSdpSemantics sdpSemantics;
+
+/** Actively reset the SRTP parameters when the DTLS transports underneath are
+ * changed after offer/answer negotiation. This is only intended to be a
+ * workaround for crbug.com/835958
+ */
+@property(nonatomic, assign) BOOL activeResetSrtpParams;
+
+/**
+ * Defines advanced optional cryptographic settings related to SRTP and
+ * frame encryption for native WebRTC. Setting this will overwrite any
+ * options set through the PeerConnectionFactory (which is deprecated).
+ */
+@property(nonatomic, nullable) RTC_OBJC_TYPE(RTCCryptoOptions) * cryptoOptions;
+
+/**
+ * An optional string that will be attached to the TURN_ALLOCATE_REQUEST which
+ * which can be used to correlate client logs with backend logs.
+ */
+@property(nonatomic, nullable, copy) NSString *turnLoggingId;
+
+/**
+ * Time interval between audio RTCP reports.
+ */
+@property(nonatomic, assign) int rtcpAudioReportIntervalMs;
+
+/**
+ * Time interval between video RTCP reports.
+ */
+@property(nonatomic, assign) int rtcpVideoReportIntervalMs;
+
+/**
+ * Allow implicit rollback of local description when remote description
+ * conflicts with local description.
+ * See: https://w3c.github.io/webrtc-pc/#dom-peerconnection-setremotedescription
+ */
+@property(nonatomic, assign) BOOL enableImplicitRollback;
+
+/**
+ * Control if "a=extmap-allow-mixed" is included in the offer.
+ * See: https://www.chromestatus.com/feature/6269234631933952
+ */
+@property(nonatomic, assign) BOOL offerExtmapAllowMixed;
+
+/**
+ * Defines the interval applied to ALL candidate pairs
+ * when ICE is strongly connected, and it overrides the
+ * default value of this interval in the ICE implementation;
+ */
+@property(nonatomic, copy, nullable)
+ NSNumber *iceCheckIntervalStrongConnectivity;
+
+/**
+ * Defines the counterpart for ALL pairs when ICE is
+ * weakly connected, and it overrides the default value of
+ * this interval in the ICE implementation
+ */
+@property(nonatomic, copy, nullable) NSNumber *iceCheckIntervalWeakConnectivity;
+
+/**
+ * The min time period for which a candidate pair must wait for response to
+ * connectivity checks before it becomes unwritable. This parameter
+ * overrides the default value in the ICE implementation if set.
+ */
+@property(nonatomic, copy, nullable) NSNumber *iceUnwritableTimeout;
+
+/**
+ * The min number of connectivity checks that a candidate pair must sent
+ * without receiving response before it becomes unwritable. This parameter
+ * overrides the default value in the ICE implementation if set.
+ */
+@property(nonatomic, copy, nullable) NSNumber *iceUnwritableMinChecks;
+
+/**
+ * The min time period for which a candidate pair must wait for response to
+ * connectivity checks it becomes inactive. This parameter overrides the
+ * default value in the ICE implementation if set.
+ */
+@property(nonatomic, copy, nullable) NSNumber *iceInactiveTimeout;
+
+- (instancetype)init;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCryptoOptions.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCryptoOptions.h
new file mode 100644
index 00000000..a4c85d78
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCCryptoOptions.h
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * Objective-C bindings for webrtc::CryptoOptions. This API had to be flattened
+ * as Objective-C doesn't support nested structures.
+ */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCCryptoOptions) : NSObject
+
+/**
+ * Enable GCM crypto suites from RFC 7714 for SRTP. GCM will only be used
+ * if both sides enable it
+ */
+@property(nonatomic, assign) BOOL srtpEnableGcmCryptoSuites;
+/**
+ * If set to true, the (potentially insecure) crypto cipher
+ * kSrtpAes128CmSha1_32 will be included in the list of supported ciphers
+ * during negotiation. It will only be used if both peers support it and no
+ * other ciphers get preferred.
+ */
+@property(nonatomic, assign) BOOL srtpEnableAes128Sha1_32CryptoCipher;
+/**
+ * If set to true, encrypted RTP header extensions as defined in RFC 6904
+ * will be negotiated. They will only be used if both peers support them.
+ */
+@property(nonatomic, assign) BOOL srtpEnableEncryptedRtpHeaderExtensions;
+
+/**
+ * If set all RtpSenders must have an FrameEncryptor attached to them before
+ * they are allowed to send packets. All RtpReceivers must have a
+ * FrameDecryptor attached to them before they are able to receive packets.
+ */
+@property(nonatomic, assign) BOOL sframeRequireFrameEncryption;
+
+/**
+ * Initializes CryptoOptions with all possible options set explicitly. This
+ * is done when converting from a native RTCConfiguration.crypto_options.
+ */
+- (instancetype)
+ initWithSrtpEnableGcmCryptoSuites:(BOOL)srtpEnableGcmCryptoSuites
+ srtpEnableAes128Sha1_32CryptoCipher:
+ (BOOL)srtpEnableAes128Sha1_32CryptoCipher
+ srtpEnableEncryptedRtpHeaderExtensions:
+ (BOOL)srtpEnableEncryptedRtpHeaderExtensions
+ sframeRequireFrameEncryption:(BOOL)sframeRequireFrameEncryption
+ NS_DESIGNATED_INITIALIZER;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDataChannel.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDataChannel.h
new file mode 100644
index 00000000..c5c2c9a1
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDataChannel.h
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCDataBuffer) : NSObject
+
+/** NSData representation of the underlying buffer. */
+@property(nonatomic, readonly) NSData *data;
+
+/** Indicates whether `data` contains UTF-8 or binary data. */
+@property(nonatomic, readonly) BOOL isBinary;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/**
+ * Initialize an RTCDataBuffer from NSData. `isBinary` indicates whether `data`
+ * contains UTF-8 or binary data.
+ */
+- (instancetype)initWithData:(NSData *)data isBinary:(BOOL)isBinary;
+
+@end
+
+@class RTC_OBJC_TYPE(RTCDataChannel);
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCDataChannelDelegate)
+
+ /** The data channel state changed. */
+ - (void)dataChannelDidChangeState
+ : (RTC_OBJC_TYPE(RTCDataChannel) *)dataChannel;
+
+/** The data channel successfully received a data buffer. */
+- (void)dataChannel:(RTC_OBJC_TYPE(RTCDataChannel) *)dataChannel
+ didReceiveMessageWithBuffer:(RTC_OBJC_TYPE(RTCDataBuffer) *)buffer;
+
+@optional
+/** The data channel's `bufferedAmount` changed. */
+- (void)dataChannel:(RTC_OBJC_TYPE(RTCDataChannel) *)dataChannel
+ didChangeBufferedAmount:(uint64_t)amount;
+
+@end
+
+/** Represents the state of the data channel. */
+typedef NS_ENUM(NSInteger, RTCDataChannelState) {
+ RTCDataChannelStateConnecting,
+ RTCDataChannelStateOpen,
+ RTCDataChannelStateClosing,
+ RTCDataChannelStateClosed,
+};
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCDataChannel) : NSObject
+
+/**
+ * A label that can be used to distinguish this data channel from other data
+ * channel objects.
+ */
+@property(nonatomic, readonly) NSString *label;
+
+/** Whether the data channel can send messages in unreliable mode. */
+@property(nonatomic, readonly) BOOL isReliable DEPRECATED_ATTRIBUTE;
+
+/** Returns whether this data channel is ordered or not. */
+@property(nonatomic, readonly) BOOL isOrdered;
+
+/** Deprecated. Use maxPacketLifeTime. */
+@property(nonatomic, readonly)
+ NSUInteger maxRetransmitTime DEPRECATED_ATTRIBUTE;
+
+/**
+ * The length of the time window (in milliseconds) during which transmissions
+ * and retransmissions may occur in unreliable mode.
+ */
+@property(nonatomic, readonly) uint16_t maxPacketLifeTime;
+
+/**
+ * The maximum number of retransmissions that are attempted in unreliable mode.
+ */
+@property(nonatomic, readonly) uint16_t maxRetransmits;
+
+/**
+ * The name of the sub-protocol used with this data channel, if any. Otherwise
+ * this returns an empty string.
+ */
+@property(nonatomic, readonly) NSString *protocol;
+
+/**
+ * Returns whether this data channel was negotiated by the application or not.
+ */
+@property(nonatomic, readonly) BOOL isNegotiated;
+
+/** Deprecated. Use channelId. */
+@property(nonatomic, readonly) NSInteger streamId DEPRECATED_ATTRIBUTE;
+
+/** The identifier for this data channel. */
+@property(nonatomic, readonly) int channelId;
+
+/** The state of the data channel. */
+@property(nonatomic, readonly) RTCDataChannelState readyState;
+
+/**
+ * The number of bytes of application data that have been queued using
+ * `sendData:` but that have not yet been transmitted to the network.
+ */
+@property(nonatomic, readonly) uint64_t bufferedAmount;
+
+/** The delegate for this data channel. */
+@property(nonatomic, weak) id delegate;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/** Closes the data channel. */
+- (void)close;
+
+/** Attempt to send `data` on this data channel's underlying data transport. */
+- (BOOL)sendData:(RTC_OBJC_TYPE(RTCDataBuffer) *)data;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDataChannelConfiguration.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDataChannelConfiguration.h
new file mode 100644
index 00000000..b1d8d770
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDataChannelConfiguration.h
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCDataChannelConfiguration) : NSObject
+
+/** Set to YES if ordered delivery is required. */
+@property(nonatomic, assign) BOOL isOrdered;
+
+/** Deprecated. Use maxPacketLifeTime. */
+@property(nonatomic, assign) NSInteger maxRetransmitTimeMs DEPRECATED_ATTRIBUTE;
+
+/**
+ * Max period in milliseconds in which retransmissions will be sent. After this
+ * time, no more retransmissions will be sent. -1 if unset.
+ */
+@property(nonatomic, assign) int maxPacketLifeTime;
+
+/** The max number of retransmissions. -1 if unset. */
+@property(nonatomic, assign) int maxRetransmits;
+
+/** Set to YES if the channel has been externally negotiated and we do not send
+ * an in-band signalling in the form of an "open" message.
+ */
+@property(nonatomic, assign) BOOL isNegotiated;
+
+/** Deprecated. Use channelId. */
+@property(nonatomic, assign) int streamId DEPRECATED_ATTRIBUTE;
+
+/** The id of the data channel. */
+@property(nonatomic, assign) int channelId;
+
+/** Set by the application and opaque to the WebRTC implementation. */
+@property(nonatomic) NSString* protocol;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDefaultVideoDecoderFactory.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDefaultVideoDecoderFactory.h
new file mode 100644
index 00000000..88b1d9c8
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDefaultVideoDecoderFactory.h
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** This decoder factory include support for all codecs bundled with WebRTC. If
+ * using custom codecs, create custom implementations of RTCVideoEncoderFactory
+ * and RTCVideoDecoderFactory.
+ */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCDefaultVideoDecoderFactory) : NSObject
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDefaultVideoEncoderFactory.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDefaultVideoEncoderFactory.h
new file mode 100644
index 00000000..6defc80c
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDefaultVideoEncoderFactory.h
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** This encoder factory include support for all codecs bundled with WebRTC. If
+ * using custom codecs, create custom implementations of RTCVideoEncoderFactory
+ * and RTCVideoDecoderFactory.
+ */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCDefaultVideoEncoderFactory) : NSObject
+
+@property(nonatomic, retain) RTC_OBJC_TYPE(RTCVideoCodecInfo) *preferredCodec;
+
++ (NSArray *)supportedCodecs;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDispatcher.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDispatcher.h
new file mode 100644
index 00000000..bc44b478
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDispatcher.h
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+typedef NS_ENUM(NSInteger, RTCDispatcherQueueType) {
+ // Main dispatcher queue.
+ RTCDispatcherTypeMain,
+ // Used for starting/stopping AVCaptureSession, and assigning
+ // capture session to AVCaptureVideoPreviewLayer.
+ RTCDispatcherTypeCaptureSession,
+ // Used for operations on AVAudioSession.
+ RTCDispatcherTypeAudioSession,
+ // Used for operations on NWPathMonitor.
+ RTCDispatcherTypeNetworkMonitor,
+};
+
+/** Dispatcher that asynchronously dispatches blocks to a specific
+ * shared dispatch queue.
+ */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCDispatcher) : NSObject
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/** Dispatch the block asynchronously on the queue for dispatchType.
+ * @param dispatchType The queue type to dispatch on.
+ * @param block The block to dispatch asynchronously.
+ */
++ (void)dispatchAsyncOnType:(RTCDispatcherQueueType)dispatchType
+ block:(dispatch_block_t)block;
+
+/** Returns YES if run on queue for the dispatchType otherwise NO.
+ * Useful for asserting that a method is run on a correct queue.
+ */
++ (BOOL)isOnQueueForType:(RTCDispatcherQueueType)dispatchType;
+
+@end
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDtmfSender.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDtmfSender.h
new file mode 100644
index 00000000..33d98c57
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCDtmfSender.h
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCDtmfSender)
+
+ /**
+ * Returns true if this RTCDtmfSender is capable of sending DTMF. Otherwise
+ * returns false. To be able to send DTMF, the associated RTCRtpSender must
+ * be able to send packets, and a "telephone-event" codec must be
+ * negotiated.
+ */
+ @property(nonatomic, readonly) BOOL canInsertDtmf;
+
+/**
+ * Queues a task that sends the DTMF tones. The tones parameter is treated
+ * as a series of characters. The characters 0 through 9, A through D, #, and *
+ * generate the associated DTMF tones. The characters a to d are equivalent
+ * to A to D. The character ',' indicates a delay of 2 seconds before
+ * processing the next character in the tones parameter.
+ *
+ * Unrecognized characters are ignored.
+ *
+ * @param duration The parameter indicates the duration to use for each
+ * character passed in the tones parameter. The duration cannot be more
+ * than 6000 or less than 70 ms.
+ *
+ * @param interToneGap The parameter indicates the gap between tones.
+ * This parameter must be at least 50 ms but should be as short as
+ * possible.
+ *
+ * If InsertDtmf is called on the same object while an existing task for this
+ * object to generate DTMF is still running, the previous task is canceled.
+ * Returns true on success and false on failure.
+ */
+- (BOOL)insertDtmf:(nonnull NSString *)tones
+ duration:(NSTimeInterval)duration
+ interToneGap:(NSTimeInterval)interToneGap;
+
+/** The tones remaining to be played out */
+- (nonnull NSString *)remainingTones;
+
+/**
+ * The current tone duration value. This value will be the value last set via
+ * the insertDtmf method, or the default value of 100 ms if insertDtmf was never
+ * called.
+ */
+- (NSTimeInterval)duration;
+
+/**
+ * The current value of the between-tone gap. This value will be the value last
+ * set via the insertDtmf() method, or the default value of 50 ms if
+ * insertDtmf() was never called.
+ */
+- (NSTimeInterval)interToneGap;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCEAGLVideoView.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCEAGLVideoView.h
new file mode 100644
index 00000000..75cf9aed
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCEAGLVideoView.h
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+#import
+
+#import
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class RTC_OBJC_TYPE(RTCEAGLVideoView);
+
+/**
+ * RTCEAGLVideoView is an RTCVideoRenderer which renders video frames
+ * in its bounds using OpenGLES 2.0 or OpenGLES 3.0.
+ */
+NS_EXTENSION_UNAVAILABLE_IOS("Rendering not available in app extensions.")
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCEAGLVideoView) : UIView
+
+@property(nonatomic, weak) id delegate;
+
+- (instancetype)initWithFrame:(CGRect)frame
+ shader:(id)shader
+ NS_DESIGNATED_INITIALIZER;
+
+- (instancetype)initWithCoder:(NSCoder *)aDecoder
+ shader:(id)shader
+ NS_DESIGNATED_INITIALIZER;
+
+/** @abstract Wrapped RTCVideoRotation, or nil.
+ */
+@property(nonatomic, nullable) NSValue *rotationOverride;
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCEncodedImage.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCEncodedImage.h
new file mode 100644
index 00000000..97f29ed3
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCEncodedImage.h
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Represents an encoded frame's type. */
+typedef NS_ENUM(NSUInteger, RTCFrameType) {
+ RTCFrameTypeEmptyFrame = 0,
+ RTCFrameTypeAudioFrameSpeech = 1,
+ RTCFrameTypeAudioFrameCN = 2,
+ RTCFrameTypeVideoFrameKey = 3,
+ RTCFrameTypeVideoFrameDelta = 4,
+};
+
+typedef NS_ENUM(NSUInteger, RTCVideoContentType) {
+ RTCVideoContentTypeUnspecified,
+ RTCVideoContentTypeScreenshare,
+};
+
+/** Represents an encoded frame. Corresponds to webrtc::EncodedImage. */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCEncodedImage) : NSObject
+
+@property(nonatomic, strong) NSData *buffer;
+@property(nonatomic, assign) int32_t encodedWidth;
+@property(nonatomic, assign) int32_t encodedHeight;
+@property(nonatomic, assign) uint32_t timeStamp;
+@property(nonatomic, assign) int64_t captureTimeMs;
+@property(nonatomic, assign) int64_t ntpTimeMs;
+@property(nonatomic, assign) uint8_t flags;
+@property(nonatomic, assign) int64_t encodeStartMs;
+@property(nonatomic, assign) int64_t encodeFinishMs;
+@property(nonatomic, assign) RTCFrameType frameType;
+@property(nonatomic, assign) RTCVideoRotation rotation;
+@property(nonatomic, strong) NSNumber *qp;
+@property(nonatomic, assign) RTCVideoContentType contentType;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFieldTrials.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFieldTrials.h
new file mode 100644
index 00000000..fa27322f
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFieldTrials.h
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2016 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+/** The only valid value for the following if set is kRTCFieldTrialEnabledValue.
+ */
+RTC_EXTERN NSString *const kRTCFieldTrialAudioForceABWENoTWCCKey;
+RTC_EXTERN NSString *const kRTCFieldTrialFlexFec03AdvertisedKey;
+RTC_EXTERN NSString *const kRTCFieldTrialFlexFec03Key;
+RTC_EXTERN NSString *const kRTCFieldTrialH264HighProfileKey;
+RTC_EXTERN NSString *const kRTCFieldTrialMinimizeResamplingOnMobileKey;
+RTC_EXTERN NSString *const kRTCFieldTrialUseNWPathMonitor;
+
+/** The valid value for field trials above. */
+RTC_EXTERN NSString *const kRTCFieldTrialEnabledValue;
+
+/** Initialize field trials using a dictionary mapping field trial keys to their
+ * values. See above for valid keys and values. Must be called before any other
+ * call into WebRTC. See: webrtc/system_wrappers/include/field_trial.h
+ */
+// TODO: bugs.webrtc.org/42220378 - Delete after January 1, 2026.
+RTC_OBJC_DEPRECATED("Pass field trials when building PeerConnectionFactory")
+RTC_EXTERN void RTCInitFieldTrialDictionary(
+ NSDictionary *fieldTrials);
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFileLogger.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFileLogger.h
new file mode 100644
index 00000000..551a895f
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFileLogger.h
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+typedef NS_ENUM(NSUInteger, RTCFileLoggerSeverity) {
+ RTCFileLoggerSeverityVerbose,
+ RTCFileLoggerSeverityInfo,
+ RTCFileLoggerSeverityWarning,
+ RTCFileLoggerSeverityError
+};
+
+typedef NS_ENUM(NSUInteger, RTCFileLoggerRotationType) {
+ RTCFileLoggerTypeCall,
+ RTCFileLoggerTypeApp,
+};
+
+NS_ASSUME_NONNULL_BEGIN
+
+// This class intercepts WebRTC logs and saves them to a file. The file size
+// will not exceed the given maximum bytesize. When the maximum bytesize is
+// reached, logs are rotated according to the rotationType specified.
+// For kRTCFileLoggerTypeCall, logs from the beginning and the end
+// are preserved while the middle section is overwritten instead.
+// For kRTCFileLoggerTypeApp, the oldest log is overwritten.
+// This class is not threadsafe.
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCFileLogger) : NSObject
+
+// The severity level to capture. The default is kRTCFileLoggerSeverityInfo.
+@property(nonatomic, assign) RTCFileLoggerSeverity severity;
+
+// The rotation type for this file logger. The default is
+// kRTCFileLoggerTypeCall.
+@property(nonatomic, readonly) RTCFileLoggerRotationType rotationType;
+
+// Disables buffering disk writes. Should be set before `start`. Buffering
+// is enabled by default for performance.
+@property(nonatomic, assign) BOOL shouldDisableBuffering;
+
+// Default constructor provides default settings for dir path, file size and
+// rotation type.
+- (instancetype)init;
+
+// Create file logger with default rotation type.
+- (instancetype)initWithDirPath:(NSString *)dirPath
+ maxFileSize:(NSUInteger)maxFileSize;
+
+- (instancetype)initWithDirPath:(NSString *)dirPath
+ maxFileSize:(NSUInteger)maxFileSize
+ rotationType:(RTCFileLoggerRotationType)rotationType
+ NS_DESIGNATED_INITIALIZER;
+
+// Starts writing WebRTC logs to disk if not already started. Overwrites any
+// existing file(s).
+- (void)start;
+
+// Stops writing WebRTC logs to disk. This method is also called on dealloc.
+- (void)stop;
+
+// Returns the current contents of the logs, or nil if start has been called
+// without a stop.
+- (nullable NSData *)logData;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFileVideoCapturer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFileVideoCapturer.h
new file mode 100644
index 00000000..38f65f81
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCFileVideoCapturer.h
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2017 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * Error passing block.
+ */
+typedef void (^RTCFileVideoCapturerErrorBlock)(NSError *error);
+
+/**
+ * Captures buffers from bundled video file.
+ *
+ * See @c RTCVideoCapturer for more info on capturers.
+ */
+RTC_OBJC_EXPORT
+
+NS_CLASS_AVAILABLE_IOS(10)
+@interface RTC_OBJC_TYPE (RTCFileVideoCapturer) : RTC_OBJC_TYPE(RTCVideoCapturer)
+
+/**
+ * Starts asynchronous capture of frames from video file.
+ *
+ * Capturing is not started if error occurs. Underlying error will be
+ * relayed in the errorBlock if one is provided.
+ * Successfully captured video frames will be passed to the delegate.
+ *
+ * @param nameOfFile The name of the bundled video file to be read.
+ * @errorBlock block to be executed upon error.
+ */
+- (void)startCapturingFromFileNamed:(NSString *)nameOfFile
+ onError:(__nullable RTCFileVideoCapturerErrorBlock)errorBlock;
+
+/**
+ * Immediately stops capture.
+ */
+- (void)stopCapture;
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCH264ProfileLevelId.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCH264ProfileLevelId.h
new file mode 100644
index 00000000..67bcae16
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCH264ProfileLevelId.h
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+RTC_EXTERN NSString *const kRTCVideoCodecH264Name;
+RTC_EXTERN NSString *const kRTCLevel31ConstrainedHigh;
+RTC_EXTERN NSString *const kRTCLevel31ConstrainedBaseline;
+RTC_EXTERN NSString *const kRTCMaxSupportedH264ProfileLevelConstrainedHigh;
+RTC_EXTERN NSString *const kRTCMaxSupportedH264ProfileLevelConstrainedBaseline;
+
+/** H264 Profiles and levels. */
+typedef NS_ENUM(NSUInteger, RTCH264Profile) {
+ RTCH264ProfileConstrainedBaseline,
+ RTCH264ProfileBaseline,
+ RTCH264ProfileMain,
+ RTCH264ProfileConstrainedHigh,
+ RTCH264ProfileHigh,
+};
+
+typedef NS_ENUM(NSUInteger, RTCH264Level) {
+ RTCH264Level1_b = 0,
+ RTCH264Level1 = 10,
+ RTCH264Level1_1 = 11,
+ RTCH264Level1_2 = 12,
+ RTCH264Level1_3 = 13,
+ RTCH264Level2 = 20,
+ RTCH264Level2_1 = 21,
+ RTCH264Level2_2 = 22,
+ RTCH264Level3 = 30,
+ RTCH264Level3_1 = 31,
+ RTCH264Level3_2 = 32,
+ RTCH264Level4 = 40,
+ RTCH264Level4_1 = 41,
+ RTCH264Level4_2 = 42,
+ RTCH264Level5 = 50,
+ RTCH264Level5_1 = 51,
+ RTCH264Level5_2 = 52
+};
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCH264ProfileLevelId) : NSObject
+
+@property(nonatomic, readonly) RTCH264Profile profile;
+@property(nonatomic, readonly) RTCH264Level level;
+@property(nonatomic, readonly) NSString *hexString;
+
+- (instancetype)initWithHexString:(NSString *)hexString;
+- (instancetype)initWithProfile:(RTCH264Profile)profile
+ level:(RTCH264Level)level;
+
+@end
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCI420Buffer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCI420Buffer.h
new file mode 100644
index 00000000..54c32408
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCI420Buffer.h
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Protocol for RTCYUVPlanarBuffers containing I420 data */
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCI420Buffer) @end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceCandidate.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceCandidate.h
new file mode 100644
index 00000000..23b4fece
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceCandidate.h
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCIceCandidate) : NSObject
+
+/**
+ * If present, the identifier of the "media stream identification" for the media
+ * component this candidate is associated with.
+ */
+@property(nonatomic, readonly, nullable) NSString *sdpMid;
+
+/**
+ * The index (starting at zero) of the media description this candidate is
+ * associated with in the SDP.
+ */
+@property(nonatomic, readonly) int sdpMLineIndex;
+
+/** The SDP string for this candidate. */
+@property(nonatomic, readonly) NSString *sdp;
+
+/** The URL of the ICE server which this candidate is gathered from. */
+@property(nonatomic, readonly, nullable) NSString *serverUrl;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/**
+ * Initialize an RTCIceCandidate from SDP.
+ */
+- (instancetype)initWithSdp:(NSString *)sdp
+ sdpMLineIndex:(int)sdpMLineIndex
+ sdpMid:(nullable NSString *)sdpMid
+ NS_DESIGNATED_INITIALIZER;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceCandidateErrorEvent.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceCandidateErrorEvent.h
new file mode 100644
index 00000000..fb8e853f
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceCandidateErrorEvent.h
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2021 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCIceCandidateErrorEvent) : NSObject
+
+/** The local IP address used to communicate with the STUN or TURN server. */
+@property(nonatomic, readonly) NSString *address;
+
+/** The port used to communicate with the STUN or TURN server. */
+@property(nonatomic, readonly) int port;
+
+/** The STUN or TURN URL that identifies the STUN or TURN server for which the
+ * failure occurred. */
+@property(nonatomic, readonly) NSString *url;
+
+/** The numeric STUN error code returned by the STUN or TURN server. If no host
+ * candidate can reach the server, errorCode will be set to the value 701 which
+ * is outside the STUN error code range. This error is only fired once per
+ * server URL while in the RTCIceGatheringState of "gathering". */
+@property(nonatomic, readonly) int errorCode;
+
+/** The STUN reason text returned by the STUN or TURN server. If the server
+ * could not be reached, errorText will be set to an implementation-specific
+ * value providing details about the error. */
+@property(nonatomic, readonly) NSString *errorText;
+
+- (instancetype)init NS_DESIGNATED_INITIALIZER;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceServer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceServer.h
new file mode 100644
index 00000000..6f6c7eaa
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCIceServer.h
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+typedef NS_ENUM(NSUInteger, RTCTlsCertPolicy) {
+ RTCTlsCertPolicySecure,
+ RTCTlsCertPolicyInsecureNoCheck
+};
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCIceServer) : NSObject
+
+/** URI(s) for this server represented as NSStrings. */
+@property(nonatomic, readonly) NSArray *urlStrings;
+
+/** Username to use if this RTCIceServer object is a TURN server. */
+@property(nonatomic, readonly, nullable) NSString *username;
+
+/** Credential to use if this RTCIceServer object is a TURN server. */
+@property(nonatomic, readonly, nullable) NSString *credential;
+
+/**
+ * TLS certificate policy to use if this RTCIceServer object is a TURN server.
+ */
+@property(nonatomic, readonly) RTCTlsCertPolicy tlsCertPolicy;
+
+/**
+ If the URIs in `urls` only contain IP addresses, this field can be used
+ to indicate the hostname, which may be necessary for TLS (using the SNI
+ extension). If `urls` itself contains the hostname, this isn't necessary.
+ */
+@property(nonatomic, readonly, nullable) NSString *hostname;
+
+/** List of protocols to be used in the TLS ALPN extension. */
+@property(nonatomic, readonly) NSArray *tlsAlpnProtocols;
+
+/**
+ List elliptic curves to be used in the TLS elliptic curves extension.
+ Only curve names supported by OpenSSL should be used (eg. "P-256","X25519").
+ */
+@property(nonatomic, readonly) NSArray *tlsEllipticCurves;
+
+- (nonnull instancetype)init NS_UNAVAILABLE;
+
+/** Convenience initializer for a server with no authentication (e.g. STUN). */
+- (instancetype)initWithURLStrings:(NSArray *)urlStrings;
+
+/**
+ * Initialize an RTCIceServer with its associated URLs, optional username,
+ * optional credential, and credentialType.
+ */
+- (instancetype)initWithURLStrings:(NSArray *)urlStrings
+ username:(nullable NSString *)username
+ credential:(nullable NSString *)credential;
+
+/**
+ * Initialize an RTCIceServer with its associated URLs, optional username,
+ * optional credential, and TLS cert policy.
+ */
+- (instancetype)initWithURLStrings:(NSArray *)urlStrings
+ username:(nullable NSString *)username
+ credential:(nullable NSString *)credential
+ tlsCertPolicy:(RTCTlsCertPolicy)tlsCertPolicy;
+
+/**
+ * Initialize an RTCIceServer with its associated URLs, optional username,
+ * optional credential, TLS cert policy and hostname.
+ */
+- (instancetype)initWithURLStrings:(NSArray *)urlStrings
+ username:(nullable NSString *)username
+ credential:(nullable NSString *)credential
+ tlsCertPolicy:(RTCTlsCertPolicy)tlsCertPolicy
+ hostname:(nullable NSString *)hostname;
+
+/**
+ * Initialize an RTCIceServer with its associated URLs, optional username,
+ * optional credential, TLS cert policy, hostname and ALPN protocols.
+ */
+- (instancetype)initWithURLStrings:(NSArray *)urlStrings
+ username:(nullable NSString *)username
+ credential:(nullable NSString *)credential
+ tlsCertPolicy:(RTCTlsCertPolicy)tlsCertPolicy
+ hostname:(nullable NSString *)hostname
+ tlsAlpnProtocols:(NSArray *)tlsAlpnProtocols;
+
+/**
+ * Initialize an RTCIceServer with its associated URLs, optional username,
+ * optional credential, TLS cert policy, hostname, ALPN protocols and
+ * elliptic curves.
+ */
+- (instancetype)
+ initWithURLStrings:(NSArray *)urlStrings
+ username:(nullable NSString *)username
+ credential:(nullable NSString *)credential
+ tlsCertPolicy:(RTCTlsCertPolicy)tlsCertPolicy
+ hostname:(nullable NSString *)hostname
+ tlsAlpnProtocols:(nullable NSArray *)tlsAlpnProtocols
+ tlsEllipticCurves:(nullable NSArray *)tlsEllipticCurves
+ NS_DESIGNATED_INITIALIZER;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCLegacyStatsReport.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCLegacyStatsReport.h
new file mode 100644
index 00000000..c9ce8e38
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCLegacyStatsReport.h
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** This does not currently conform to the spec. */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCLegacyStatsReport) : NSObject
+
+/** Time since 1970-01-01T00:00:00Z in milliseconds. */
+@property(nonatomic, readonly) CFTimeInterval timestamp;
+
+/** The type of stats held by this object. */
+@property(nonatomic, readonly) NSString *type;
+
+/** The identifier for this object. */
+@property(nonatomic, readonly) NSString *reportId;
+
+/** A dictionary holding the actual stats. */
+@property(nonatomic, readonly) NSDictionary *values;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCLogging.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCLogging.h
new file mode 100644
index 00000000..4cc29010
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCLogging.h
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+// Subset of webrtc::LoggingSeverity.
+typedef NS_ENUM(NSInteger, RTCLoggingSeverity) {
+ RTCLoggingSeverityVerbose,
+ RTCLoggingSeverityInfo,
+ RTCLoggingSeverityWarning,
+ RTCLoggingSeverityError,
+ RTCLoggingSeverityNone,
+};
+
+// Wrapper for C++ RTC_LOG(sev) macros.
+// Logs the log string to the webrtc logstream for the given severity.
+RTC_EXTERN void RTCLogEx(RTCLoggingSeverity severity, NSString* log_string);
+
+// Wrapper for webrtc::LogMessage::LogToDebug.
+// Sets the minimum severity to be logged to console.
+RTC_EXTERN void RTCSetMinDebugLogLevel(RTCLoggingSeverity severity);
+
+// Returns the filename with the path prefix removed.
+RTC_EXTERN NSString* RTCFileName(const char* filePath);
+
+// Some convenience macros.
+
+#define RTCLogString(format, ...) \
+ [NSString stringWithFormat:@"(%@:%d %s): " format, \
+ RTCFileName(__FILE__), \
+ __LINE__, \
+ __FUNCTION__, \
+ ##__VA_ARGS__]
+
+#define RTCLogFormat(severity, format, ...) \
+ do { \
+ NSString* log_string = RTCLogString(format, ##__VA_ARGS__); \
+ RTCLogEx(severity, log_string); \
+ } while (false)
+
+#define RTCLogVerbose(format, ...) \
+ RTCLogFormat(RTCLoggingSeverityVerbose, format, ##__VA_ARGS__)
+
+#define RTCLogInfo(format, ...) \
+ RTCLogFormat(RTCLoggingSeverityInfo, format, ##__VA_ARGS__)
+
+#define RTCLogWarning(format, ...) \
+ RTCLogFormat(RTCLoggingSeverityWarning, format, ##__VA_ARGS__)
+
+#define RTCLogError(format, ...) \
+ RTCLogFormat(RTCLoggingSeverityError, format, ##__VA_ARGS__)
+
+#if !defined(NDEBUG)
+#define RTCLogDebug(format, ...) RTCLogInfo(format, ##__VA_ARGS__)
+#else
+#define RTCLogDebug(format, ...) \
+ do { \
+ } while (false)
+#endif
+
+#define RTCLog(format, ...) RTCLogInfo(format, ##__VA_ARGS__)
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMTLVideoView.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMTLVideoView.h
new file mode 100644
index 00000000..f93ab591
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMTLVideoView.h
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * RTCMTLVideoView is thin wrapper around MTKView.
+ *
+ * It has id property that renders video frames in the view's
+ * bounds using Metal.
+ */
+NS_CLASS_AVAILABLE_IOS(9)
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCMTLVideoView) : UIView
+
+@property(nonatomic, weak) id delegate;
+
+@property(nonatomic) UIViewContentMode videoContentMode;
+
+/** @abstract Enables/disables rendering.
+ */
+@property(nonatomic, getter=isEnabled) BOOL enabled;
+
+/** @abstract Wrapped RTCVideoRotation, or nil.
+ */
+@property(nonatomic, nullable) NSValue* rotationOverride;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMacros.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMacros.h
new file mode 100644
index 00000000..cb943b4b
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMacros.h
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2016 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#ifndef SDK_OBJC_BASE_RTCMACROS_H_
+#define SDK_OBJC_BASE_RTCMACROS_H_
+
+#ifdef WEBRTC_ENABLE_OBJC_SYMBOL_EXPORT
+
+#if defined(WEBRTC_LIBRARY_IMPL)
+#define RTC_OBJC_EXPORT __attribute__((visibility("default")))
+#endif
+
+#endif // WEBRTC_ENABLE_OBJC_SYMBOL_EXPORT
+
+#ifndef RTC_OBJC_EXPORT
+#define RTC_OBJC_EXPORT
+#endif
+
+// Macro used to mark a function as deprecated.
+#define RTC_OBJC_DEPRECATED(msg) __attribute__((deprecated(msg)))
+
+// Internal macros used to correctly concatenate symbols.
+#define RTC_SYMBOL_CONCAT_HELPER(a, b) a##b
+#define RTC_SYMBOL_CONCAT(a, b) RTC_SYMBOL_CONCAT_HELPER(a, b)
+
+// RTC_OBJC_TYPE_PREFIX
+//
+// Macro used to prepend a prefix to the API types that are exported with
+// RTC_OBJC_EXPORT.
+//
+// Clients can patch the definition of this macro locally and build
+// WebRTC.framework with their own prefix in case symbol clashing is a
+// problem.
+//
+// This macro must be defined uniformily across all the translation units.
+#ifndef RTC_OBJC_TYPE_PREFIX
+#define RTC_OBJC_TYPE_PREFIX
+#endif
+
+// RCT_OBJC_TYPE
+//
+// Macro used internally to declare API types. Declaring an API type without
+// using this macro will not include the declared type in the set of types
+// that will be affected by the configurable RTC_OBJC_TYPE_PREFIX.
+#define RTC_OBJC_TYPE(type_name) \
+ RTC_SYMBOL_CONCAT(RTC_OBJC_TYPE_PREFIX, type_name)
+
+#if defined(__cplusplus)
+#define RTC_EXTERN extern "C" RTC_OBJC_EXPORT
+#else
+#define RTC_EXTERN extern RTC_OBJC_EXPORT
+#endif
+
+#ifdef __OBJC__
+#define RTC_FWD_DECL_OBJC_CLASS(classname) @class classname
+#else
+#define RTC_FWD_DECL_OBJC_CLASS(classname) typedef struct objc_object classname
+#endif
+
+#endif // SDK_OBJC_BASE_RTCMACROS_H_
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaConstraints.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaConstraints.h
new file mode 100644
index 00000000..8d002b97
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaConstraints.h
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Constraint keys for media sources. */
+/** The value for this key should be a base64 encoded string containing
+ * the data from the serialized configuration proto.
+ */
+RTC_EXTERN NSString *const kRTCMediaConstraintsAudioNetworkAdaptorConfig;
+
+/** Constraint keys for generating offers and answers. */
+RTC_EXTERN NSString *const kRTCMediaConstraintsIceRestart;
+RTC_EXTERN NSString *const kRTCMediaConstraintsOfferToReceiveAudio;
+RTC_EXTERN NSString *const kRTCMediaConstraintsOfferToReceiveVideo;
+RTC_EXTERN NSString *const kRTCMediaConstraintsVoiceActivityDetection;
+
+/** Constraint values for Boolean parameters. */
+RTC_EXTERN NSString *const kRTCMediaConstraintsValueTrue;
+RTC_EXTERN NSString *const kRTCMediaConstraintsValueFalse;
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCMediaConstraints) : NSObject
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/** Initialize with mandatory and/or optional constraints. */
+- (instancetype)initWithMandatoryConstraints:
+ (nullable NSDictionary *)mandatory
+ optionalConstraints:
+ (nullable NSDictionary *)
+ optional NS_DESIGNATED_INITIALIZER;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaSource.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaSource.h
new file mode 100644
index 00000000..51ceb605
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaSource.h
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2016 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+typedef NS_ENUM(NSInteger, RTCSourceState) {
+ RTCSourceStateInitializing,
+ RTCSourceStateLive,
+ RTCSourceStateEnded,
+ RTCSourceStateMuted,
+};
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCMediaSource) : NSObject
+
+/** The current state of the RTCMediaSource. */
+@property(nonatomic, readonly) RTCSourceState state;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaStream.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaStream.h
new file mode 100644
index 00000000..ce3eec5d
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaStream.h
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class RTC_OBJC_TYPE(RTCAudioTrack);
+@class RTC_OBJC_TYPE(RTCPeerConnectionFactory);
+@class RTC_OBJC_TYPE(RTCVideoTrack);
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCMediaStream) : NSObject
+
+/** The audio tracks in this stream. */
+@property(nonatomic, strong, readonly) NSArray *audioTracks;
+
+/** The video tracks in this stream. */
+@property(nonatomic, strong, readonly)
+ NSArray *videoTracks;
+
+/** An identifier for this media stream. */
+@property(nonatomic, readonly) NSString *streamId;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/** Adds the given audio track to this media stream. */
+- (void)addAudioTrack:(RTC_OBJC_TYPE(RTCAudioTrack) *)audioTrack;
+
+/** Adds the given video track to this media stream. */
+- (void)addVideoTrack:(RTC_OBJC_TYPE(RTCVideoTrack) *)videoTrack;
+
+/** Removes the given audio track to this media stream. */
+- (void)removeAudioTrack:(RTC_OBJC_TYPE(RTCAudioTrack) *)audioTrack;
+
+/** Removes the given video track to this media stream. */
+- (void)removeVideoTrack:(RTC_OBJC_TYPE(RTCVideoTrack) *)videoTrack;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaStreamTrack.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaStreamTrack.h
new file mode 100644
index 00000000..52658794
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMediaStreamTrack.h
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+/**
+ * Represents the state of the track. This exposes the same states in C++.
+ */
+typedef NS_ENUM(NSInteger, RTCMediaStreamTrackState) {
+ RTCMediaStreamTrackStateLive,
+ RTCMediaStreamTrackStateEnded
+};
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_EXTERN NSString *const kRTCMediaStreamTrackKindAudio;
+RTC_EXTERN NSString *const kRTCMediaStreamTrackKindVideo;
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCMediaStreamTrack) : NSObject
+
+/**
+ * The kind of track. For example, "audio" if this track represents an audio
+ * track and "video" if this track represents a video track.
+ */
+@property(nonatomic, readonly) NSString *kind;
+
+/** An identifier string. */
+@property(nonatomic, readonly) NSString *trackId;
+
+/** The enabled state of the track. */
+@property(nonatomic, assign) BOOL isEnabled;
+
+/** The state of the track. */
+@property(nonatomic, readonly) RTCMediaStreamTrackState readyState;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMetrics.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMetrics.h
new file mode 100644
index 00000000..fffb451a
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMetrics.h
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2016 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+/**
+ * Enables gathering of metrics (which can be fetched with
+ * RTCGetAndResetMetrics). Must be called before any other call into WebRTC.
+ */
+RTC_EXTERN void RTCEnableMetrics(void);
+
+/** Gets and clears native histograms. */
+RTC_EXTERN NSArray*
+ RTCGetAndResetMetrics(void);
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMetricsSampleInfo.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMetricsSampleInfo.h
new file mode 100644
index 00000000..18afdc0b
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMetricsSampleInfo.h
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2016 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCMetricsSampleInfo) : NSObject
+
+/**
+ * Example of RTCMetricsSampleInfo:
+ * name: "WebRTC.Video.InputFramesPerSecond"
+ * min: 1
+ * max: 100
+ * bucketCount: 50
+ * samples: [29]:2 [30]:1
+ */
+
+/** The name of the histogram. */
+@property(nonatomic, readonly) NSString *name;
+
+/** The minimum bucket value. */
+@property(nonatomic, readonly) int min;
+
+/** The maximum bucket value. */
+@property(nonatomic, readonly) int max;
+
+/** The number of buckets. */
+@property(nonatomic, readonly) int bucketCount;
+
+/** A dictionary holding the samples . */
+@property(nonatomic, readonly) NSDictionary *samples;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMutableI420Buffer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMutableI420Buffer.h
new file mode 100644
index 00000000..7685234f
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMutableI420Buffer.h
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Extension of the I420 buffer with mutable data access */
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCMutableI420Buffer) @end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMutableYUVPlanarBuffer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMutableYUVPlanarBuffer.h
new file mode 100644
index 00000000..feb7417b
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCMutableYUVPlanarBuffer.h
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Extension of the YUV planar data buffer with mutable data access */
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCMutableYUVPlanarBuffer)
+
+ @property(nonatomic, readonly) uint8_t *mutableDataY;
+@property(nonatomic, readonly) uint8_t *mutableDataU;
+@property(nonatomic, readonly) uint8_t *mutableDataV;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNativeI420Buffer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNativeI420Buffer.h
new file mode 100644
index 00000000..c5a0ddf7
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNativeI420Buffer.h
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** RTCI420Buffer implements the RTCI420Buffer protocol */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCI420Buffer) : NSObject
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNativeMutableI420Buffer.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNativeMutableI420Buffer.h
new file mode 100644
index 00000000..b300731a
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNativeMutableI420Buffer.h
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Mutable version of RTCI420Buffer */
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCMutableI420Buffer) : RTC_OBJC_TYPE(RTCI420Buffer)
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNetworkMonitor.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNetworkMonitor.h
new file mode 100644
index 00000000..21d22f54
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCNetworkMonitor.h
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2020 The WebRTC Project Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Listens for NWPathMonitor updates and forwards the results to a C++
+ * observer.
+ */
+@interface RTCNetworkMonitor : NSObject
+
+- (instancetype)init NS_UNAVAILABLE;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnection.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnection.h
new file mode 100644
index 00000000..7ddb5ba4
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnection.h
@@ -0,0 +1,422 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+@class RTC_OBJC_TYPE(RTCConfiguration);
+@class RTC_OBJC_TYPE(RTCDataChannel);
+@class RTC_OBJC_TYPE(RTCDataChannelConfiguration);
+@class RTC_OBJC_TYPE(RTCIceCandidate);
+@class RTC_OBJC_TYPE(RTCIceCandidateErrorEvent);
+@class RTC_OBJC_TYPE(RTCMediaConstraints);
+@class RTC_OBJC_TYPE(RTCMediaStream);
+@class RTC_OBJC_TYPE(RTCMediaStreamTrack);
+@class RTC_OBJC_TYPE(RTCPeerConnectionFactory);
+@class RTC_OBJC_TYPE(RTCRtpReceiver);
+@class RTC_OBJC_TYPE(RTCRtpSender);
+@class RTC_OBJC_TYPE(RTCRtpTransceiver);
+@class RTC_OBJC_TYPE(RTCRtpTransceiverInit);
+@class RTC_OBJC_TYPE(RTCSessionDescription);
+@class RTC_OBJC_TYPE(RTCStatisticsReport);
+@class RTC_OBJC_TYPE(RTCLegacyStatsReport);
+
+typedef NS_ENUM(NSInteger, RTCRtpMediaType);
+
+NS_ASSUME_NONNULL_BEGIN
+
+extern NSString *const kRTCPeerConnectionErrorDomain;
+extern int const kRTCSessionDescriptionErrorCode;
+
+/** Represents the signaling state of the peer connection. */
+typedef NS_ENUM(NSInteger, RTCSignalingState) {
+ RTCSignalingStateStable,
+ RTCSignalingStateHaveLocalOffer,
+ RTCSignalingStateHaveLocalPrAnswer,
+ RTCSignalingStateHaveRemoteOffer,
+ RTCSignalingStateHaveRemotePrAnswer,
+ // Not an actual state, represents the total number of states.
+ RTCSignalingStateClosed,
+};
+
+/** Represents the ice connection state of the peer connection. */
+typedef NS_ENUM(NSInteger, RTCIceConnectionState) {
+ RTCIceConnectionStateNew,
+ RTCIceConnectionStateChecking,
+ RTCIceConnectionStateConnected,
+ RTCIceConnectionStateCompleted,
+ RTCIceConnectionStateFailed,
+ RTCIceConnectionStateDisconnected,
+ RTCIceConnectionStateClosed,
+ RTCIceConnectionStateCount,
+};
+
+/** Represents the combined ice+dtls connection state of the peer connection. */
+typedef NS_ENUM(NSInteger, RTCPeerConnectionState) {
+ RTCPeerConnectionStateNew,
+ RTCPeerConnectionStateConnecting,
+ RTCPeerConnectionStateConnected,
+ RTCPeerConnectionStateDisconnected,
+ RTCPeerConnectionStateFailed,
+ RTCPeerConnectionStateClosed,
+};
+
+/** Represents the ice gathering state of the peer connection. */
+typedef NS_ENUM(NSInteger, RTCIceGatheringState) {
+ RTCIceGatheringStateNew,
+ RTCIceGatheringStateGathering,
+ RTCIceGatheringStateComplete,
+};
+
+/** Represents the stats output level. */
+typedef NS_ENUM(NSInteger, RTCStatsOutputLevel) {
+ RTCStatsOutputLevelStandard,
+ RTCStatsOutputLevelDebug,
+};
+
+typedef void (^RTCCreateSessionDescriptionCompletionHandler)(
+ RTC_OBJC_TYPE(RTCSessionDescription) *_Nullable sdp,
+ NSError *_Nullable error);
+
+typedef void (^RTCSetSessionDescriptionCompletionHandler)(
+ NSError *_Nullable error);
+
+@class RTC_OBJC_TYPE(RTCPeerConnection);
+
+RTC_OBJC_EXPORT
+@protocol RTC_OBJC_TYPE
+(RTCPeerConnectionDelegate)
+
+ /** Called when the SignalingState changed. */
+ - (void)peerConnection
+ : (RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection didChangeSignalingState
+ : (RTCSignalingState)stateChanged;
+
+/** Called when media is received on a new stream from remote peer. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didAddStream:(RTC_OBJC_TYPE(RTCMediaStream) *)stream;
+
+/** Called when a remote peer closes a stream.
+ * This is not called when RTCSdpSemanticsUnifiedPlan is specified.
+ */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didRemoveStream:(RTC_OBJC_TYPE(RTCMediaStream) *)stream;
+
+/** Called when negotiation is needed, for example ICE has restarted. */
+- (void)peerConnectionShouldNegotiate:
+ (RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection;
+
+/** Called any time the IceConnectionState changes. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didChangeIceConnectionState:(RTCIceConnectionState)newState;
+
+/** Called any time the IceGatheringState changes. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didChangeIceGatheringState:(RTCIceGatheringState)newState;
+
+/** New ice candidate has been found. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didGenerateIceCandidate:(RTC_OBJC_TYPE(RTCIceCandidate) *)candidate;
+
+/** Called when a group of local Ice candidates have been removed. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didRemoveIceCandidates:
+ (NSArray *)candidates;
+
+/** New data channel has been opened. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didOpenDataChannel:(RTC_OBJC_TYPE(RTCDataChannel) *)dataChannel;
+
+/** Called when signaling indicates a transceiver will be receiving media from
+ * the remote endpoint.
+ * This is only called with RTCSdpSemanticsUnifiedPlan specified.
+ */
+@optional
+/** Called any time the IceConnectionState changes following standardized
+ * transition. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didChangeStandardizedIceConnectionState:(RTCIceConnectionState)newState;
+
+/** Called any time the PeerConnectionState changes. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didChangeConnectionState:(RTCPeerConnectionState)newState;
+
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didStartReceivingOnTransceiver:
+ (RTC_OBJC_TYPE(RTCRtpTransceiver) *)transceiver;
+
+/** Called when a receiver and its track are created. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didAddReceiver:(RTC_OBJC_TYPE(RTCRtpReceiver) *)rtpReceiver
+ streams:(NSArray *)mediaStreams;
+
+/** Called when the receiver and its track are removed. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didRemoveReceiver:(RTC_OBJC_TYPE(RTCRtpReceiver) *)rtpReceiver;
+
+/** Called when the selected ICE candidate pair is changed. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didChangeLocalCandidate:(RTC_OBJC_TYPE(RTCIceCandidate) *)local
+ remoteCandidate:(RTC_OBJC_TYPE(RTCIceCandidate) *)remote
+ lastReceivedMs:(int)lastDataReceivedMs
+ changeReason:(NSString *)reason;
+
+/** Called when gathering of an ICE candidate failed. */
+- (void)peerConnection:(RTC_OBJC_TYPE(RTCPeerConnection) *)peerConnection
+ didFailToGatherIceCandidate:
+ (RTC_OBJC_TYPE(RTCIceCandidateErrorEvent) *)event;
+
+@end
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCPeerConnection) : NSObject
+
+/** The object that will be notifed about events such as state changes and
+ * streams being added or removed.
+ */
+@property(nonatomic, weak, nullable) id delegate;
+/** This property is not available with RTCSdpSemanticsUnifiedPlan. Please use
+ * `senders` instead.
+ */
+@property(nonatomic, readonly)
+ NSArray *localStreams;
+@property(nonatomic, readonly, nullable) RTC_OBJC_TYPE(RTCSessionDescription) *
+ localDescription;
+@property(nonatomic, readonly, nullable) RTC_OBJC_TYPE(RTCSessionDescription) *
+ remoteDescription;
+@property(nonatomic, readonly) RTCSignalingState signalingState;
+@property(nonatomic, readonly) RTCIceConnectionState iceConnectionState;
+@property(nonatomic, readonly) RTCPeerConnectionState connectionState;
+@property(nonatomic, readonly) RTCIceGatheringState iceGatheringState;
+@property(nonatomic, readonly, copy) RTC_OBJC_TYPE(RTCConfiguration) *
+ configuration;
+
+/** Gets all RTCRtpSenders associated with this peer connection.
+ * Note: reading this property returns different instances of RTCRtpSender.
+ * Use isEqual: instead of == to compare RTCRtpSender instances.
+ */
+@property(nonatomic, readonly) NSArray *senders;
+
+/** Gets all RTCRtpReceivers associated with this peer connection.
+ * Note: reading this property returns different instances of RTCRtpReceiver.
+ * Use isEqual: instead of == to compare RTCRtpReceiver instances.
+ */
+@property(nonatomic, readonly)
+ NSArray *receivers;
+
+/** Gets all RTCRtpTransceivers associated with this peer connection.
+ * Note: reading this property returns different instances of
+ * RTCRtpTransceiver. Use isEqual: instead of == to compare
+ * RTCRtpTransceiver instances. This is only available with
+ * RTCSdpSemanticsUnifiedPlan specified.
+ */
+@property(nonatomic, readonly)
+ NSArray *transceivers;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/** Sets the PeerConnection's global configuration to `configuration`.
+ * Any changes to STUN/TURN servers or ICE candidate policy will affect the
+ * next gathering phase, and cause the next call to createOffer to generate
+ * new ICE credentials. Note that the BUNDLE and RTCP-multiplexing policies
+ * cannot be changed with this method.
+ */
+- (BOOL)setConfiguration:(RTC_OBJC_TYPE(RTCConfiguration) *)configuration;
+
+/** Terminate all media and close the transport. */
+- (void)close;
+
+/** Provide a remote candidate to the ICE Agent. */
+- (void)addIceCandidate:(RTC_OBJC_TYPE(RTCIceCandidate) *)candidate
+ DEPRECATED_MSG_ATTRIBUTE(
+ "Please use addIceCandidate:completionHandler: instead");
+
+/** Provide a remote candidate to the ICE Agent. */
+- (void)addIceCandidate:(RTC_OBJC_TYPE(RTCIceCandidate) *)candidate
+ completionHandler:(void (^)(NSError *_Nullable error))completionHandler;
+
+/** Remove a group of remote candidates from the ICE Agent. */
+- (void)removeIceCandidates:
+ (NSArray *)candidates;
+
+/** Add a new media stream to be sent on this peer connection.
+ * This method is not supported with RTCSdpSemanticsUnifiedPlan. Please use
+ * addTrack instead.
+ */
+- (void)addStream:(RTC_OBJC_TYPE(RTCMediaStream) *)stream;
+
+/** Remove the given media stream from this peer connection.
+ * This method is not supported with RTCSdpSemanticsUnifiedPlan. Please use
+ * removeTrack instead.
+ */
+- (void)removeStream:(RTC_OBJC_TYPE(RTCMediaStream) *)stream;
+
+/** Add a new media stream track to be sent on this peer connection, and return
+ * the newly created RTCRtpSender. The RTCRtpSender will be
+ * associated with the streams specified in the `streamIds` list.
+ *
+ * Errors: If an error occurs, returns nil. An error can occur if:
+ * - A sender already exists for the track.
+ * - The peer connection is closed.
+ */
+- (nullable RTC_OBJC_TYPE(RTCRtpSender) *)
+ addTrack:(RTC_OBJC_TYPE(RTCMediaStreamTrack) *)track
+ streamIds:(NSArray *)streamIds;
+
+/** With PlanB semantics, removes an RTCRtpSender from this peer connection.
+ *
+ * With UnifiedPlan semantics, sets sender's track to null and removes the
+ * send component from the associated RTCRtpTransceiver's direction.
+ *
+ * Returns YES on success.
+ */
+- (BOOL)removeTrack:(RTC_OBJC_TYPE(RTCRtpSender) *)sender;
+
+/** addTransceiver creates a new RTCRtpTransceiver and adds it to the set of
+ * transceivers. Adding a transceiver will cause future calls to CreateOffer
+ * to add a media description for the corresponding transceiver.
+ *
+ * The initial value of `mid` in the returned transceiver is nil. Setting a
+ * new session description may change it to a non-nil value.
+ *
+ * https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-addtransceiver
+ *
+ * Optionally, an RtpTransceiverInit structure can be specified to configure
+ * the transceiver from construction. If not specified, the transceiver will
+ * default to having a direction of kSendRecv and not be part of any streams.
+ *
+ * These methods are only available when Unified Plan is enabled (see
+ * RTCConfiguration).
+ */
+
+/** Adds a transceiver with a sender set to transmit the given track. The kind
+ * of the transceiver (and sender/receiver) will be derived from the kind of
+ * the track.
+ */
+- (nullable RTC_OBJC_TYPE(RTCRtpTransceiver) *)addTransceiverWithTrack:
+ (RTC_OBJC_TYPE(RTCMediaStreamTrack) *)track;
+- (nullable RTC_OBJC_TYPE(RTCRtpTransceiver) *)
+ addTransceiverWithTrack:(RTC_OBJC_TYPE(RTCMediaStreamTrack) *)track
+ init:(RTC_OBJC_TYPE(RTCRtpTransceiverInit) *)init;
+
+/** Adds a transceiver with the given kind. Can either be RTCRtpMediaTypeAudio
+ * or RTCRtpMediaTypeVideo.
+ */
+- (nullable RTC_OBJC_TYPE(RTCRtpTransceiver) *)addTransceiverOfType:
+ (RTCRtpMediaType)mediaType;
+- (nullable RTC_OBJC_TYPE(RTCRtpTransceiver) *)
+ addTransceiverOfType:(RTCRtpMediaType)mediaType
+ init:(RTC_OBJC_TYPE(RTCRtpTransceiverInit) *)init;
+
+/** Tells the PeerConnection that ICE should be restarted. This triggers a need
+ * for negotiation and subsequent offerForConstraints:completionHandler call
+ * will act as if RTCOfferAnswerOptions::ice_restart is true.
+ */
+- (void)restartIce;
+
+/** Generate an SDP offer. */
+- (void)offerForConstraints:(RTC_OBJC_TYPE(RTCMediaConstraints) *)constraints
+ completionHandler:
+ (RTCCreateSessionDescriptionCompletionHandler)completionHandler;
+
+/** Generate an SDP answer. */
+- (void)answerForConstraints:(RTC_OBJC_TYPE(RTCMediaConstraints) *)constraints
+ completionHandler:
+ (RTCCreateSessionDescriptionCompletionHandler)completionHandler;
+
+/** Apply the supplied RTCSessionDescription as the local description. */
+- (void)setLocalDescription:(RTC_OBJC_TYPE(RTCSessionDescription) *)sdp
+ completionHandler:
+ (RTCSetSessionDescriptionCompletionHandler)completionHandler;
+
+/** Creates an offer or answer (depending on current signaling state) and sets
+ * it as the local session description. */
+- (void)setLocalDescriptionWithCompletionHandler:
+ (RTCSetSessionDescriptionCompletionHandler)completionHandler;
+
+/** Apply the supplied RTCSessionDescription as the remote description. */
+- (void)setRemoteDescription:(RTC_OBJC_TYPE(RTCSessionDescription) *)sdp
+ completionHandler:
+ (RTCSetSessionDescriptionCompletionHandler)completionHandler;
+
+/** Limits the bandwidth allocated for all RTP streams sent by this
+ * PeerConnection. Nil parameters will be unchanged. Setting
+ * `currentBitrateBps` will force the available bitrate estimate to the given
+ * value. Returns YES if the parameters were successfully updated.
+ */
+- (BOOL)setBweMinBitrateBps:(nullable NSNumber *)minBitrateBps
+ currentBitrateBps:(nullable NSNumber *)currentBitrateBps
+ maxBitrateBps:(nullable NSNumber *)maxBitrateBps;
+
+/** Start or stop recording an Rtc EventLog. */
+- (BOOL)startRtcEventLogWithFilePath:(NSString *)filePath
+ maxSizeInBytes:(int64_t)maxSizeInBytes;
+- (void)stopRtcEventLog;
+
+@end
+
+@interface RTC_OBJC_TYPE (RTCPeerConnection)
+(Media)
+
+ /** Create an RTCRtpSender with the specified kind and media stream ID.
+ * See RTCMediaStreamTrack.h for available kinds.
+ * This method is not supported with RTCSdpSemanticsUnifiedPlan. Please use
+ * addTransceiver instead.
+ */
+ - (RTC_OBJC_TYPE(RTCRtpSender) *)senderWithKind : (NSString *)kind streamId
+ : (NSString *)streamId;
+
+@end
+
+@interface RTC_OBJC_TYPE (RTCPeerConnection)
+(DataChannel)
+
+ /** Create a new data channel with the given label and configuration. */
+ - (nullable RTC_OBJC_TYPE(RTCDataChannel) *)dataChannelForLabel
+ : (NSString *)label configuration
+ : (RTC_OBJC_TYPE(RTCDataChannelConfiguration) *)configuration;
+
+@end
+
+typedef void (^RTCStatisticsCompletionHandler)(
+ RTC_OBJC_TYPE(RTCStatisticsReport) *);
+
+@interface RTC_OBJC_TYPE (RTCPeerConnection)
+(Stats)
+
+ /** Gather stats for the given RTCMediaStreamTrack. If `mediaStreamTrack` is
+ * nil statistics are gathered for all tracks.
+ */
+ - (void)statsForTrack : (nullable RTC_OBJC_TYPE(RTCMediaStreamTrack) *)
+ mediaStreamTrack statsOutputLevel
+ : (RTCStatsOutputLevel)statsOutputLevel completionHandler
+ : (nullable void (^)(NSArray *stats))
+ completionHandler;
+
+/** Gather statistic through the v2 statistics API. */
+- (void)statisticsWithCompletionHandler:
+ (RTCStatisticsCompletionHandler)completionHandler;
+
+/** Spec-compliant getStats() performing the stats selection algorithm with the
+ * sender.
+ */
+- (void)statisticsForSender:(RTC_OBJC_TYPE(RTCRtpSender) *)sender
+ completionHandler:(RTCStatisticsCompletionHandler)completionHandler;
+
+/** Spec-compliant getStats() performing the stats selection algorithm with the
+ * receiver.
+ */
+- (void)statisticsForReceiver:(RTC_OBJC_TYPE(RTCRtpReceiver) *)receiver
+ completionHandler:(RTCStatisticsCompletionHandler)completionHandler;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnectionFactory.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnectionFactory.h
new file mode 100644
index 00000000..7396beab
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnectionFactory.h
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2015 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class RTC_OBJC_TYPE(RTCRtpCapabilities);
+@class RTC_OBJC_TYPE(RTCAudioSource);
+@class RTC_OBJC_TYPE(RTCAudioTrack);
+@class RTC_OBJC_TYPE(RTCConfiguration);
+@class RTC_OBJC_TYPE(RTCMediaConstraints);
+@class RTC_OBJC_TYPE(RTCMediaStream);
+@class RTC_OBJC_TYPE(RTCPeerConnection);
+@class RTC_OBJC_TYPE(RTCVideoSource);
+@class RTC_OBJC_TYPE(RTCVideoTrack);
+@class RTC_OBJC_TYPE(RTCPeerConnectionFactoryOptions);
+@protocol RTC_OBJC_TYPE
+(RTCPeerConnectionDelegate);
+@protocol RTC_OBJC_TYPE
+(RTCVideoDecoderFactory);
+@protocol RTC_OBJC_TYPE
+(RTCVideoEncoderFactory);
+@protocol RTC_OBJC_TYPE
+(RTCSSLCertificateVerifier);
+@protocol RTC_OBJC_TYPE
+(RTCAudioDevice);
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCPeerConnectionFactory) : NSObject
+
+/* Initialize object with default H264 video encoder/decoder factories and default ADM */
+- (instancetype)init;
+
+/* Initialize object with injectable video encoder/decoder factories and default
+ * ADM */
+- (instancetype)
+ initWithEncoderFactory:
+ (nullable id)encoderFactory
+ decoderFactory:(nullable id)
+ decoderFactory;
+
+/* Initialize object with injectable video encoder/decoder factories and
+ * injectable ADM */
+- (instancetype)
+ initWithEncoderFactory:
+ (nullable id)encoderFactory
+ decoderFactory:(nullable id)
+ decoderFactory
+ audioDevice:
+ (nullable id)audioDevice;
+
+/**
+ * Valid kind values are kRTCMediaStreamTrackKindAudio and
+ * kRTCMediaStreamTrackKindVideo.
+ */
+- (RTC_OBJC_TYPE(RTCRtpCapabilities) *)rtpSenderCapabilitiesForKind:
+ (NSString *)kind;
+
+/**
+ * Valid kind values are kRTCMediaStreamTrackKindAudio and
+ * kRTCMediaStreamTrackKindVideo.
+ */
+- (RTC_OBJC_TYPE(RTCRtpCapabilities) *)rtpReceiverCapabilitiesForKind:
+ (NSString *)kind;
+
+/** Initialize an RTCAudioSource with constraints. */
+- (RTC_OBJC_TYPE(RTCAudioSource) *)audioSourceWithConstraints:
+ (nullable RTC_OBJC_TYPE(RTCMediaConstraints) *)constraints;
+
+/** Initialize an RTCAudioTrack with an id. Convenience ctor to use an audio
+ * source with no constraints.
+ */
+- (RTC_OBJC_TYPE(RTCAudioTrack) *)audioTrackWithTrackId:(NSString *)trackId;
+
+/** Initialize an RTCAudioTrack with a source and an id. */
+- (RTC_OBJC_TYPE(RTCAudioTrack) *)audioTrackWithSource:
+ (RTC_OBJC_TYPE(RTCAudioSource) *)source
+ trackId:(NSString *)trackId;
+
+/** Initialize a generic RTCVideoSource. The RTCVideoSource should be
+ * passed to a RTCVideoCapturer implementation, e.g.
+ * RTCCameraVideoCapturer, in order to produce frames.
+ */
+- (RTC_OBJC_TYPE(RTCVideoSource) *)videoSource;
+
+/** Initialize a generic RTCVideoSource with he posibility of marking
+ * it as usable for screen sharing. The RTCVideoSource should be
+ * passed to a RTCVideoCapturer implementation, e.g.
+ * RTCCameraVideoCapturer, in order to produce frames.
+ */
+- (RTC_OBJC_TYPE(RTCVideoSource) *)videoSourceForScreenCast:(BOOL)forScreenCast;
+
+/** Initialize an RTCVideoTrack with a source and an id. */
+- (RTC_OBJC_TYPE(RTCVideoTrack) *)videoTrackWithSource:
+ (RTC_OBJC_TYPE(RTCVideoSource) *)source
+ trackId:(NSString *)trackId;
+
+/** Initialize an RTCMediaStream with an id. */
+- (RTC_OBJC_TYPE(RTCMediaStream) *)mediaStreamWithStreamId:(NSString *)streamId;
+
+/** Initialize an RTCPeerConnection with a configuration, constraints, and
+ * delegate.
+ */
+- (nullable RTC_OBJC_TYPE(RTCPeerConnection) *)
+ peerConnectionWithConfiguration:
+ (RTC_OBJC_TYPE(RTCConfiguration) *)configuration
+ constraints:
+ (RTC_OBJC_TYPE(RTCMediaConstraints) *)constraints
+ delegate:(nullable id)delegate;
+
+- (nullable RTC_OBJC_TYPE(RTCPeerConnection) *)
+ peerConnectionWithConfiguration:
+ (RTC_OBJC_TYPE(RTCConfiguration) *)configuration
+ constraints:
+ (RTC_OBJC_TYPE(RTCMediaConstraints) *)constraints
+ certificateVerifier:
+ (id)
+ certificateVerifier
+ delegate:(nullable id)delegate;
+
+/** Set the options to be used for subsequently created RTCPeerConnections */
+- (void)setOptions:
+ (nonnull RTC_OBJC_TYPE(RTCPeerConnectionFactoryOptions) *)options;
+
+/** Start an AecDump recording. This API call will likely change in the future.
+ */
+- (BOOL)startAecDumpWithFilePath:(NSString *)filePath
+ maxSizeInBytes:(int64_t)maxSizeInBytes;
+
+/* Stop an active AecDump recording */
+- (void)stopAecDump;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnectionFactoryOptions.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnectionFactoryOptions.h
new file mode 100644
index 00000000..1c7a10d1
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCPeerConnectionFactoryOptions.h
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2017 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+RTC_OBJC_EXPORT
+@interface RTC_OBJC_TYPE (RTCPeerConnectionFactoryOptions) : NSObject
+
+@property(nonatomic, assign) BOOL disableEncryption;
+
+@property(nonatomic, assign) BOOL disableNetworkMonitor;
+
+@property(nonatomic, assign) BOOL ignoreLoopbackNetworkAdapter;
+
+@property(nonatomic, assign) BOOL ignoreVPNNetworkAdapter;
+
+@property(nonatomic, assign) BOOL ignoreCellularNetworkAdapter;
+
+@property(nonatomic, assign) BOOL ignoreWiFiNetworkAdapter;
+
+@property(nonatomic, assign) BOOL ignoreEthernetNetworkAdapter;
+
+- (instancetype)init NS_DESIGNATED_INITIALIZER;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCRtcpParameters.h b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCRtcpParameters.h
new file mode 100644
index 00000000..8449500f
--- /dev/null
+++ b/ios/Vendor/WebRTC.xcframework/ios-arm64/WebRTC.framework/Headers/RTCRtcpParameters.h
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2018 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import
+
+#import