HyperTalk and the Naming of Things: A Parable

In the days when every story about Apple still contained the word “beleaguered“, I learned this lesson vicariously through my coworker, Dave.

We worked for a small company that did contract QA. Companies would hire us to test their hardware or software and report all the bugs to them. This was before overseas outsourcing basically destroyed that niche in the US. Apple was actually one of our biggest customers at the time. Do you remember the Performa 637CD? I bet you don’t. I do.

Anyway, how it worked was, we had a large FileMaker database that contained all the bugs found in all the products we were testing. Then, we had a HyperCard stack which had a UI for entering bugs. Each tester had their own stack on a floppy disk. They’d use this to enter bugs, and at the end of the day, they’d hand their floppy to the Test Lead, who would export all the data from the stacks and import it into FileMaker, where some report would be run to get the bug reports back to the customers.

Once I became Test Lead, I decided this process could use some improvement. The HyperCard stack was written, naturally, by the company CEO. Being an aspiring young developer, I thought I could do better. I started making changes to improve it just for myself. But since it was used by all the testers, I had to get them on board using it also. Rather than simply mandating they use it, I started adding features to make it easier for them to use, and showed them what I’d done. They loved it.

This was all pretty good. But Dave, one of the testers, also fancied himself an aspiring developer. He made his own copy of the stack and also starting adding features to it. Soon we had HyperCard stack fragmentation, with half of the testers using my stack and the other half using Dave’s. But Dave and I had different philosophies. I was focused on making things simpler and easier, and Dave wanted to add as many features as possible. Dave would add a button that would automate some process. I’d make it so that process was unnecessary. At some point, I stopped messing with my stack, because it did everything I wanted and nothing I didn’t. But Dave didn’t stop.

With all his new features, Dave’s codebase soon grew rather large. In the bad old days before Mac OS X, 32K was an all-too-common limit on things. In this case, it was the limit on code size in HyperCard. HyperTalk was an interpreted language, so that was the size of the source code itself – the text.

Once he hit the 32K limit, Dave decided to reclaim space by deleting all his comments. Once that wasn’t enough, he started renaming functions and variables. “bugIndex” became “bi”. “GetNextID” might become “GNI”. You probably see where this is going. Within a couple weeks, Dave’s code became an unmaintainable mess. Not only could he not add more features, but he couldn’t even fix bugs anymore. Testers became frustrated at his lack of progress, and everybody moved over to my stack.

A lesson was learned. By me, anyway. I don’t think Dave learned anything. But now I want to thank Dave for teaching me, very early in my development career, the critical importance of naming things.

Posted in Uncategorized

An NSWindowController with a Mojave Dark Mode-Savvy WebView

A WebView is a super handy way to put arbitrarily styled content into a window, for example, in an about box.

VideoBuffet About box - Light

VideoBuffet About box – Light

You can't just throw a WebView in a window and call it a day, though. For one thing, if you include any links to websites, they'll load right there in your about box. That's probably not what you want. It's sure not what I want.

VideoBuffet About box – Bad

Right, we can fix that. All we need to do is catch navigation actions to the WebView, ignore them, and instead load them in the user's default browser (which is super easy via NSWorkspace). We just need to set our WebView's policyDelegate to our class, and override webView(_:decidePolicyForNaviationAction:request:frame:decisionListener:) (whew).

Let's say your class is an NSWindowController class (because, well, mine is).

class WebViewWindowController: NSWindowController {
    @IBOutlet weak var webView: WebView!

    override func windowDidLoad() {
        super.windowDidLoad()

        // Set delegate for WebView (so we can catch links and do stuff).
        webView.policyDelegate = self
    }
}

Implement webView(_:decidePolicyForNaviationAction:request:frame:decisionListener:) to ignore the request, and open it yourself via NSWorkspace's open method. It's really up to you how to determine which links to do this with. I've chosen to only do this for links that aren't file URLs, so that way you could link to other html files in your own bundle if you wanted. Like this:

extension WebViewWindowController: WebPolicyDelegate {

    public func webView(_ webView: WebView!,
                        decidePolicyForNavigationAction actionInformation: [AnyHashable : Any]!,
                        request: URLRequest!,
                        frame: WebFrame!,
                        decisionListener listener: WebPolicyDecisionListener!)
    {
        if let fileURL = request.url, !fileURL.isFileURL {
            // If it's not a file URL, let an external app handle it.
            NSWorkspace.shared.open(fileURL)
            listener.ignore()
            return
        }

        // Allow it.
        listener.use()
    }
}

That takes care of the link clicking. We still have an issue, though. In Mojave, this is going to look terrible in Dark Mode.

