8 years of Apple text editor solo dev

👨🏻‍💻  Thoughts on building Paper as a solo maker.
  19 min read

In 2015 I was just a regular full-stack web dev (and still am to this day). I’ve owned a Mac because I thought it looked nice. I’ve built a tiny iOS app once. That was about the extent of my proximity to the world of Apple dev at the time.

Having spent some time behind a Mac I’ve grown fond of its quirky and vibrant ecosystem of indie apps. One day, upon stumbling on a very simple and elegant app called iA Writer, for one or the other reason, I decided to make something similar. I cannot recall exactly why, but I guess it was a mixture of:

Armed with enthusiasm I started learning how to make a native text editor for the Mac. Xcode, AppKit, Objective-C — all of that I had to learn from scratch. Furthermore, not a single piece of this knowledge was reusable at my day job. I had to learn a completely different tech stack that had to live inside my head in parallel to all the web experience I was using (and still am using) at work.

At some point, I started calling the app Paper, because in pursuit of ultimate minimalism (remember, I was trying to out-engineer iA Writer) I’ve made the editor nothing more than a blank sheet of white paper. To top it off I’ve made the corners 90° instead of the typical rounded ones. Silly? Maybe… But it was my app, so I could do whatever I wanted. 😜

In January 2017, 2 years after I started from ground zero, the Mac app was launched on the Mac App Store. Fast forward to today the Mac app is accompanied by an iOS app, both having sizable amounts of users.

Now it’s not every day that a random, unknown web dev decides to build an app in a ridiculously crowded category, in a tech stack that they have no experience with, then actually does it, carrying on for the next 6 years. There gotta be some good material here — I thought to myself.

And so here is a brain dump of all the weird, bizarre, and occasionally smart ways that I’ve organized my dev process, app architecture, and product philosophy, coming from a web dev, who has not earned a cent working at a job as a Mac or an iOS developer, but have earned quite a few of them selling a native text editor to the users of Apple devices.

Why native?

You could make the argument that an Electron app would work as well. Why go through the hassle of learning a brand new tech stack, especially when my main job is web-related? I could have reused the skills, saved time, and supported more platforms all the same time.

My goal was to deliver the best experience possible. I was trying to compete with highly polished apps like iA Writer, thus I needed the app to be light and fast. In addition to that, there are just more ways to mess with the app on the native level, to make it unique (especially when it comes to text). I was not trying to reach the maximum number of users nor to cut down the development time. I had all the time in the world. I was trying to craft an experience, which starts with a lightning-fast download time and carries on into a native-feeling UI and UX.

I wanted the best, and I was willing to pay the penalty.

Why only Apple?

Okay, I did not want to go with Electron, but still — I could have built more native versions for more platforms with a shared C++ core, right?

Well, the original plan was to stop at the Mac app. I never liked apps on mobile devices and never liked mobile devices themselves. They felt too limiting, too boring. On the Mac, there is more freedom and more ways to make your experience unique, and that’s what attracted me.

Unsurprisingly, the iOS app came to be because… people asked for it. In the end, I am glad how it turned out. I feared I would never be able to replicate the minimalist magic of the Mac app, but after a few years of polishing and a few just-in-time iOS feature additions, the mobile app has matured to a point of aesthetic parity with the Mac app.

As for other platforms — I just don’t care. I like Apple’s walled garden, and users like it as well. There are enough people who are happy with an Apple-exclusive app and happy to pay for it as well. Enough for me at least.

From a purely technical point of view, certain Mac APIs make it easier to build simple text editors on the Mac and iOS. Going somewhere else would mean losing that and a lot of other synergistic benefits that exist between Apple platforms.

Why Objective-C?

In 2015 Swift was just getting started. I decided to make a test. I’ve compiled an empty Xcode project with Objective-C and another one with Swift, then examined the respective .app packages. To my surprise, the Swift one had the full Swift runtime embedded into it — about 5MB, while the Objective-C one was super light — tens or maybe a 100KB in total. That was enough to convince me to go with Objective-C.

Again — I wanted the best and I was willing to pay the penalty of a harder-to-learn, soon-to-be outdated language to get a smaller distributable.

