A RELIABLE approach with File Lock. UserDefaults+App Group wont’ work (Well). Darwin Notification won’t work.

First of all, please let me share with you my background story as a little motivation. (Feel free to skip if you are not interested!)
- I have some feature that can ONLY be started from the container app while running in the foreground. This feature can keep running in the background though. (Yes, audio recording, you know I am looking at you here.)
- I have a keyboard extension
- I wanted to know from my extension whether if this feature is running or not, ie: if the audio recording is started or not, upon some user interaction (I don’t need to know it in real time). So that if it is not, I can potentially open up the container app and start it.
So!
Here will be the problem statement of this article!
Is my container app (Or App’s xxx feature) running?
Yes, the same approach (same implementations) will work for determining both
- whether the app itself if running, and
- whether a specific feature is running
I will be sharing with you the usage on both!
(PS: Launching the container app from the extension is easy. Custom URL Scheme + openURL, you don’t need me to say more about it. Feel free to check out my previous articles Support Custom URL Scheme if you are interested.)
I have made a little demo on my GitHub if you want to give it a try (without setting up the app yourself and copying/pasting the code).
Start!
What WON’T WORK (Well)
First of all, before I sharing with you the final approach I ended up going for, please let me share with you what I have indeed tried that either won’t work well or won’t work at all.
For my own reference and so that you don’t have to waste your time trying out the same ones. (Honestly speaking, for 99.999999% of the time, knowing what doesn’t work and why it does NOT is a lot more valuable than what does work, at least for me!)
App Groups + shared UserDefaults + Feature flag
If we are thinking about sharing something between the extension and the container app, app groups + user defaults is probably the first thing that comes into mind (At least mine tiny one)!
- The container app writes to the flag (some variable stored in the user defaults) when it starts/stops the feature (or launches/terminates if you are using it to check whether the app is running)
- The keyboard reads the flag
However, we all know that applicationWillTerminate isn’t reliable. The system can totally kill the process without calling it.
Darwin Notification
The I saw some online articles talking about communicating between the extension and the container app using Darwin Notification where they have used it for sending notifications from a broadcast upload extension or a Document Provider Extension to the main app.
I thought it would apply equally well in our case here from a keyboard extension, where
- our extension ping the container app with a notification and wait for a pong
- If the container app is indeed running, it pongs the extension with another notification upon receiving the ping. Ie: if the extension is not receiving the pong, the container app is not running.
However, I was totally wrong. This approach just won’t work in so many sense.
- Obviously pong happens at some callback that is registered together with the notification observer which means the extension will have to loop to wait for it
- Even though CFNotificationCenterPostNotification accept a userInfo, it is totally ignored by Darwin Notify, ie: nil regardless of what we pass in. That is the container app does not have an actual way to tell the extension whether if a feature is running or not, but will just have to not respond to the extension’s ping if the feature is not running.
- Notifications will NOT BE DELIVERED until the receiver (observer) is in the FOREGROUND. That is if I am on my keyboard extension with the container already running in the background and I ping the container, that notification will not be delivered until I actually bring the container app into the foreground. Which doesn’t solve a single problem for me! By the way, I have indeed set deliverImmediately to true when CFNotificationCenterPostNotification and CFNotificationSuspensionBehavior to deliverImmediately when CFNotificationCenterAddObserver. However, if we take a look at the documentations carefully (for both of the functions), both of those actually mention that those two parameters are ignored by Darwin notification center. So here is my guess on why those are not delivered until the receiver is in the foreground. When an iOS app goes to background, the system suspends the process, all threads are frozen, all run loop stops. Since the run loop isn’t ticking, the Darwin notification center has no opportunity to deliver anything! Boomer!
(Yes, I know, I am spending a lot of words in this section! Because that is how much time I have spent on it! How hard I have scratched my head!)
The Approach That Worked For me!
Time for the main dish (?)! It will actually be a lot shorter than the section above! Because it is simple and straightforward! I like simple things! Simple is the BEST!
Basic Idea
App Group + file lock
- Create A shared file with App Group
- When the container starts the feature, open the file and acquires the lock with open(_:_:options:permissions:retryOnInterrupt:) on FileDescriptor
- When the container app ends the feature, release the lock by closing the file descriptor.
- When the extension needs to find whether the app (or the feature) is running or not, try to open the same file and get the lock. If lock succeeded, then the container app is dead or the feature is not running. Otherwise, the feature is running!
Why this approach is great?
- No async waits (polling on notifications)
- Auto release on app termination.
Implementation (Swift Version)
Add in the same App Group to both the container app and the extension, and let’s implement!
import System
import Foundation
let groupId = "your.group.id"
final class AppFeatureStateManager {
var lockFD: FileDescriptor? = nil
private static let lockFileURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: groupId)?
.appendingPathComponent("app.lock")
private static let fileManager = FileManager.default
func acquireLock() {
guard let url = Self.lockFileURL else {
return
}
if !Self.fileManager.fileExists(atPath: url.path()) {
Self.fileManager.createFile(atPath: url.path, contents: nil)
}
// open the file and acquires the lock
lockFD = try? FileDescriptor.open(
FilePath(url.path),
.readWrite,
options: [.exclusiveLock, .nonBlocking, .create],
permissions: .ownerReadWrite
)
}
func releaseLock() {
try? lockFD?.close()
lockFD = nil
}
static func isContainerAppFeatureRunning() -> Bool {
guard let url = self.lockFileURL else {
return false
}
do {
// try to open the file and acquires the lock
// if not thrown, the lock acquired, ie: app is dead or feature is not running
let fileDescriptor = try FileDescriptor.open(
FilePath(url.path),
.readWrite,
options: [.exclusiveLock, .nonBlocking]
)
try? fileDescriptor.close()
return false // acquired lock = app is dead or feature is not running
} catch let error as Errno {
return error == .wouldBlock // wouldBlock = lock held = app is alive or feature is running
} catch {
return false // file doesn't exist or other error = not alive
}
}
}
To use this class to check for a specific feature running or not
Container app side
- Call acquireLock on feature start
- call releaseLock on feature end
Extension side
- call isContainerAppFeatureRunning whenever the state is needed
To check whether the container app is alive or not.
Container app side
- call acquireLock on app launch (never need to call releaseLock, the lock will be automatically released on app termination)
Extension side
- Same as above, call isContainerAppFeatureRunning whenever the state is needed
And Of course, you can combine the two (or use it to check multiple different features) by simple locking different files!
Implementation (Darwin/C)
I don’t know why you would prefer this option, but since I have it, please let me share it here with you really quick!
final class AppFeatureStateManager {
var lockFD: Int32 = -1
private static let lockFileURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: groupId)?
.appendingPathComponent("app.lock")
private static let fileManager = FileManager.default
func acquireLock() {
guard let url = Self.lockFileURL else {
return
}
if !Self.fileManager.fileExists(atPath: url.path()) {
Self.fileManager.createFile(atPath: url.path, contents: nil)
}
// Same as performing the following two seperately
// 1. open(url.path, O_RDWR) to open the file
// 2. acquire the lock with flock(lockFD, LOCK_EX | LOCK_NB)
// However, open with O_RDWR | O_EXLOCK | O_NONBLOCK flag is better because with separate open + flock there's a tiny window between the two calls where another process could theoretically interfere.
lockFD = open(url.path, O_RDWR | O_EXLOCK | O_NONBLOCK)
}
func releaseLock() {
guard lockFD >= 0 else { return }
flock(lockFD, LOCK_UN)
close(lockFD)
lockFD = -1
}
static func isContainerAppFeatureRunning() -> Bool {
guard let url = self.lockFileURL else {
return false
}
// tries to open the file and acquire the lock.
// result:
// 0: locking succeed => app container is dead or the feature is not running
// 1: locking failed
// same as calling
// 1. fileDescriptor = open(url.path, O_RDWR)
// 2. if fileDescriptor is >= 0, open succeed => app dead/feature not running => return false
// 3. try lock the file with flock(fileDescriptor, LOCK_EX | LOCK_NB)
// 4. close(fileDescriptor) to release the lock in case it succeed
// 5. return result != 0 => failed to lock = someone else holds it = app feature is running
let fileDescriptor = open(url.path, O_RDWR | O_EXLOCK | O_NONBLOCK)
if fileDescriptor >= 0 {
// acquired lock = container app is dead
close(fileDescriptor)
return false
} else {
// failed = container app is alive
return true
}
}
}The usage is exactly the same as above, so please let me skip those.
A little point I would like to point out here is that when trying to acquire the lock to a file, there is also the option of calling open + flock. However, open with O_RDWR | O_EXLOCK | O_NONBLOCK flag is better because with separate calls, there’s a tiny window between the two where another process could theoretically interfere. (Not really applicable to our usage/scenario here, but just like to point that out really quick!)
Try it out!
Here is a simple view/keyboard controllers so that we can give our little AppFeatureStateManager above a try!
// container app
struct ContentView: View {
@State private var isFeatureRunning: Bool = false
@State private var appFeatureStateManager = AppFeatureStateManager()
var body: some View {
VStack(spacing: 48) {
Text("Is the feature Running? \(isFeatureRunning ? "Yah!" : "No!")")
.font(.title2)
.fontWeight(.semibold)
Toggle(isOn: $isFeatureRunning, label: {
Text("Some Feature")
})
.onChange(of: self.isFeatureRunning, {
self.isFeatureRunning ? appFeatureStateManager.acquireLock() : appFeatureStateManager.releaseLock()
})
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.yellow.opacity(0.1))
}
}
// keyboard extension
import UIKit
class KeyboardViewController: UIInputViewController {
override func viewDidLoad() {
super.viewDidLoad()
let checkFeatureButton = UIButton(type: .system)
checkFeatureButton.setTitle("Is Feature Running?", for: [])
checkFeatureButton.sizeToFit()
checkFeatureButton.translatesAutoresizingMaskIntoConstraints = false
checkFeatureButton.addTarget(self, action: #selector(checkFeatureRunning), for: .touchUpInside)
self.view.addSubview(checkFeatureButton)
checkFeatureButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
checkFeatureButton.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true
}
@objc private func checkFeatureRunning() {
let result = AppFeatureStateManager.isContainerAppFeatureRunning()
self.textDocumentProxy.insertText(result ? "Yah!" : "No!")
}
}
I am too lazy to set up a SwiftUI view for the demo but if you are interested, please feel free to check out my previous article: SwiftUI: Create Systemwide Custom Keyboard.

Thank you for reading!
That’s it for this article!
Happy communicating!
SwiftUI: Is My Container App (Or App’s xxx Feature) Running? Asked By My Keyboard Extension. was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.