VideoBuffet About box – Dark – Bad

Ick.

There will certainly be some CSS way to make html content adapt to Dark Mode (see, for example, Paul Miller's Using dark mode in CSS with MacOS Mojave). But so far that's not in a shipping version of Safari (and even if it was, it wouldn't really help you for a WebView in an app deployed to an OS which didn't have that version of Safari).

There are a couple things to do here:
1. Detect whether the user is in Dark Mode. That's super easy to via NSAppearance.
2. Modify the CSS of the loaded page to look better in Dark Mode.
3. Watch for appearance changes, so the UI can change. Also easy.

Again assuming an NSWindowController subclass, we'll have something like this:

class WebViewWindowController: NSWindowController {

    @IBOutlet weak var webView: WebView!
    @IBInspectable var htmlFile: String!    
    private var appearanceObserver: NSKeyValueObservation? = nil

    override func windowDidLoad() {
        super.windowDidLoad()

        // Watch for appearance changes.
        if #available(OSX 10.14, *) {
            appearanceObserver = window!.observe(\.effectiveAppearance) {
                [weak self] (window, change) in
                self?.updateAppearance()
            }
        }

        // Set delegate for WebView (so we can catch loading to modify appearance).
        webView.frameLoadDelegate = self

        // Load our HTML page.
        loadHtml()
    }

    func modify(forAppearance appearance: NSAppearance) {
        // Only do anything for dark theme.
        var isDarkTheme = false
        if #available(OSX 10.14, *) {
            isDarkTheme = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
        }
        guard isDarkTheme else { return }

        // Modify CSS for dark mode.
        let darkModeCSS = [
            "body { color : #fff }",
            "a { color: #419CFF }"
        ]

        for aCSS in darkModeCSS {
            let js = "var style = document.createElement('style'); style.innerHTML = '\(aCSS)'; document.head.appendChild(style);"
            _ = webView.stringByEvaluatingJavaScript(from: js)
        }
    }

    private func loadHtml() {
        let fileURL = NSURL(fileURLWithPath: htmlFile)
        let fileName = fileURL.deletingPathExtension?.lastPathComponent
        let fileExtension = fileURL.pathExtension

        if let htmlPath = Bundle.main.path(forResource: fileName, ofType: fileExtension),
            let html = try? String(contentsOfFile: htmlPath, encoding: String.Encoding.utf8)
        {
            webView.mainFrame.loadHTMLString(html, baseURL: Bundle.main.bundleURL)
        }
    }

    @available(OSX 10.14, *)
    private func updateAppearance() {
        // Reload HTML.
        loadHtml()
    }
}

extension WebViewWindowController: WebFrameLoadDelegate {
    open func webView(_ sender: WebView!, didFinishLoadFor frame: WebFrame!) {
        guard window != nil else { return }
        modify(forAppearance: window!.effectiveAppearance)
    }
}

That's its. And it looks like:

VideoBuffet About box – Dark

I've posted this code, along with a sample project on GitHub. Check it out here: WebViewWindowController.

Programmatically count App Store ratings

Overcast, one of my favorite iOS apps, has a thing where it shows how many App Store ratings it has. I don’t know if it was the first app to do this, but it was the first I’d seen. Anyway, I wanted that functionality in one of my apps.

As it turns out, the App Store has an API for getting this information. All you need to do is download from https://itunes.apple.com/lookup?id=APP_ID (where APP_ID is the ID of the app you’re interested in). This gives you a pile of JSON which contains, among many other things, the count of ratings for the app.

So, download some JSON over https, and parse it. Simple. I wrote a little class to do this. There’s one function, which looks something like this:

func fetchNumberOfRatings(appID: String, completion: @escaping (Bool, Int) -> ()) {
	
    let appStoreURL = URL(string: "https://itunes.apple.com/lookup?id=\(appID)")
    let task = URLSession.shared.dataTask(with: appStoreURL!, completionHandler: { (data, response, error) in
		
        var gotResult = false
        var ratingsCount = 0
		
        if error == nil && data != nil,
            let jsonResult = (try? JSONSerialization.jsonObject(with: data!, options:[])) as? NSDictionary,
            let results = jsonResult["results"] as? NSArray,
            results.count > 0,
            let result = results[0] as? NSDictionary,
            let numberOfTimes = result["userRatingCountForCurrentVersion"] as? Int
        {
            // Got it!
            gotResult = true
            ratingsCount = numberOfTimes
        }
		
        DispatchQueue.main.async {
            completion(gotResult, ratingsCount)
        }
    })
	
    task.resume()
}