To be fair, if you run this experiment today the difference will not be that dramatic. Swift has come a long way, and Apple has either embedded it into their platforms or maybe added some fancy tree shaking for the binary (I am too lazy to verify). Today I might have picked Swift… or maybe not. Some APIs are straight-up not available in Swift, and Objective-C is just more hackable and low-level, which gives you more power to work around the limitations of AppKit and UIKit.

Third-party dependencies

Paper does not have third-party dependencies.

Partially this decision was driven by my unfamiliarity with the ecosystem. Having no dependencies means my build process is simpler, and I don’t have to keep pace with updating them. There was also a real possibility that I would end up stuck with some unmaintained package since the world of Objective-C is way smaller and more closed than your typical open-source web. Above all else, I wanted a predictable dev process without surprises that I could follow for years.

Another consideration is that it allows me to have full control over the experience. I can have an advantage over competitors that very often rely on external dependencies even for the core parts of their apps.

For example, the Markdown parsing engine in Paper is bespoke. Why is that a good thing? Because Paper supports less Markdown syntax than the traditional, fully-fledged Markdown editor. I can code in just the right amount of parsing logic and nothing more. In addition to that, I can parse it with the right level of metadata granularity, which makes implementing features like highlighting and text transformations simpler and more efficient.

Vision

In the beginning, the vision for Paper was simple — build something that has the core tricks of iA Writer, but in a package that feels even more elegant and minimal. To achieve the desired effect I went all-in on cutting down distractions.

And this only scratches the surface.

Have people noticed the effort? Most probably not… but some have. So much so that at one point Paper received a perfectly succinct review that I use as a tagline to this day.

This is a super-clean writing space with a lot of configurability that stays out of sight when you don’t need it.

As time went on I started developing a feeling for how the market of Apple minimal Markdown editors looks like, and what could be Paper’s place in it.

To my observations, minimalist writing apps in the App Store usually follow 2 paths.

  1. Become popular and start slowly drifting away from their minimalist roots to satisfy the ever-growing demands of mainstream users.
  2. Remain too simple and niche to eventually be abandoned by their creators.

Paper is definitely not [1.], but it could be on the path to [2.].

My plan is to keep Paper as minimal as it has been when it launched — to resist adding any visual clutter. At the same time by having a slow and predictable cadence of small updates (more on that in the last chapter) I can slowly add features to the fringes of the app while keeping the default path super clean.

Something will be here soon…

The slow pace and the overall limited number of features compared to [1.] allow me to focus on building a better foundation, to better understand how things work together, and to avoid adding features that bring instability and high maintenance burdens in the future.

Closed-sourced native UI is a fragile place compared to the predictable JavaScript runtime of the browser. If you don’t invest substantial resources into refactoring your app and eliminating crashes — it’s death by a thousand crashes. And this is what I am banking on. The bloat, complexity, and bugs that [1.] accumulate from their decision to go mainstream present good opportunities to capture some of the disappointed users that eventually leave them.

This, however, may still not be enough to make Paper into a viable product. There are just not enough (reachable) people who need these kinds of ultra-simple writing apps (let alone pay for one). Power users are the ones who pay the bills because they need power tools to earn money that they can then justify spending on those tools.

Architecture

I find it convenient to think of Paper’s code as consisting of two scopes.

The reality is a bit more nuanced as there are also scenes on iOS that subdivide the global scope, but the mental model holds true more or less.

For every scope I define a storyboard that has 2 functions.

Modules in Paper are just plain Objective-C classes that take responsibility for a piece of functionality within the app. It’s a way to group functionality related to a particular feature instead of spreading it across multiple places.

Modules have a well-defined lifecycle.

  1. They are created by the storyboard when the main thing within the storyboard gets created.

    • Application scope

      • The main thing is the app itself, so all of the modules are created on startup.
    • Document scope

      • The main thing is the view controller that holds the editor.
  2. They have a setup method that gets called after the dependencies have been injected, but before the main thing becomes visible.

    • Application scope

      • Called on

        • ApplicationDidFinishLaunchingNotification
        • UISceneWillConnectNotification for scenes
    • Document scope

      • Called on didMoveToWindow of the main view.
  3. They have a tear-down method that gets called before the main thing is destroyed.

    • Application scope

      • No need. Modules die with the app.
    • Document scope

      • Called on willMoveToWindow of the main view when the newWindow argument is nil.

Modules declare their dependencies (views and other modules).

