Skip to content

Commit 3cb0c5f

Browse files
committed
fix: mask iOS simulator chrome
1 parent d297c57 commit 3cb0c5f

4 files changed

Lines changed: 92 additions & 56 deletions

File tree

ios/SimDeckStudio.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@
363363
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
364364
CODE_SIGN_ENTITLEMENTS = SimDeckStudio/SimDeckStudio.entitlements;
365365
CODE_SIGN_STYLE = Automatic;
366-
CURRENT_PROJECT_VERSION = 202605170407;
366+
CURRENT_PROJECT_VERSION = 202605172332;
367367
DEVELOPMENT_ASSET_PATHS = "";
368368
DEVELOPMENT_TEAM = CS838V553Y;
369369
ENABLE_PREVIEWS = YES;
@@ -392,7 +392,7 @@
392392
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
393393
CODE_SIGN_ENTITLEMENTS = SimDeckStudio/SimDeckStudio.entitlements;
394394
CODE_SIGN_STYLE = Automatic;
395-
CURRENT_PROJECT_VERSION = 202605170407;
395+
CURRENT_PROJECT_VERSION = 202605172332;
396396
DEVELOPMENT_ASSET_PATHS = "";
397397
DEVELOPMENT_TEAM = CS838V553Y;
398398
ENABLE_PREVIEWS = YES;

ios/SimDeckStudio/App/AppModel.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ private struct EmptyControlPayload: Encodable {}
3636
private struct ChromeAssets {
3737
var profile: ChromeProfile?
3838
var image: UIImage?
39+
var screenMask: UIImage?
3940

4041
var isEmpty: Bool {
41-
profile == nil && image == nil
42+
profile == nil && image == nil && screenMask == nil
4243
}
4344
}
4445