It’s pretty self-explanatory. It uses NSURLSession to download the JSON, parses it with NSJSONSerialization, and calls a completion proc you provide. You’d call it like:


FARatingCounter.defaultCounter.fetchNumberOfRatings(appID: appInfo.ID) {
    success, number in
    // Do something with number
}

I just posted this to GitHub, along with a little sample app showing how to use it. Check it out here.

TaskLog 1.2 app update metrics

Another app update, another metrics post.

I just shipped TaskLog 1.2. This was a pretty major feature update – the largest in TaskLog’s history. As with the recent PuzzleTiles update, I thought I’d share some metrics about the app, and the update.

The previous shipping version of TaskLog comprised 3,388 lines of Objective-C code in 28 files (121 lines per file), plus 226 lines in C header files (8 lines per file). This is physical lines of code (SLOC), as counted by cloc.

The new updated version comprises 4,649 lines of Objective-C in 37 files (125 lines per file), plus 226 lines in 36 header files (8 lines per file).

Objective-C C Headers Total
TaskLog 1.1.2 28 files, 3,388 lines 26 files, 226 lines 54 files, 3,614 lines
TaskLog 2.0 37 files, 4,649 lines 36 files, 290 lines 73 files, 4,939 lines

The largest file was (and still is) the main view controller, TTMainViewController.m, which was previously 889 lines of code, and in the latest update is 1,085 lines of code.

In total, I spent 112.5 hours developing this update. Had I been working on it full time, I could have finished it in 3 weeks. Doing it part time, it ended up more like 6 weeks.

Notably (unlike the PuzzleTiles update), I added no Swift to TaskLog. It wasn’t really a conscious decision. I just did my thing, and when it was done, well, there was no Swift in there. I guess it didn’t really occur to me. TaskLog is several generations of code newer than PuzzleTiles, so it needed much less of an overhaul; maybe that was it.

On a semi-related note, I recently converted PuzzleTiles to Swift 2.0, and it took me two hours to straighten out all the code and make everything build and run properly once the Swift converter was done. Considering it has less than 600 lines of Swift code, that seems like a whole lot of effort. I’ve been working on an (as yet non-shipping) app for quite some time which is currently over 20,000 lines of Objective-C. It occurs to me that if it were written in Swift, I’d be in a world of hurt right now. I wonder if Apple isn’t updating Swift too, um, swiftly.

Random metrics for an app update

I just finished up a very large update to PuzzleTiles, and wanted to share a few development metrics.

The previous shipping version comprised 6,181 lines of Objective-C code in 27 files (228 lines per file), plus 559 lines in 26 header files (21 lines per file). This is actual LOC (as measured by cloc), not including comments or blank lines.

The new update comprises 7,394 lines of code: 6,417 lines of Objective-C code in 24 files (267 lines per file), plus 388 lines in 22 header files (17 lines per file), and finally, 589 lines of Swift code in 15 files (39 lines per file)

Objective-C C Headers Swift Total
PuzzleTiles 1.2.4 27 files, 6,181 lines 26 files, 559 lines N/A 53 files, 6,740 lines
PuzzleTiles 2.0 24 files, 6,417 lines 22 files, 388 lines 15 files, 589 lines 61 files, 7,394 lines

The largest file was (and is) PuzzleViewController.m, the file that holds the main view controller in the app, at 962 lines of code. Not too bad, I think.

From start to finish, the update took me around 258 hours. This includes everything related to the app: research, design, development, testing, artwork, screenshots and videos for iTunes Connect, etc.. Had I done this full time (which I did not), it would have taken just over 6 weeks. That's actually kind of a long time; more time than I usually spend on an app update. To be fair, though, this was a huge update.

When I took my car to the dealer for its 100,000 mile service, the dealer told me it would cost nearly $1,000. I asked what they could possibly be doing to make it cost that much. He said they intended to basically lift up the radiator cap, and pull a new car underneath it. So it was with this app update.

I overhauled the entire UI – previously it was still iOS6-vintage. I converted everything to use Storyboards and Autolayout, adding support for all the bigger phones. I added 3x graphics for iPhone 6 Plus. I added a new tile set, and updated all the others to look better (and have 3x). I added in-app purchase, and iCloud syncing (both things with which I previously had no experience). I removed every deprecated call, and added some new APIs introduced in iOS7 and iOS8 where it made sense to do so. I rewrote a few problem areas entirely (such as my GameKit code), and made some nice enhancements to other areas (the control scheme, and sound playing, among others).

PuzzleTiles was my first iOS app, originally released in 2010, and last updated in 2012. The guy that wrote it had much less expertise than I currently possess. It was very fun to get in there and redo things with the benefit of the knowledge I've gained in the years since.