TvTextAttributeModule.m
@interface TvTextAttributeModule()

@property IBOutlet NSTextView *textView;

@property IBOutlet TvTextContentModule *tvTextContentModule;
@property IBOutlet TvLayoutAttributeModule *tvLayoutAttributeModule;
@property IBOutlet TvCaretModule *tvCaretModule;
@property IBOutlet TvTypewriterModeModule *tvTypewriterModeModule;

@end

I resolve them manually in Xcode.

Dependencies are then injected by the storyboard at runtime.

If a document scoped module needs something from the application scope, it can just access it from the global variable.

DocClosingModule.m
[self.viewController.presentingViewController dismiss_:^{
  [self.docNavigationBarModule documentViewControllerDidDismiss];
  [Application.get.viewController.storeReviewModule tryAsk];
}];

(or from the root of the view hierarchy for scenes)

DocNavigationBarModule.m
BOOL unread = self.rootViewController.supportChatModule.unread > 0;
UIImage *icon = [UIImage systemImageNamed:@"gear"];
UIImage *unreadIcon = [UIImage systemImageNamed:@"gear.badge"];
self.rightBarButtonItem.image = unread ? unreadIcon : icon;

Modules subscribe to notifications (pub-sub events between classes) during the setup phase of the lifecycle and unsubscribe during the tear-down phase.

TvCaretHintModule.m
- (void)addNotificationObservers {
  [self addObserver:@selector(windowDidResize)
                   :NSWindowDidResizeNotification
                   :self.window];
  [self addObserver:@selector(textViewDidChangeSelection)
                   :NSTextViewDidChangeSelectionNotification
                   :self.textView];
  [self addObserver:@selector(colorsDidChange)
                   :Colors.didChangeNotification];
}

Modules can let other modules know that something has happened. This is done through direct method calls rather than notifications to keep things simpler, IDE navigable, and faster.

TvInsetModule.m
UIEdgeInsets contentInset = self.calculateContentInset;
UIEdgeInsets indicatorInsets = self.calculateScrollIndicatorInsets;

self.textView.contentInset = contentInset;
self.textView.verticalScrollIndicatorInsets = indicatorInsets;

[self.docFrameModule textViewDidChangeInsets];
[self.tvTypewriterModeModule textViewDidChangeInsets];

Modules can ask other modules to do or to calculate something that those other modules are responsible for.

TvInsetModule.m
- (UIEdgeInsets)calculateContentInset {
  if (self.tvTypewriterModeModule.shouldApplyVerticalInset) {
    return Sum(
      self.normalInsets,
      self.tvTypewriterModeModule.verticalInsets
    );
  }

  return self.normalInsets;
}

This leaves the delegates, view controllers, and views as just proxies that notify modules about stuff that’s going on in the app and delegate everything to them.

MaTextView.m
- (void)insertText:(id)string replacementRange:(NSRange)range {
  [self.tvAutocompleteModule textViewWillInsertText:string];
  [self.tvTypewriterModeModule textViewWillInsertText:string];

  if ([self.tvTextAttributeModule textViewWillInsertText:string]) {
    [super insertText:string replacementRange:range];
  }

  [self.tvTextAttributeModule textViewDidInsertText];
  [self.tvTypewriterModeModule textViewDidInsertText];
}

All in all, the combination of modules and dependency injection through storyboards gives a nice mechanism to decouple and subdivide what would have been a giant document view controller. Applying the same approach to the application scope gives the whole app a uniform structure that is a joy to work with.

Cross-platform code

AppKit and UIKit are both quite similar and annoyingly different in many places.

I employ 2 Objective-C features to work around the differences.

For example, in many cases, the difference comes down to NS vs UI prefix, which I solve by having this macro:

SharedAdapter.h
#if TARGET_OS_OSX
#define KIT(symbol) NS##symbol
#else
#define KIT(symbol) UI##symbol
#endif

Now whenever I need to declare a view in the shared code I can just type KIT(View) instead of NSView or UIView.

TvCaretModule.m
- (KIT(View) *)cloneCaretView {
  KIT(View) *view = [self createCaretView:self.caretFrame];
  [view.layer setNeedsDisplay];
  return view;
}

