Skip to content

Making an iOS app from scratch

Matt Haggard edited this page Feb 28, 2020 · 3 revisions

We carry supercomputers around in our pockets. But can we make them run the programs we want?

Level 1

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?

Level 2

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™.

Level 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!

Level Logs

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.

Level Nim

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.

Level these aren't the droids you're looking for

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!
...

Level I don't want to write Objective-C

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/

Level Loop Fight

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.

Level Loop if you can't beat 'em

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

Conclusion

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.