I'm reasonably sure this level of effort won't turn out to have made sense, financially. In that regard, I'd likely have done better to spend that time on other apps that actually do make money. But money wasn't the point here. I wanted PuzzleTiles to once again be an app I could be proud of. In that regard, it's already a success.

Of course, it won't hurt if you download it, and buy the in-app-purchase. :)

Regarding Swift

I haven't yet written anything here about Swift. It's not because I haven't played with it (I have), and it's not because I don't have thoughts on it (I do). I just feel like, until I've actually shipped an app using it, I'm not qualified to write about it with any level of authority. And at the moment, I've not shipped an app using Swift.

That's about to change. I'm working on a long-overdue update to PuzzleTiles. I decided from the start not to rewrite any existing code in Swift. What I have been doing, however, is writing all new code in Swift.

This has taught me a lot about Swift; especially, how Swift and Objective-C interoperate. I'll be posting more about this later. And, from this point forward, any code in my posts will be almost certainly be Swift, not Objective-C.

High resolution timing in Cocoa, revisited

A while back I wrote about high resolution timing in Cocoa. It's been a while, and I've updated it a bit – adding ARC support, adding a call to time a block, and removing some stuff that wasn't needed. Hopefullly you find it useful.

// MachTimer.h
#include <mach/mach_time.h>

@interface MachTimer : NSObject

+ (instancetype) timer;
+ (NSTimeInterval) timeForBlock:(void (^)(void))block;

- (void) start;
- (NSTimeInterval) elapsedSeconds;

@end


// MachTimer.m
#import "MachTimer.h"

static mach_timebase_info_data_t timeBase;

@implementation MachTimer
{
    uint64_t timeZero;
}   

+ (void) initialize
{
    (void) mach_timebase_info( &timeBase );
}

- (instancetype) init
{
    self = [super init];
    if( self ) {
        [self start];
    }
    return self;
}

+ (instancetype) timer
{
#if( __has_feature( objc_arc ) )
    return [[[self class] alloc] init];
#else
    return [[[[self class] alloc] init] autorelease];
#endif
}

+ (NSTimeInterval) timeForBlock:(void (^)(void))block
{
    MachTimer* aTimer = [self timer];

    [aTimer start];
    block();
    return [aTimer elapsedSeconds];
}

- (void) start
{
    timeZero = mach_absolute_time();
}

- (NSTimeInterval) elapsedSeconds
{
    return ((NSTimeInterval)(mach_absolute_time() - timeZero)) * ((NSTimeInterval)timeBase.numer) / ((NSTimeInterval)timeBase.denom) / 1000000000.0f;
}

@end

You might use it like this:

MachTimer* aTimer = [MachTimer timer];
[self doSomeLengthyOperation];
NSLog( @"Lengthy operation took %f seconds", [aTimer elapsedSeconds] );

Or, with the new block call:

NSTimeInterval theTime = [MachTimer timeForBlock:^{
    // Do some lengthy operation.
}];
NSLog( @"Lengthy operation took %f seconds", theTime );

Credit where credit is due

I originally found the basis for this code on the Apple message boards, here. I dunno who wrote that, but thanks, dude. If you're that guy, let me know, and I'll be happy to give you attribution. Since writing this, I've discovered Waffle Software has a similar thing, which has some additional functionality; check that out if you're interested.

If you want to follow me, I'm @zpasternack on Twitter and on app.net.

Relative date formatting like Mail.app

Mail.app on Mac OS has a cool way of showing you relative dates of messages. Messages received today show only the time (e.g., "12:55 PM"), whereas messages received prior to today show only the date (e.g., 3/13/14), and if the date was Yesterday, it shows that, rather than the date. I wanted this same functionality in one of my apps.

Googling around for a bit turned up this StackOverflow question, with which I was not happy. So I did it myself (and, of course, added my own answer to that question). Here's what I did.