Besides that, I can just use those #if statements to quickly add platform-specific code in shared classes. To make such code more readable Xcode even has a cool trick that deemphasizes the code that does not apply to the currently selected platform.

Categories in Objective-C are a way to add new methods (and even replace existing ones) to any class, including framework classes. So if a method in UITextField is called text and in NSTextField it is called stringValue I can add a stringValue method to UITextField that just calls text. Now I can always refer to the text method in my KIT(TextField) variable and it will compile on both platforms.

I gather all these little patches to an adapter header in the shared library.

SharedApplicationAdapter.h
@interface UITextField(SharedApplicationAdapter)

@property (nonatomic) NSString *stringValue;
@property (nonatomic) NSString *placeholderString;

@end

And then implement this header in respective app projects.

SharedApplicationAdapter.m
@implementation UITextField(SharedApplicationAdapter)

- (NSString *)stringValue {
  return self.text;
}

- (void)setStringValue:(NSString *)stringValue {
  self.text = stringValue;
}

- (NSString *)placeholderString {
  return self.placeholder;
}

- (void)setPlaceholderString:(NSString *)placeholderString {
  self.placeholder = placeholderString;
}

@end

By the way, I also use categories to shorten long AppKit and UIKit methods.

UIViewController+Mobile_.m
@implementation UIViewController(Mobile_)

- (void)dismiss_ {
  [self dismissViewControllerAnimated:YES completion:nil];
}

@end

Cross-platform logic

Using the tricks described above I can make shared modules that centralize the common logic between Mac and iOS apps.

In most cases, the shared logic works on the data layer of the app, so it’s free from the visual nuances of respective platforms. However, in some cases, you need to commonize gnarly stuff like the geometrical math behind the text editor rectangle. And things can get hairy.

For instance, here are all the considerations I have to keep in mind in order to extract the common part of the editor rectangle calculation.

Something will be here soon…

Text editors are hard

Text editors are just hard. Especially native ones. On the web, stuff is also messy, but the APIs are simpler and less powerful. You’re constrained by what you can do, plus the web's immense scale means that a lot of smart people have invested a lot of time into making amazing libraries that abstract away all the gnarly bits.

Text is one of those things that always gets things bolted onto it. Copy-paste, drag and drop, undo, caret interactions, right-to-left languages, non-alphabetic input languages, dictation, spoken text, scanned text, data format recognizers, link previews, autocomplete, autocorrection, AI suggestions — it never stops. You’re always at the mercy of the next OS update adding new ways to insert, update, and interact with editable text.

The text system itself is quite complicated. There are a lot of layers, which build on one another to turn a string of characters into formatted glyphs inside a scrollable rectangle on your screen. Apple gives you ways to configure and hook into this system, but there are a million ways to shoot yourself in the foot.

And then there’s the painfully fiddly logic of batched text updates. In Paper’s case, the pain is most felt when dealing with shortcuts that toggle Markdown formatting on a selected piece of text. Most Markdown editors that I’ve tested in the App Store cover a few simple cases and ignore the rest. The shortcut can break the Markdown if applied to the wrong place, but that’s kind of fine — users will learn the limitations over time and won’t do stupid stuff. However, for Paper I wanted to go the extra mile and make the shortcuts intelligent enough to produce a good outcome regardless of what the user selects.

For example, here is a breakdown of how the **bold** syntax is added or removed in various situations.

Something will be here soon…

Debugging

Throughout my career I’ve always learned the quickest and most about a particular third-party dependency by reading the code rather than going through the docs. Documentation is fine to get a general picture, but the details matter, especially when stuff does not work the way it should, either by (undocumented) design or due to mistakes of the authors.

Now in the case of Apple frameworks documentation is, sadly, the only readable option you have. However, that did not stop me from wanting to learn more. In fact, you can put a breakpoint in your code and examine the compiled stack trace of the framework.

A call stack in Xcode on the left. One frame is selected. The right pane shows the machine code of the selected frame.

It looks like gibberish, but the important thing is that the method names are visible. Analyzing the flow of method calls is enough to grasp what is going on. And if you need to inspect something that is outside the stack ending in your source code, you can always put a symbolic breakpoint to stop inside the framework.

For instance, below it stops at recognizedFlickDirection that is visible at the top of the compiled code above.

A call stack in Xcode on the left. One frame is selected. The right pane shows the machine code of the selected frame.

