|
| 1 | +# FSKit app extension example |
| 2 | + |
| 3 | +[FSKit](https://developer.apple.com/documentation/fskit) is a framework introduced in macOS 15.4 that allows to providing custom filesystem support from user space. |
| 4 | + |
| 5 | +It builds upon Apple's existing app extension support, see [WWDC 2014 217](https://nonstrict.eu/wwdcindex/wwdc2014/217/) and [Apple's documentation](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/index.html) for an understanding on how this system works. |
| 6 | + |
| 7 | +Note in particular that **extensions must be bundled inside another application**, and must be sandboxed. To allow communication between the host application and the app extension, one should use "app groups". We won't do so in this example though. |
| 8 | + |
| 9 | + |
| 10 | +## Usage |
| 11 | + |
| 12 | +1. Build with: |
| 13 | + ```sh |
| 14 | + ./examples/fskit/bundle.sh |
| 15 | + ``` |
| 16 | + |
| 17 | +2. Navigate to `System Settings > General > Login Items & Extensions`, and scroll down to the Extensions section. |
| 18 | + - Opening the link `x-apple.systempreferences:com.apple.LoginItems-Settings.extension` should do the trick. |
| 19 | + - In the File System Extensions cell, click the ⓘ symbol, and enable "FSKit Example Extension". |
| 20 | + |
| 21 | +4. Create a directory to serve as the mount target for the file system: |
| 22 | + ```sh |
| 23 | + mkdir /tmp/fskit-mount-target |
| 24 | + ``` |
| 25 | + |
| 26 | +5. Create a dummy file and construct a new block device from it: |
| 27 | + ```sh |
| 28 | + mkfile -n 100m ./target/dummy-block-device |
| 29 | + DISK="$(basename $(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nomount ./target/dummy-block-device))" |
| 30 | + echo $DISK |
| 31 | + ``` |
| 32 | + |
| 33 | + - On macOS 26, it should be possible to avoid the block device, and simply do: |
| 34 | + ```sh |
| 35 | + mount -F -t fskitexample ~/some-path /tmp/fskit-mount-target |
| 36 | + ``` |
| 37 | + |
| 38 | + If you set `FSSupportsPathURLs` and/or `FSSupportsGenericURLResources` to `true`, and handle the resource appropriately with `resource.downcast_ref::<FSPathURLResource>()` and/or `resource.downcast_ref::<FSGenericURLResource>()`. |
| 39 | +
|
| 40 | +6. Optional: In another terminal, tun the following to get the logging output: |
| 41 | + ```sh |
| 42 | + /usr/bin/log stream --predicate 'subsystem = "fskit-example"' --style compact --level debug |
| 43 | + ``` |
| 44 | + |
| 45 | + See [`println!` debugging](#println-debugging) below for details. |
| 46 | + |
| 47 | +7. Mount the block device: |
| 48 | + ```sh |
| 49 | + mount -F -t fskitexample $DISK /tmp/fskit-mount-target |
| 50 | + ``` |
| 51 | + |
| 52 | + |
| 53 | +## App extension setup |
| 54 | + |
| 55 | +Xcode provides templates to create app extensions (go to `File > New > Target`), but for clarity we do it here without Xcode and Swift. |
| 56 | + |
| 57 | +The general setup is: |
| 58 | +1. Two Cargo binaries, the host application `fskit-example` and the app extension `fskit-example-extension`. |
| 59 | +2. A custom `build.rs` which configures `fskit-example-extension` to use `NSExtensionMain` instead of Rust's usual `fn main` as the entrypoint. |
| 60 | +3. A `FSUnaryFileSystem` subclass, which is registered before `NSExtensionMain` is called by using `#[ctor]`. |
| 61 | + - `EXExtensionPrincipalClass` is used to point to our `FSUnaryFileSystem` class (an alternative to using Swift's `UnaryFileSystemExtension`). |
| 62 | + - This is also what's done [by Apple's own `msdosfs`](https://github.com/apple-oss-distributions/msdosfs/blob/msdosfs-788.40.4/msdos_appex/Info.plist). |
| 63 | +4. Signing and bundling is done in `bundle.sh`. |
| 64 | + |
| 65 | +This general setup should be transferable to other app extensions. |
| 66 | + |
| 67 | + |
| 68 | +## Internals overview |
| 69 | + |
| 70 | +My rough understanding of how FSKit works internally, gleaned from attaching to various processes and inspecting their state: |
| 71 | + |
| 72 | +A system service somewhere (`fskitd` or `fskit_helper`?) receives custom `mount`/`umount`/... requests from with the kernel, and delegates these to each custom filesystem. |
| 73 | + |
| 74 | +Upon `mount`, it then does roughly the following on a filesystem extension: |
| 75 | +1. If no instance of the application extension exists, tell `launchd` to spawn a "root" filesystem process. |
| 76 | + - This is a normal executable, but it's linked with `_NSExtensionMain` as the entry point. |
| 77 | + - The main thread is then put in a runloop state, listening for new events over XPC from `fskitd`. |
| 78 | +2. Message root process above, call `probeResource:replyHandler:` (on a worker thread) |
| 79 | +3. If successful, tell `launchd` to spawn a new filesystem process. |
| 80 | + - Similarly to the root process, this starts a runloop and listens for events. |
| 81 | + - Difference seems to be that it's launched with `LaunchInstanceID=$RANDOM_UUID` instead of `LaunchInstanceID=00000000-0000-0000-0000-000000000001`? |
| 82 | +4. Message newly created process, call `loadResource:replyHandler:` (on a worker thread). |
| 83 | +5. Further messages are sent to that same process, which is later handled by `FSVolume`. |
| 84 | + |
| 85 | +On `umount`: |
| 86 | +- `deactivateWithOptions:replyHandler:` is called |
| 87 | +- But nothing else? Quick testing didn't seem to call `unmountWithReplyHandler:` nor `unloadResource:replyHandler:`, but there might be a way to opt into that? |
| 88 | +
|
| 89 | +At some point, the root process is killed if there are no more individual mount processes left. |
| 90 | +
|
| 91 | +
|
| 92 | +## Debugging |
| 93 | +
|
| 94 | +Ideally, you'll want to attach a debugger. That can be a bit troublesome though, because: |
| 95 | +- We do not control the process launch (`launchd` / `fskitd` does). |
| 96 | +- There is no way (that I've found) to tell `fskitd` that you want processes spawned with `POSIX_SPAWN_START_SUSPENDED` set. |
| 97 | +- Which in turn means that any attempt to attach a debugger is inherently racy. |
| 98 | + - A trick here is to add `std::thread::sleep_ms(100)` to the top of `fn setup()`. |
| 99 | +
|
| 100 | +Even more annoying is that `fskitd` seems to have an internal timer that kills the process if it takes more than ~10 seconds to launch. So it's a lot of juggling. |
| 101 | + |
| 102 | +That said, I have had some luck with: |
| 103 | + |
| 104 | +```sh |
| 105 | +lldb --wait-for --attach-name fskit-example-extension # Optionally: --one-line "continue" |
| 106 | +``` |
| 107 | + |
| 108 | +Depending on what step you're debugging, you might end up catching the "wrong" process with this though, because as stated above, `fskitd` seems to spawn two extension processes (root process, and one for the new volume) when starting up. |
| 109 | +
|
| 110 | +
|
| 111 | +### `println!` debugging |
| 112 | +
|
| 113 | +Generally a good alternative when debuggers are unavailable, though also perilous here: `stdout` and `stderr` are redirected to `/dev/null` by `launchd`. |
| 114 | +
|
| 115 | +Writing to `/tmp` files won't work either, as those are sandboxed away too. |
| 116 | +- `std::env::temp_dir()` should work, though that means it's gonna be buried in something like `~/Library/Containers/com.example.fskit-example.extension/Data/tmp`. |
| 117 | +- Alternatively, maybe add [`com.apple.security.temporary-exception.files.absolute-path.read-write`](https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/AppSandboxTemporaryExceptionEntitlements.html#//apple_ref/doc/uid/TP40011195-CH5-SW7)? |
| 118 | +
|
| 119 | +Instead, we use `tracing-oslog` to log errors via Apple's OSLog. These can be viewed with: |
| 120 | +```sh |
| 121 | +/usr/bin/log stream --predicate 'subsystem = "fskit-example"' --style compact --level debug |
| 122 | +``` |
| 123 | +
|
| 124 | +That said, I _have_ seen it fail at the boundaries / entry/exit points (`deactivateWithOptions:replyHandler:` for example isn't logged IIRC), so it's not a perfect solution. |
| 125 | +
|
| 126 | +
|
| 127 | +## Troubleshooting |
| 128 | +
|
| 129 | +### Nothing is spawned. |
| 130 | +
|
| 131 | +Make sure that it's the only extension with the given `FSShortName`: |
| 132 | +
|
| 133 | +```sh |
| 134 | +/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -dump | grep FSShortName |
| 135 | +``` |
| 136 | +
|
| 137 | +You can view the entirety of the registered FSKit entries with: |
| 138 | +
|
| 139 | +```sh |
| 140 | +/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -dump | awk 'BEGIN{RS="------"; FS="\n"} /com\.apple\.fskit\.fsmodule/ {print $0}' |
| 141 | +``` |
| 142 | +
|
| 143 | +Also try: |
| 144 | +
|
| 145 | +```sh |
| 146 | +pluginkit --match --all-versions --duplicates -vv --identifier com.example.fskit-example.extension |
| 147 | +``` |
| 148 | +
|
| 149 | +
|
| 150 | +### My logs aren't showing up / weirdness |
| 151 | +
|
| 152 | +Try `trash ./target/debug/fskit-example.app`, empty trash, and rerun `./example/fskit/bundle.sh`. |
| 153 | +
|
| 154 | +TODO: Maybe we can use smth. like `RegisterExecutionPolicyException` to avoid the delay? |
| 155 | +
|
| 156 | +
|
| 157 | +### Resource busy |
| 158 | +
|
| 159 | +Run `killall fskit-example-extension` and try again after a few seconds. |
| 160 | +
|
| 161 | +
|
| 162 | +### Probing resource: Couldn’t communicate with a helper application. |
| 163 | +
|
| 164 | +You might get this error if attempting to `mount` right after bundling the extension. Try again. |
0 commit comments