NSDateFormatter (as of Mac OS 10.6 and iOS4) does relative date formatting (e.g., "Today", "Yesterday" for you, like so:

NSDateFormatter* formatter = [[NSDateFormatter alloc] init];
[formatter setTimeStyle:NSDateFormatterNoStyle];
[formatter setDateStyle:NSDateFormatterShortStyle];
[formatter setDoesRelativeDateFormatting:YES];

NSString* dateString = [formatter stringFromDate:[NSDate date]];    
NSLog( @"date = %@", dateString );

Which outputs:

2014-03-15 15:26:37.683 TestApp[1293:303] date = Today

But that still leaves the issue of instead using the time if the date is today. First, we need to be able to tell if a given date is today. For this I implemented a method isToday, as a category on NSDate. (Side note: I freaking love categories!)

@implementation NSDate (IsToday)

- (BOOL) isToday
{
    NSCalendar* calendar = [NSCalendar currentCalendar];

    // Components representing the day of our date.
    NSDateComponents* dateComp = [calendar components:NSYearCalendarUnit | NSMonthCalendarUnit | NSDayCalendarUnit
                                             fromDate:self];
    NSDate* date = [calendar dateFromComponents:dateComp];

    // Components representing today.
    NSDateComponents* todayComp = [calendar components:NSYearCalendarUnit | NSMonthCalendarUnit | NSDayCalendarUnit
                                              fromDate:[NSDate date]];
    NSDate* todayDate = [calendar dateFromComponents:todayComp];

    // If the dates are equal, then our date is today.
    return [date isEqualToDate:todayDate];
}

@end

Then, I made two date formatters, one for the day, and one for the time, and decided which to use based on whether the date is today or not. Like so:

- (NSString*) postDateToString:(NSDate*)aDate
{
    static NSDateFormatter* todayFormatter = nil;
    if( todayFormatter == nil ) {
        todayFormatter = [[NSDateFormatter alloc] init];
        [todayFormatter setTimeStyle:NSDateFormatterShortStyle];
        [todayFormatter setDateStyle:NSDateFormatterNoStyle];
    }

    static NSDateFormatter* notTodayFormatter = nil;
    if( notTodayFormatter == nil ) {
        notTodayFormatter = [[NSDateFormatter alloc] init];
        [notTodayFormatter setTimeStyle:NSDateFormatterNoStyle];
        [notTodayFormatter setDateStyle:NSDateFormatterShortStyle];
        [notTodayFormatter setDoesRelativeDateFormatting:YES];
    }

    NSDateFormatter* formatter = notTodayFormatter;

    if( [aDate isToday] ) {
        formatter = todayFormatter;
    }

    return [formatter stringFromDate:aDate];
}

And there you have it.

If you want to follow me, I'm @zpasternack on Twitter and on app.net.

Accessing the *real* home folder from a sandboxed app

The preferred way to find a path for a directory within the user’s folder is to use NSSearchPathForDirectoriesInDomains (or an NSFileManager equivalent, such as URLsForDirectory:inDomains:). Problem with that is, if your app is sandboxed, these fuctions won’t give you paths the actual directories you’ve asked for, but rather the equivalent paths within your app’s container, even if you’re using entitlements which allow access to those paths.

So, let’s say you want to get the path for the user’s Documents directory. You’d end up with something like this:

- (NSURL*) getDocumentsDirectoryURL
{
    NSArray* paths = NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, YES );
    NSString* documentsPath = paths[0];
    return [NSURL fileURLWithPath:documentsPath];
}

You might expect to get back something like file://localhost/Volumes/SSD/Users/zach/Documents/ (at least, you’d expect that if your name was Zach). But you might be surprised when you actually get something more like file://localhost/Volumes/SSD/Users/zach/Library/Containers/com.mycompany.myapp/Data/Documents/.

If you really want the path to the real Documents directory (in ~/Users/zach/Documents), you need to do something like this:

- (NSURL*) getDocumentsDirectoryURL
{
    struct passwd *pw = getpwuid(getuid());
    NSString* realHomeDir = [NSString stringWithUTF8String:pw->pw_dir];
    NSString* documentsPath = [realHomeDir stringByAppendingPathComponent:@"Documents"];
    return [NSURL fileURLWithPath:documentsPath];
}

Admittedly, there aren’t many cases where you would want to do this. One reason you’d do this is to set the default location for an open/save file dialog – navigating the user to the Documents directory in your app container would be quite confusing. In my case, VideoBuffet really wants to find all the movies in your Movies folder, and there of course aren’t any in the Movies folder equivalent of the app container.

Notably, if you’re doing something like getting the path to Caches or Library (say, to save some settings into a .plist), you definitely want to use the normal NSSearchPathForDirectoriesInDomains method, because those should be saved into the app conainer.

Update 05/11/17:This is still a valid thing to do today, and here’s how you’d do it in Swift 3:


func getDocumentsDirectoryURL() -> URL {
	let pw = getpwuid(getuid());
	let home = pw?.pointee.pw_dir
	let homePath = FileManager.default.string(withFileSystemRepresentation: home!, length: Int(strlen(home)))
	let documentsPath = (homePath as NSString).appendingPathComponent("Documents")
	return URL(fileURLWithPath: documentsPath)
}