As a side note, going through a 30-year-old codebase you occasionally discover gems like if statements adjusting the logic of a framework depending on the app that runs the framework, or instances of creative naming. 🙃

A call stack in Xcode. The top frame is “reallySendEvent”. One below it is “sendEvent”.

Back in 2015–17, subscriptions were not a widespread thing yet in the App Store. Pay-to-download was (and is) common, but I did not think that someone would pay for an unknown app without trying it, so the one-time payment freemium seemed like the only option. At the same time, I did not want to just paywall the features, or to implement a custom time-based trial (only subscriptions have built-in trials in the App Store). I wanted something extremely user-friendly. Something that feels like you can push all the buttons, and adjust all the toggles of the paid offering without limits, and without needing to explicitly commit to a trial of arbitrary length.

My solution was to offer only cosmetic upgrades as part of the Pro offering — visual changes rather than functional features (e.g. file syncing or PDF exports).

I would then make them trialable for an unlimited amount of time. Users could test drive the features to see how they look and work, and then buy the Pro offering if they thought that the features were worth it.

Of course, there needed to be a measure that prevented people from using the features indefinitely without paying. At first, it was a 60-second timer that would nag them to buy if at least one Pro feature was active. This proved to be too annoying, so I tied the nagging to characters written instead. It made more sense — try everything for as much time as you want without distractions, but as soon as you start using Paper to write, every couple of hundred characters you will see a popup.

A macOS alert popup that says “Enjoying Pro Features? 😎 You are using some of the Pro Features from the View menu. 👀 Consider Subscribing if you think they are worth it. 💳 Press Reset to reset Pro Features to defaults and get rid of this popup. 👻”. Three buttons: Subscribe, Ask Again Later, Reset.

This system works because unlike functional features, whose value is tied to a specific action at the moment that it is used, cosmetic upgrades enhance your writing experience on a constant basis. You cannot put an action behind the same frictionless trial described above, since the value of the action is fully realized the moment the action is performed. On the other hand, letting the users apply cosmetic changes, accompanied by a nagging mechanism, gives them a taste of the enhanced writing experience, yet limits the prolonged value of this enhancement. Forcing the purchase, yet not limiting the ability to fully explore everything on their own terms before spending the money.

What do users think about it? Here is an excerpt from a recent review.

