-
Notifications
You must be signed in to change notification settings - Fork 7
Making an iOS app from scratch
We carry supercomputers around in our pockets. But can we make them run the programs we want?
On the laptop where I'm writing this, I can make this C program:
#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
}
Then compile and run it like so:
$ gcc hello.c -o hello
$ ./hello
Hello, world!
Can I do the same on an iPhone? How do I even get a terminal on an iPhone?
I don't have an iPhone, but luckily I'm on a mac, so I own a fake iPhone:
$ open -a Simulator
I'm sure that only works because I installed something previously. Now about that terminal... There's no terminal app on the iPhone. At least not built in. Is there?
There's xcrun simctl
. I found that command magically in a previous life. After running xcrun simctl help
this command look promising:
-
spawn
- Spawn a process by executing a given executable on a device.
Let's see who I am (the booted
means use the booted device):
$ xcrun simctl spawn booted whoami
An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=2):
The operation couldn’t be completed. No such file or directory
No such file or directory
No dice. echo
?
$ xcrun simctl spawn booted echo hello
An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=2):
The operation couldn’t be completed. No such file or directory
No such file or directory
This supercomputer isn't very super. Or normal macOS.
After too much searching, I've learned that available commands I can spawn are the executable files within /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/bin/
. Obviously.
But none of them seem like much help. Though it looks like I might have success running an App™ instead of a program. I can xcrun simctl install
an App™.
How hard can it be to make an App™? This easy:
$ mkdir HelloWorld.app
Now install it:
$ xcrun simctl install booted HelloWorld.app/
An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=22):
Failed to install the requested application
The bundle identifier of the application could not be determined.
Ensure that the application's Info.plist contains a value for CFBundleIdentifier.
Oh, I need an Info.plist
. Google around a bit and I get this:
$ cat > HelloWorld.app/Info.plist <<EOF
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>HelloWorld</string>
<key>CFBundleIdentifier</key>
<string>com.example.helloworld</string>
<key>CFBundleExecutable</key>
<string>helloworld</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0.1</string>
</dict>
</plist>
EOF
But I've lied. I told Info.plist
that my executable would be named helloworld
and there's no such file. Let's try this (though I seriously doubt it will work):
$ gcc hello.c -o HelloWorld.app/helloworld
$ xcrun simctl install booted HelloWorld.app/
It worked. Well, it installed.
When I tap the app, it starts then immediately closes just like the hello.c
program did on my laptop. I can only assume that the program ran correctly and printed Hello, World!
into the universe.
Success!
Maybe it prints it to a log that I can stream?
$ xcrun simctl spawn booted log stream
# then I clicked on the app in the simulator
... (much later)
2020-02-25 22:34:56.549946-0500 0x3e53cc Default 0x0 7193 0 SpringBoard: (SpringBoardHome) [com.apple.SpringBoard:Icon] Allowing tap for icon view 'com.example.helloworld'
... (lots of noise)
2020-02-25 22:34:56.785807-0500 0x3ee571 Default 0x19ab73 7193 0 SpringBoard: (FrontBoard) [com.apple.FrontBoard:Process] [application<com.example.helloworld>:11584] Launch failed.
No, and it looks like the program actually failed. That's fair. I compiled for my laptop, not a supercomputer.
Nim is a fun language. Helpfully, it will compile to C, C++ or even... Objective-C. Objective-C is like C, but with terrible syntax. It's mostly found on pocket-sized supercomputers.
Here's a Nim-style hello world:
echo "Hello, world!"
Compile (c
) and run it:
$ nim c hello.nim
$ ./hello
Hello, world!
You can also target different operating systems with Nim. The docs even describe how to compile for iOS, but we're going to mostly ignore them for now because it wants us to open Xcode and we don't want to do that. Let's see what this does:
$ nim c --os:ios -o:HelloWorld.app/helloworld hello.nim
$ xcrun simctl install booted HelloWorld.app/
$ xcrun simctl launch booted com.example.helloworld
com.example.helloworld: 12034
Launch still failed.
I've skipped a bunch of steps and will just show you this new file. It's an abomination of Objective-C and Nim:
{.passL: "-framework Foundation".}
{.passL: "-framework UIKit" .}
{.passL: "-framework WebKit" .}
{.emit: """
#include <UIKit/UIKit.h>
#include <WebKit/WebKit.h>
#include <CoreFoundation/CoreFoundation.h>
#import <os/log.h>
os_log_t logtype = OS_LOG_DEFAULT;
@interface AppDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@end
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.window.rootViewController = [[UIViewController alloc] init];
self.window.backgroundColor = [UIColor blueColor];
[self.window makeKeyAndVisible];
return YES;
}
@end
N_CDECL(void, NimMain)(void);
int main(int argc, char * argv[]) {
@autoreleasepool {
NimMain();
logtype = os_log_create("com.example.helloworld", "info");
os_log(logtype, "%s", "Hello, World!");
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
""" .}
$ nim objc --os:macosx --cpu:amd64 --out:HelloWorld.app/helloworld --noMain --passL:-mios-simulator-version-min=13.2 --passL:-isysroot --passL:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.2.sdk --passC:-mios-simulator-version-min=13.2 --passC:-isysroot --passC:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.2.sdk hello2.nim
$ xcrun simctl install booted HelloWorld.app/
Would you look at that! Hello, World!
in the logs below and the Simulator shows a blue screen of life.
# from xcrun simctl spawn booted log stream
...
2020-02-25 23:28:51.214500-0500 0x3fa170 Default 0x0 16830 0 helloworld: [com.example.helloworld:info] Hello, World!
...
You might notice that the code above is actually all Objective-C wrapped in a Nim {.emit.}
block. I'd rather write Nim than Objective-C. This version shows how Nim can be mixed in. See the configureLogger
and emitLog
procs:
const appBundleIdentifier {.strdefine, exportc.}: string = ""
{.passL: "-framework Foundation".}
{.passL: "-framework UIKit" .}
{.passL: "-framework WebKit" .}
{.emit: """
#include <UIKit/UIKit.h>
#include <WebKit/WebKit.h>
#include <CoreFoundation/CoreFoundation.h>
#import <os/log.h>
os_log_t logtype = OS_LOG_DEFAULT;
""".}
proc configureLogger(subsystem: cstring) {.exportc.} =
var the_id {.exportc.} = subsystem
{.emit: """
logtype = os_log_create(the_id, "info");
""".}
if appBundleIdentifier != "":
configureLogger(appBundleIdentifier)
proc emitLog(msg: cstring) {.exportc.} =
var message {.exportc.} = msg
{.emit: """
os_log(logtype, "%s", message);
""" .}
{.emit: """
@interface AppDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@end
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.window.rootViewController = [[UIViewController alloc] init];
self.window.backgroundColor = [UIColor blueColor];
[self.window makeKeyAndVisible];
return YES;
}
@end
N_CDECL(void, NimMain)(void);
int main(int argc, char * argv[]) {
@autoreleasepool {
NimMain();
emitLog("Hello from Nim");
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
""" .}
$ nim objc --os:macosx --cpu:amd64 --out:HelloWorld.app/helloworld --noMain --passL:-mios-simulator-version-min=13.2 --passL:-isysroot --passL:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.2.sdk --passC:-mios-simulator-version-min=13.2 --passC:-isysroot --passC:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.2.sdk hello3.nim
$ xcrun simctl install booted HelloWorld.app/
However, with this setup, iOS owns the main thread and loop. What if I want Nim to own the loop? For instance, if I want to run an asynchronous TCP echo server (and so must call runForever
)?
One option is to use threads:
import net
import os
import asyncnet
import asyncdispatch
import strformat
import strutils
import darwin/objc/runtime
proc processClient(client: AsyncSocket) {.async.} =
while true:
let line = await client.recvLine()
if line.len == 0: break
await client.send(line & "\L")
client.close()
proc echoServer() {.async.} =
var server = newAsyncSocket()
server.setSockOpt(OptReuseAddr, true)
server.bindAddr(Port(12345), "127.0.0.1")
server.listen()
{.emit: """NSLog(@"NIM: echoServer listening");""" .}
while true:
let client = await server.accept()
{.emit: """NSLog(@"NIM: got client");""" .}
asyncCheck processClient(client)
proc nimThreadMain() {.exportc.} =
asyncCheck echoServer()
runForever()
{.passL: "-framework Foundation".}
{.passL: "-framework UIKit" .}
{.passL: "-framework WebKit" .}
{.emit: """
#include <UIKit/UIKit.h>
#include <WebKit/WebKit.h>
#include <CoreFoundation/CoreFoundation.h>
""".}
{.emit: """
@interface NimController : UIViewController <WKNavigationDelegate, WKScriptMessageHandler>
@end
@interface NimDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@end
@implementation NimDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.window.rootViewController = [[NimController alloc] init];
self.window.backgroundColor = [UIColor blueColor];
[self.window makeKeyAndVisible];
return YES;
}
@end
@implementation NimController
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
}
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
^ {
nimThreadMain();
});
}
@end
N_CDECL(void, NimMain)(void);
int main(int argc, char * argv[]) {
@autoreleasepool {
NimMain();
return UIApplicationMain(argc, argv, nil, NSStringFromClass([NimDelegate class]));
}
}
""" .}
Compilation requires these additional flags --threads:on --tlsEmulation:off --gc:regions
:
$ nim objc --os:macosx --cpu:amd64 --out:HelloWorld.app/helloworld --noMain --passL:-mios-simulator-version-min=13.2 --passL:-isysroot --passL:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.2.sdk --passC:-mios-simulator-version-min=13.2 --passC:-isysroot --passC:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.2.sdk --threads:on --tlsEmulation:off --gc:regions hello4.nim
$ xcrun simctl terminate booted com.example.helloworld
$ xcrun simctl install booted HelloWorld.app/
$ xcrun simctl launch booted com.example.helloworld
$ echo hello | nc 127.0.0.1 12345
hello
But this is flakey in some ways I don't understand and some ways I do—for instance using --gc:regions
without configuring memory regions within the code is the same as having no GC.
It would be nice to ditch threads and piggy-back off the iOS loop. I've considered:
- Attaching a
CFRunLoopSourceRef
to the iOS main thread RunLoop. See this - Turning off the main
NSRunLoop
and running it myself (see the example near the bottom of this page)
After trying and trying to work with the NSRunLoop
, instead I've just made better use of threads and the garbage collector is still on.
This app changes colors every 2 seconds and when you click it. It also opens a TCP server on port 12345 that will repeat back whatever line it receives.
Also, I split the Objective-C into its own file (named hello5.m
):
# hello5.nim
import net
import asyncnet
import asyncdispatch
import strutils
{.passL: "-framework Foundation".}
{.passL: "-framework UIKit" .}
{.passL: "-framework WebKit" .}
{.compile: "hello5.m".}
#-----------------------------------------
# Asynchronous echo TCP server
#-----------------------------------------
proc processClient(client: AsyncSocket) {.async.} =
while true:
let line = await client.recvLine()
if line.len == 0: break
await client.send(line & "\L")
client.close()
proc echoServer() {.async.} =
var server = newAsyncSocket()
server.setSockOpt(OptReuseAddr, true)
server.bindAddr(Port(12345), "127.0.0.1")
server.listen()
while true:
let client = await server.accept()
asyncCheck processClient(client)
#-----------------------------------------
# Nim thread where runForever runs
#-----------------------------------------
proc nimLoop() {.exportc.} =
setupForeignThreadGc()
asyncCheck echoServer()
runForever()
proc startNimLoop():Thread[void] =
setupForeignThreadGc()
createThread(result, nimLoop)
#-----------------------------------------
# All the Objective C code
#-----------------------------------------
proc startIOSLoop():cint {.importc.}
#-----------------------------------------
# Nim starts the app rather than the app
#-----------------------------------------
if isMainModule:
discard startNimLoop()
discard startIOSLoop()
// hello5.m
#include <UIKit/UIKit.h>
#include <WebKit/WebKit.h>
#include <CoreFoundation/CoreFoundation.h>
@interface NimController : UIViewController <WKNavigationDelegate, WKScriptMessageHandler>
@end
@interface NimDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@end
@implementation NimDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.window.rootViewController = [[NimController alloc] init];
self.window.backgroundColor = [UIColor blueColor];
[self.window makeKeyAndVisible];
double interval = 2.0f;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
if (timer)
{
dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, interval * NSEC_PER_SEC), interval * NSEC_PER_SEC, (1ull * NSEC_PER_SEC) / 10);
dispatch_source_set_event_handler(timer, ^{
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.window.backgroundColor isEqual:[UIColor greenColor]]) {
self.window.backgroundColor = [UIColor blueColor];
} else {
self.window.backgroundColor = [UIColor greenColor];
}
});
});
dispatch_resume(timer);
}
return YES;
}
- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
[super touchesBegan:touches withEvent:event];
self.window.backgroundColor = [UIColor blackColor];
}
@end
@implementation NimController
@end
int startIOSLoop() {
@autoreleasepool {
return UIApplicationMain(0, nil, nil, NSStringFromClass([NimDelegate class]));
}
}
$ xcrun simctl terminate booted com.example.helloworld
$ nim objc --os:macosx --cpu:amd64 --out:HelloWorld.app/helloworld --passL:-mios-simulator-version-min=13.2 --passL:-isysroot --passL:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.2.sdk --passC:-mios-simulator-version-min=13.2 --passC:-isysroot --passC:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.2.sdk --threads:on --tlsEmulation:off hello5.nim
$ xcrun simctl install booted HelloWorld.app/
$ xcrun simctl launch booted com.example.helloworld
$ echo hello | nc 127.0.0.1 12345
hello
As you can see, we have supercomputers in our pockets, and they're kind of a pain to program (when compared to our desktop computers). That's why I'm working to make it less hard with Wiish.