Skip to content

Commit 3f69f72

Browse files
authored
feat: add drag-and-drop app install for selected simulators (#42)
* feat: add drag-and-drop app install support * docs: update commit message instructions
1 parent bebc4ef commit 3f69f72

17 files changed

Lines changed: 634 additions & 12 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ simdeck boot <udid>
121121
simdeck shutdown <udid>
122122
simdeck erase <udid>
123123
simdeck install <udid> /path/to/App.app
124+
simdeck install <udid> /path/to/App.ipa
124125
simdeck install android:<avd-name> /path/to/app.apk
125126
simdeck uninstall <udid> com.example.App
126127
simdeck open-url <udid> https://example.com

cli/XCWSimctl.m

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
#import "XCWPrivateSimulatorBooter.h"
66
#import "XCWProcessRunner.h"
77

8+
#import <errno.h>
9+
#import <stdlib.h>
10+
#import <string.h>
11+
812
static NSString * const XCWSimctlErrorDomain = @"SimDeck.Simctl";
913

1014
@interface XCWSimctl ()
@@ -19,6 +23,9 @@ + (nullable XCWProcessResult *)runSimctl:(NSArray<NSString *> *)arguments
1923
timeoutSec:(NSTimeInterval)timeoutSec
2024
error:(NSError * _Nullable __autoreleasing *)error;
2125
+ (nullable NSDictionary *)listJSONPayloadWithError:(NSError * _Nullable __autoreleasing *)error;
26+
+ (NSError *)errorWithDescription:(NSString *)description code:(NSInteger)code;
27+
- (BOOL)installAppBundleAtPath:(NSString *)appPath simulatorUDID:(NSString *)udid error:(NSError * _Nullable __autoreleasing *)error;
28+
- (BOOL)installIPAAtPath:(NSString *)ipaPath simulatorUDID:(NSString *)udid error:(NSError * _Nullable __autoreleasing *)error;
2229

2330
@end
2431

@@ -43,6 +50,70 @@ + (nullable NSDictionary *)listJSONPayloadWithError:(NSError * _Nullable __autor
4350
return [value isKindOfClass:[NSNumber class]] ? value : nil;
4451
}
4552

53+
static NSString * _Nullable XCWCreateTemporaryDirectory(NSString *prefix, NSError * _Nullable __autoreleasing *error) {
54+
NSString *templatePath = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSString stringWithFormat:@"%@-XXXXXX", prefix]];
55+
char *directoryTemplate = strdup(templatePath.fileSystemRepresentation);
56+
if (directoryTemplate == NULL) {
57+
if (error != NULL) {
58+
*error = [XCWSimctl errorWithDescription:@"Failed to allocate temporary IPA extraction path." code:15];
59+
}
60+
return nil;
61+
}
62+
63+
char *createdPath = mkdtemp(directoryTemplate);
64+
if (createdPath == NULL) {
65+
if (error != NULL) {
66+
*error = [XCWSimctl errorWithDescription:[NSString stringWithFormat:@"Failed to create temporary IPA extraction directory: %s", strerror(errno)] code:15];
67+
}
68+
free(directoryTemplate);
69+
return nil;
70+
}
71+
72+
NSString *path = [[NSFileManager defaultManager] stringWithFileSystemRepresentation:createdPath
73+
length:strlen(createdPath)];
74+
free(directoryTemplate);
75+
return path;
76+
}
77+
78+
static NSString * _Nullable XCWAppBundlePathInExtractedIPA(NSString *extractedPath, NSError * _Nullable __autoreleasing *error) {
79+
NSFileManager *fileManager = [NSFileManager defaultManager];
80+
NSString *payloadPath = [extractedPath stringByAppendingPathComponent:@"Payload"];
81+
BOOL isDirectory = NO;
82+
if (![fileManager fileExistsAtPath:payloadPath isDirectory:&isDirectory] || !isDirectory) {
83+
if (error != NULL) {
84+
*error = [XCWSimctl errorWithDescription:@"IPA archive did not contain a Payload directory." code:15];
85+
}
86+
return nil;
87+
}
88+
89+
NSArray<NSString *> *entries = [fileManager contentsOfDirectoryAtPath:payloadPath error:error];
90+
if (entries == nil) {
91+
return nil;
92+
}
93+
94+
NSMutableArray<NSString *> *appPaths = [NSMutableArray array];
95+
for (NSString *entry in entries) {
96+
if ([entry.pathExtension caseInsensitiveCompare:@"app"] != NSOrderedSame) {
97+
continue;
98+
}
99+
NSString *candidatePath = [payloadPath stringByAppendingPathComponent:entry];
100+
BOOL candidateIsDirectory = NO;
101+
if ([fileManager fileExistsAtPath:candidatePath isDirectory:&candidateIsDirectory] && candidateIsDirectory) {
102+
[appPaths addObject:candidatePath];
103+
}
104+
}
105+
106+
if (appPaths.count == 0) {
107+
if (error != NULL) {
108+
*error = [XCWSimctl errorWithDescription:@"IPA archive did not contain a Payload/*.app bundle." code:15];
109+
}
110+
return nil;
111+
}
112+
113+
[appPaths sortUsingSelector:@selector(localizedStandardCompare:)];
114+
return appPaths.firstObject;
115+
}
116+
46117
static NSString *XCWRuntimeDisplayName(NSDictionary *runtime, NSString *runtimeIdentifier) {
47118
NSString *name = XCWStringValue(runtime[@"name"]);
48119
if (name.length > 0 && ![name isEqualToString:runtimeIdentifier]) {
@@ -591,6 +662,54 @@ - (BOOL)eraseSimulatorWithUDID:(NSString *)udid error:(NSError * _Nullable __aut
591662
}
592663

593664
- (BOOL)installAppAtPath:(NSString *)appPath simulatorUDID:(NSString *)udid error:(NSError * _Nullable __autoreleasing *)error {
665+
NSString *extension = appPath.pathExtension.lowercaseString;
666+
if ([extension isEqualToString:@"ipa"]) {
667+
return [self installIPAAtPath:appPath simulatorUDID:udid error:error];
668+
}
669+
if (![extension isEqualToString:@"app"]) {
670+
if (error != NULL) {
671+
*error = [self.class errorWithDescription:@"iOS simulator install expects an `.app` bundle or `.ipa` archive." code:15];
672+
}
673+
return NO;
674+
}
675+
return [self installAppBundleAtPath:appPath simulatorUDID:udid error:error];
676+
}
677+
678+
- (BOOL)installIPAAtPath:(NSString *)ipaPath simulatorUDID:(NSString *)udid error:(NSError * _Nullable __autoreleasing *)error {
679+
NSString *extractedPath = XCWCreateTemporaryDirectory(@"simdeck-ipa", error);
680+
if (extractedPath == nil) {
681+
return NO;
682+
}
683+
684+
XCWProcessResult *extractResult = [XCWProcessRunner runLaunchPath:@"/usr/bin/ditto"
685+
arguments:@[@"-x", @"-k", ipaPath, extractedPath]
686+
inputData:nil
687+
timeoutSec:180
688+
error:error];
689+
if (extractResult == nil) {
690+
[[NSFileManager defaultManager] removeItemAtPath:extractedPath error:nil];
691+
return NO;
692+
}
693+
if (extractResult.terminationStatus != 0) {
694+
if (error != NULL) {
695+
*error = [self.class errorWithDescription:extractResult.stderrString.length > 0 ? extractResult.stderrString : @"Unable to extract IPA archive." code:15];
696+
}
697+
[[NSFileManager defaultManager] removeItemAtPath:extractedPath error:nil];
698+
return NO;
699+
}
700+
701+
NSString *appBundlePath = XCWAppBundlePathInExtractedIPA(extractedPath, error);
702+
if (appBundlePath == nil) {
703+
[[NSFileManager defaultManager] removeItemAtPath:extractedPath error:nil];
704+
return NO;
705+
}
706+
707+
BOOL ok = [self installAppBundleAtPath:appBundlePath simulatorUDID:udid error:error];
708+
[[NSFileManager defaultManager] removeItemAtPath:extractedPath error:nil];
709+
return ok;
710+
}
711+
712+
- (BOOL)installAppBundleAtPath:(NSString *)appPath simulatorUDID:(NSString *)udid error:(NSError * _Nullable __autoreleasing *)error {
594713
XCWProcessResult *result = [self.class runSimctl:@[@"install", udid, appPath] error:error];
595714
if (result == nil) {
596715
return NO;

client/src/api/controls.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
ButtonPayload,
55
CrownPayload,
66
EdgeTouchPayload,
7+
InstallUploadResponse,
78
KeyPayload,
89
LaunchPayload,
910
MultiTouchPayload,
@@ -69,6 +70,23 @@ export function launchSimulatorBundle(udid: string, payload: LaunchPayload) {
6970
return postSimulatorAction(udid, "launch", payload);
7071
}
7172

73+
export function uploadSimulatorApp(
74+
udid: string,
75+
file: File,
76+
): Promise<InstallUploadResponse> {
77+
return apiRequest<InstallUploadResponse>(
78+
`/api/simulators/${encodeURIComponent(udid)}/install-upload`,
79+
{
80+
body: file,
81+
headers: {
82+
"Content-Type": "application/octet-stream",
83+
"X-SimDeck-Filename": encodeURIComponent(file.name || "app-upload"),
84+
},
85+
method: "POST",
86+
},
87+
);
88+
}
89+
7290
export function sendTouch(udid: string, payload: TouchPayload) {
7391
return postSimulatorAction(udid, "touch", payload);
7492
}

client/src/api/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ export interface SimulatorResponse {
199199
simulator: SimulatorMetadata;
200200
}
201201

202+
export interface InstallUploadResponse {
203+
action: "install";
204+
fileName: string;
205+
ok: boolean;
206+
udid: string;
207+
}
208+
202209
export interface SimulatorForegroundApp {
203210
appName?: string | null;
204211
bundleIdentifier?: string | null;

0 commit comments

Comments
 (0)