the fact that it lets you mess around with and test pro features instead of locking them behind a paywall (a reason I do not like a lot of apps. They don't let you try the features until you start a subscription or trial you may very well forget about, if it even gives you a trial) is extremely nice. […] You don't even have to necessarily trust me on it: jump in and try it yourself. It lets you test every single feature it has.

Pricing

Paper started out with two one-time payments, $5 each, for 2 sets of Pro Features. Now it is $10 per month or $100 lifetime for a single set. Massive inflation, right?

The lesson here is that you need to experiment with pricing. I did not know the value of Paper (nor anyone else for that matter), thus I had to test multiple price points to see what sticks.

First, before I plunged into the world of subscriptions, I experimented with one-time payments. I started gradually doubling the price and observing if the total amount of money earned would increase or decrease. My App Store traffic was pretty stable, so there were always new people coming through the door who would not know anything about previous prices. That’s when I first discovered that people were willing to pay up to $100 for an app from an unknown developer. I tried going up to $200. I think I’ve gotten like 1 or 2 sales and some amount of complaints over the period of several months, so I figured that $200 is probably the pain point, thus the market has decided that $100 is an adequate price for Paper. Since leaving it at $100 I’ve had no further complaints and regular sales every month.

Next, after a lot of hesitation, I decided to introduce subscriptions. No one would pay a subscription for such a simple app — I thought to myself, but I was wrong yet again. They worked, and today Paper has a healthy mix of monthly, yearly, and lifetime payments. I do however promote the subscriptions more in the app (since SaaS is all the rage, right?).

Even now, I still experiment with prices. Recently I raised the monthly price from $5 to $10 while keeping the annual price at $50. This is to incentivize the annual option and to reduce churn. So far the results are that people are still paying, though in smaller numbers. Still, this could be a positive change taking into account annual upgrades. We’ll have to wait and see…

What’s also great about the App Store is that you can easily test lowering prices for countries with lower incomes. Since Apple does not charge you the typical ¢50 per transaction (e.g. Paddle), you can go as low as you want without those fees eating into your margins. Currently, I am testing to see if lowering the price there has any effect, or if people in those countries just don’t pay for software (or text editors 🤷🏼) no matter what.

Feedback

Back in the day, there was an app lifecycle management platform called HockeyApp that Microsoft later bought and turned into AppCenter. The Mac version of HockeyApp came with a hosted support chat that you could easily launch from your app. The fact that it was built into the app meant that people gave useful feedback in vastly greater numbers compared to email.

When HockeyApp became AppCenter, this feature, sadly, was sunsetted.

After going back to the desert that is feedback via email, I thought to myself that I could build the support chat myself, for both platforms, and I could integrate it to be a beautiful part of the app experience rather than something tacked on as an afterthought.

I tried mimicking the aesthetic of the Messages app on respective platforms, down to those little curvy tips on chat bubbles.

A macOS chat window and an iOS chat window. Both contain 2 chat bubbles. The first one is from Mihhail and contains the text “Hey, I make Paper. 👋 What’s on your mind? 🤔”. The second one is from the user and contains the text “Are you a bot?”.

Unlike HockeyApp, which required an email to get started, I decided to lower the friction to zero in order to maximize the feedback potential. I was fine to trade lower entry barriers for more spam.

For the backend, I wanted something hosted and free, so I ended up saving conversations to UUID.json files on Dropbox.

A part of the macOS Finder window with a list of UUID-named JSON files. File names are mostly blurred out.

I just edit those files in a text editor and it syncs back to Dropbox. Looking back, I probably should have just built it on top of a Slack workspace… but Dropbox has worked well and still works great for me.

With time I’ve even spotted a few clear patterns and coded the logic that looks for keywords both in the unsent message field and in the last sent message to show auto-replies as soon as the user has typed the needed word. Now I almost never get these questions anymore — they are answered before the send button is pressed.

A chat window with 2 chat bubbles. The first one is from Mihhail and contains the text “Hey, I make Paper. 👋 What’s on your mind? 🤔”. The second one is from the user and contains the text “Are you asking about photos? 🤔 Sorry, Paper is for text only. Can’t add photos. PS: This is an auto-reply 🤖”. The unsent message box contains the text “Can I add photos?”.

In the end, building a polished, frictionless support chat was one of the best decisions that I have made for Paper. Not only did it differentiate the app from the rest (unlike the web, live chats in native apps is not a thing, especially in tiny apps like Paper), but also resulted in happier (and often positively surprised) users, faster feedback in case of bugs, and a lot of great ideas. In fact, at some point, 90% of my roadmap started being informed by ideas from the chat.

Releases

So I’ve gathered and implemented the feedback from the chat. How are the features then propagated to the users?

About once a month I release one flagship feature that goes into release notes. Often I prepare multiple features during a development streak, and then drip them out one at a time over the next months. This gives me the highest chance that someone would actually read the single sentence that is present in the release notes every month.

Bug fixes, tweaks, and smaller features also go into releases, but I just don’t mention them at all. As a developer, you might feel better about putting We’ve fixed some bugs and made a few performance improvements into your release notes, but the average user of a niche, unknown app like Paper has no patience to care.

For the version number, I use just one monotonically increasing number. It's the simplest thing, so why make it more complicated with 2+ numbers separated by dots?

And here’s the template that I use for release notes.

Dear User,

One sentence. Two–three in rare cases.

Pleasant update,
Your Paper

It’s a bit silly, but I like it. It’s like every month the app sends you a letter explaining what’s new. Some users have even mentioned this as a thing they look forward to.

Having something to release every month, be it small or big, is what greases the flywheel of App Store distribution.

It shows current users that their subscription is worth it.

It signals to potential users that the app has been recently updated.

It tells the App Store algorithm that the app is not abandoned.

And, finally, this slow and steady pace is what allows me to keep this thing going for years to come.

Easter eggs

I was always annoyed that no matter how beautiful the Mac app was, they almost always left the About window as a boring, default one. Why not make it a little more fun?

A small, white, 2:1 macOS window that contains a centered text saying “Crafted in Tallinn”. The text is light red in a cursive font. Below this text, there is a little gray number 61 in a regular font.

(roughly in the dimensions of a business card)

Care to find the other ones? 👀