@@ -67,6 +68,7 @@ final class AppModel {
6768
var videoSize: CGSize = .zero
6869
var chromeProfile: ChromeProfile?
6970
var chromeImage: UIImage?
71+
var chromeScreenMask: UIImage?
7072
var streamDiagnostics = StreamDiagnostics()
7173
var bootingSimulatorID: String?
7274
var streamDisplayToken = 0
@@ -923,6 +925,7 @@ final class AppModel {
923925
if !applyCachedChromeAssetsForSelection() {
924926
chromeProfile = nil
925927
chromeImage = nil
928+
chromeScreenMask = nil
926929
}
927930
if !applyCachedLastStreamFrameForSelection() {
928931
lastStreamFrameKey = nil
@@ -946,7 +949,13 @@ final class AppModel {
946949

947950
let loadedProfile = try? await api.chromeProfile(udid: simulatorID)
948951
let loadedImage = try? await api.chromeImage(udid: simulatorID)
949-
let loadedAssets = ChromeAssets(profile: loadedProfile, image: loadedImage)
952+
let loadedScreenMask: UIImage?
953+
if loadedProfile?.hasScreenMask == true {
954+
loadedScreenMask = try? await api.screenMaskImage(udid: simulatorID)
955+
} else {
956+
loadedScreenMask = nil
957+
}
958+
let loadedAssets = ChromeAssets(profile: loadedProfile, image: loadedImage, screenMask: loadedScreenMask)
950959
cacheChromeAssets(loadedAssets, endpoint: endpoint, simulatorID: simulatorID)
951960
return loadedAssets
952961
}
@@ -964,6 +973,7 @@ final class AppModel {
964973
private func applyChromeAssets(_ assets: ChromeAssets) {
965974
chromeProfile = assets.profile
966975
chromeImage = assets.image
976+
chromeScreenMask = assets.screenMask
967977
}
968978

969979
private func cachedChromeAssets(endpoint: SimDeckEndpoint, simulatorID: String) -> ChromeAssets? {

ios/SimDeckStudio/Networking/SimDeckAPI.swift

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ struct SimDeckAPI: Sendable {
7171

7272
func chromeProfile(udid: String) async throws -> ChromeProfile {
7373
try await decode(
74-
path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/chrome-profile"
74+
path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/chrome-profile",
75+
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData
7576
)
7677
}
7778

@@ -80,7 +81,22 @@ struct SimDeckAPI: Sendable {
8081
path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/chrome.png",
8182
method: "GET",
8283
body: Optional<String>.none,
83-
timeout: 10
84+
timeout: 10,
85+
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData
86+
)
87+
guard let image = UIImage(data: data) else {
88+
throw SimDeckAPIError.invalidResponse
89+
}
90+
return image
91+
}
92+
93+
func screenMaskImage(udid: String) async throws -> UIImage {
94+
let data = try await request(
95+
path: "/api/simulators/\(udid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? udid)/screen-mask.png",
96+
method: "GET",
97+
body: Optional<String>.none,
98+
timeout: 10,
99+
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData
84100
)
85101
guard let image = UIImage(data: data) else {
86102
throw SimDeckAPIError.invalidResponse
@@ -96,9 +112,10 @@ struct SimDeckAPI: Sendable {
96112
path: String,
97113
method: String = "GET",
98114
body: (some Encodable)? = Optional<String>.none,
99-
timeout: TimeInterval = 10
115+
timeout: TimeInterval = 10,
116+
cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
100117
) async throws -> T {
101-
let data = try await request(path: path, method: method, body: body, timeout: timeout)
118+
let data = try await request(path: path, method: method, body: body, timeout: timeout, cachePolicy: cachePolicy)
102119
if data.isEmpty, T.self == EmptyResponse.self {
103120
return EmptyResponse() as! T
104121
}
@@ -109,21 +126,28 @@ struct SimDeckAPI: Sendable {
109126
path: String,
110127
method: String,
111128
body: (some Encodable)?,
112-
timeout: TimeInterval
129+
timeout: TimeInterval,
130+
cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
113131
) async throws -> Data {
114-
let (data, _) = try await requestWithHTTPResponse(path: path, method: method, body: body, timeout: timeout)
132+
let (data, _) = try await requestWithHTTPResponse(
133+
path: path,
134+
method: method,
135+
body: body,
136+
timeout: timeout,
137+
cachePolicy: cachePolicy
138+
)
115139
return data
116140
}
117141

118142
private func requestWithHTTPResponse(
119143
path: String,
120144
method: String,
121145
body: (some Encodable)?,
122-
timeout: TimeInterval
146+
timeout: TimeInterval,
147+
cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
123148
) async throws -> (Data, HTTPURLResponse) {
124-
var request = URLRequest(url: url(for: path))
149+
var request = URLRequest(url: url(for: path), cachePolicy: cachePolicy, timeoutInterval: timeout)
125150
request.httpMethod = method
126-
request.timeoutInterval = timeout
127151
request.setValue("application/json", forHTTPHeaderField: "Accept")
128152
request.setValue(originHeaderValue, forHTTPHeaderField: "Origin")
129153
if let token = endpoint.token?.nilIfBlank {

ios/SimDeckStudio/Views/SimulatorStreamView.swift

Lines changed: 45 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -139,22 +139,27 @@ struct SimulatorStreamView: View {
139139
GeometryReader { proxy in
140140
let layout = DeviceViewportLayout(
141141
chromeProfile: model.chromeProfile,
142-
chromeImageSize: model.chromeImage?.size,
143142
videoSize: model.videoSize,
144143
availableSize: proxy.size
145144
)
146145
let displayToken = model.streamDisplayToken
146+
let screenMaskImage = model.chromeProfile?.hasScreenMask == true ? model.chromeScreenMask : nil
147147

148148
ZStack(alignment: .topLeading) {
149149
streamBackground
150150

151-
RoundedRectangle(cornerRadius: layout.screenCornerRadius + 2, style: .continuous)
151+
Rectangle()
152152
.fill(.black)
153153
.frame(width: layout.screenBackingFrame.width, height: layout.screenBackingFrame.height)
154+
.clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius + 2, maskImage: screenMaskImage)
154155
.position(x: layout.screenBackingFrame.midX, y: layout.screenBackingFrame.midY)
155156

156157
if showsCachedStreamFrame, let lastStreamFrame = model.lastStreamFrame {
157-
CachedStreamFrameView(image: lastStreamFrame, cornerRadius: layout.screenCornerRadius + 1)
158+
CachedStreamFrameView(
159+
image: lastStreamFrame,
160+
cornerRadius: layout.screenCornerRadius + 1,
161+
maskImage: screenMaskImage
162+
)
158163
.frame(width: layout.videoFrame.width, height: layout.videoFrame.height)
159164
.position(x: layout.videoFrame.midX, y: layout.videoFrame.midY)
160165
.transition(.opacity)
@@ -175,7 +180,7 @@ struct SimulatorStreamView: View {
175180
)
176181
.id(displayToken)
177182
.frame(width: layout.videoFrame.width, height: layout.videoFrame.height)
178-
.clipShape(RoundedRectangle(cornerRadius: layout.screenCornerRadius + 1, style: .continuous))
183+
.clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius + 1, maskImage: screenMaskImage)
179184
.position(x: layout.videoFrame.midX, y: layout.videoFrame.midY)
180185
.opacity(model.hasCurrentStreamFrame ? 1 : 0)
181186
}
@@ -199,19 +204,22 @@ struct SimulatorStreamView: View {
199204
if let simulator = model.selectedSimulator, !simulator.isBooted {
200205
BootSimulatorOverlay(model: model, simulator: simulator)
201206
.frame(width: layout.screenFrame.width, height: layout.screenFrame.height)
207+
.clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius, maskImage: screenMaskImage)
202208
.position(x: layout.screenFrame.midX, y: layout.screenFrame.midY)
203209
}
204210

205211
if showsFirstFrameSpinner {
206212
StreamFirstFrameLoadingOverlay()
207213
.frame(width: layout.screenFrame.width, height: layout.screenFrame.height)
214+
.clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius, maskImage: screenMaskImage)
208215
.position(x: layout.screenFrame.midX, y: layout.screenFrame.midY)
209216
.transition(.opacity)
210217
}
211218

212219
if showsRetryOverlay {
213220
StreamRetryOverlay(model: model)
214221
.frame(width: layout.screenFrame.width, height: layout.screenFrame.height)
222+
.clippedToSimulatorScreen(cornerRadius: layout.screenCornerRadius, maskImage: screenMaskImage)
215223
.position(x: layout.screenFrame.midX, y: layout.screenFrame.midY)
216224
.transition(.opacity)
217225
}
@@ -350,6 +358,7 @@ private struct StreamFirstFrameLoadingOverlay: View {
350358
private struct CachedStreamFrameView: View {
351359
let image: UIImage
352360
let cornerRadius: CGFloat
361+
let maskImage: UIImage?
353362

354363
var body: some View {
355364
Image(uiImage: image)
@@ -358,7 +367,7 @@ private struct CachedStreamFrameView: View {
358367
.saturation(0.82)
359368
.brightness(-0.08)
360369
.overlay(Color.black.opacity(0.28))
361-
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
370+
.clippedToSimulatorScreen(cornerRadius: cornerRadius, maskImage: maskImage)
362371
.shadow(color: .black.opacity(0.34), radius: 16, y: 8)
363372
.clipped()
364373
.allowsHitTesting(false)
@@ -927,9 +936,8 @@ private struct DeviceViewportLayout {
927936
let screenCornerRadius: CGFloat
928937
let usesChrome: Bool
929938
private let chromeCoordinateScale: CGFloat
930-
private let chromeMetricScale: Double
931939

932-
init(chromeProfile: ChromeProfile?, chromeImageSize: CGSize?, videoSize: CGSize, availableSize: CGSize) {
940+
init(chromeProfile: ChromeProfile?, videoSize: CGSize, availableSize: CGSize) {
933941
let viewport = CGRect(origin: .zero, size: availableSize)
934942
.insetBy(dx: min(20, availableSize.width * 0.045), dy: 16)
935943

@@ -941,13 +949,11 @@ private struct DeviceViewportLayout {
941949
viewport.width > 0,
942950
viewport.height > 0 {
943951
let profileSize = CGSize(width: CGFloat(chromeProfile.totalWidth), height: CGFloat(chromeProfile.totalHeight))
944-
let metricScale = Self.chromeMetricScale(profile: chromeProfile, imageSize: chromeImageSize)
945952
let shell = profileSize.aspectFit(in: viewport)
946953
let scale = shell.width / profileSize.width
947-
let screenRect = Self.chromeScreenRect(profile: chromeProfile, videoSize: videoSize, metricScale: metricScale)
954+
let screenRect = Self.chromeScreenRect(profile: chromeProfile, videoSize: videoSize)
948955
shellFrame = shell
949956
chromeCoordinateScale = scale
950-
chromeMetricScale = metricScale
951957
screenFrame = CGRect(
952958
x: shell.minX + screenRect.minX * scale,
953959
y: shell.minY + screenRect.minY * scale,
@@ -959,8 +965,7 @@ private struct DeviceViewportLayout {
959965
screenCornerRadius = Self.screenCornerRadius(
960966
profile: chromeProfile,
961967
profileScreenRect: screenRect,
962-
scale: scale,
963-
metricScale: metricScale
968+
scale: scale
964969
)
965970
usesChrome = true
966971
return
@@ -977,39 +982,23 @@ private struct DeviceViewportLayout {
977982
screenCornerRadius = min(44, screen.width * 0.14)
978983
usesChrome = false
979984
chromeCoordinateScale = 1
980-
chromeMetricScale = 1
981985
}
982986

983987
func chromeButtonFrame(_ button: ChromeButtonProfile) -> CGRect {
984988
guard usesChrome else { return .zero }
985989
return CGRect(
986-
x: shellFrame.minX + CGFloat(button.x / chromeMetricScale) * chromeCoordinateScale,
987-
y: shellFrame.minY + CGFloat(button.y / chromeMetricScale) * chromeCoordinateScale,
988-
width: CGFloat(button.width / chromeMetricScale) * chromeCoordinateScale,
989-
height: CGFloat(button.height / chromeMetricScale) * chromeCoordinateScale
990+
x: shellFrame.minX + CGFloat(button.x) * chromeCoordinateScale,
991+
y: shellFrame.minY + CGFloat(button.y) * chromeCoordinateScale,
992+
width: CGFloat(button.width) * chromeCoordinateScale,
993+
height: CGFloat(button.height) * chromeCoordinateScale
990994
)
991995
}
992996

993-
private static func chromeMetricScale(profile: ChromeProfile, imageSize: CGSize?) -> Double {
994-
guard profile.totalWidth > 0,
995-
profile.totalHeight > 0,
996-
let imageSize,
997-
imageSize.width > 0,
998-
imageSize.height > 0,
999-
profile.screenWidth > profile.totalWidth || profile.screenHeight > profile.totalHeight else {
1000-
return 1
1001-
}
1002-
let widthScale = Double(imageSize.width) / profile.totalWidth
1003-
let heightScale = Double(imageSize.height) / profile.totalHeight
1004-
let scale = min(widthScale, heightScale)
1005-
return scale.isFinite && scale > 1 ? scale : 1
1006-
}
1007-
1008-
private static func chromeScreenRect(profile: ChromeProfile, videoSize: CGSize, metricScale: Double) -> CGRect {
1009-
let profileScreenWidth = profile.screenWidth / metricScale
1010-
let profileScreenHeight = profile.screenHeight / metricScale
1011-
let profileScreenX = profile.screenX / metricScale
1012-
let profileScreenY = profile.screenY / metricScale
997+
private static func chromeScreenRect(profile: ChromeProfile, videoSize: CGSize) -> CGRect {
998+
let profileScreenWidth = profile.screenWidth
999+
let profileScreenHeight = profile.screenHeight
1000+
let profileScreenX = profile.screenX
1001+
let profileScreenY = profile.screenY
10131002
let profileAspect = profileScreenWidth / profileScreenHeight
10141003
let videoAspect = videoSize.width > 0 && videoSize.height > 0
10151004
? Double(videoSize.width / videoSize.height)
@@ -1047,12 +1036,12 @@ private struct DeviceViewportLayout {
10471036
return CGRect(x: CGFloat(x), y: CGFloat(y), width: CGFloat(width), height: CGFloat(height))
10481037
}
10491038

1050-
private static func screenCornerRadius(profile: ChromeProfile, profileScreenRect: CGRect, scale: CGFloat, metricScale: Double) -> CGFloat {
1039+
private static func screenCornerRadius(profile: ChromeProfile, profileScreenRect: CGRect, scale: CGFloat) -> CGFloat {
10511040
let fullScreen = CGRect(
1052-
x: CGFloat(profile.screenX / metricScale),
1053-
y: CGFloat(profile.screenY / metricScale),
1054-
width: CGFloat(profile.screenWidth / metricScale),
1055-
height: CGFloat(profile.screenHeight / metricScale)
1041+
x: CGFloat(profile.screenX),
1042+
y: CGFloat(profile.screenY),
1043+
width: CGFloat(profile.screenWidth),
1044+
height: CGFloat(profile.screenHeight)
10561045
)
10571046
guard abs(profileScreenRect.minX - fullScreen.minX) <= 0.5,
10581047
abs(profileScreenRect.minY - fullScreen.minY) <= 0.5,
@@ -1063,7 +1052,7 @@ private struct DeviceViewportLayout {
10631052
return min(
10641053
profileScreenRect.width * scale / 2,
10651054
profileScreenRect.height * scale / 2,
1066-
CGFloat(profile.cornerRadius / metricScale) * scale
1055+
CGFloat(profile.cornerRadius) * scale
10671056
)
10681057
}
10691058
}
@@ -1112,6 +1101,19 @@ private extension CGSize {
11121101
}
11131102

11141103
private extension View {
1104+
@ViewBuilder
1105+
func clippedToSimulatorScreen(cornerRadius: CGFloat, maskImage: UIImage?) -> some View {
1106+
if let maskImage {
1107+
self.mask(
1108+
Image(uiImage: maskImage)
1109+
.resizable()
1110+
.scaledToFill()
1111+
)
1112+
} else {
1113+
self.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
1114+
}
1115+
}
1116+
11151117
@ViewBuilder
11161118
func streamTouchGesture<G: Gesture>(_ enabled: Bool, gesture: G) -> some View {
11171119
if enabled {

0 commit comments

Comments
 (0)