Skip to content

Commit 710b7cd

Browse files
committed
Add FSKit example
Fixes #815.
1 parent dabf36e commit 710b7cd

File tree

16 files changed

+1102
-2
lines changed

16 files changed

+1102
-2
lines changed

Cargo.lock

Lines changed: 42 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/fskit/Cargo.toml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
[package]
2+
name = "fskit-example"
3+
version = "0.0.0"
4+
edition = "2021"
5+
license = "Zlib OR Apache-2.0 OR MIT"
6+
publish = false
7+
8+
# The host application.
9+
[[bin]]
10+
name = "fskit-example"
11+
path = "host/main.rs"
12+
13+
# The app extension.
14+
[[bin]]
15+
name = "fskit-example-extension"
16+
path = "extension/main.rs"
17+
18+
[dependencies]
19+
ctor = "0.6.3"
20+
tracing = "0.1.44"
21+
tracing-subscriber = "0.3.22"
22+
tracing-oslog = "0.3.0"
23+
libc = "0.2.182"
24+
25+
[target.'cfg(target_os = "macos")'.dependencies]
26+
block2 = "0.6.1"
27+
objc2 = "0.6.2"
28+
objc2-core-foundation = { version = "0.3.1", default-features = false, features = ["CFCGTypes"] }
29+
objc2-foundation = { version = "0.3.1", default-features = false, features = [
30+
"std",
31+
"block2",
32+
"NSDictionary",
33+
"NSError",
34+
"NSString",
35+
] }
36+
objc2-fs-kit = { version = "0.3.1", default-features = false, features = [
37+
"std",
38+
"block2",
39+
"default", # TEMPORARY
40+
] }

examples/fskit/README.md

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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.

examples/fskit/build.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
fn main() {
2+
// Make Cargo not re-run the build-script unnecessarily.
3+
println!("cargo:rerun-if-changed=build.rs");
4+
5+
let extension_bin = "fskit-example-extension";
6+
7+
// Pass `-e _NSExtensionMain` to change the entry point for the extension.
8+
//
9+
// Combined with `#![no_main]`, this makes our binary an app extension.
10+
println!("cargo:rustc-link-arg-bin={extension_bin}=-e");
11+
println!("cargo:rustc-link-arg-bin={extension_bin}=_NSExtensionMain");
12+
13+
// Configure the linker to give earlier diagnostics if linking dylibs that
14+
// aren't supported in application extensions. There currently aren't any
15+
// public frameworks / libraries where this is the case, so this doesn't
16+
// matter that much, but there might be in the future.
17+
println!("cargo:rustc-link-arg-bin={extension_bin}=-Wl,-application_extension");
18+
}

examples/fskit/bundle.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
# Identity to use when signing. Defaults to ad-hoc signing ("-").
6+
IDENTITY="${IDENTITY:--}"
7+
8+
# Various paths.
9+
CARGO_PROFILE="dev"
10+
CARGO_PROFILE_DIR="debug"
11+
SOURCE_DIR="$(dirname "$(realpath "$0")")"
12+
BUILD_DIR="$(dirname "$(cargo locate-project --workspace --message-format plain)")/target"
13+
BUNDLE_PATH="$BUILD_DIR/$CARGO_PROFILE_DIR/fskit-example.app"
14+
# App extensions are placed under the special `Extensions` path.
15+
# `PlugIns` is the older option, that works as well (and is probably recommended if you want to support older OSes).
16+
EXTENSION_BUNDLE_PATH="$BUNDLE_PATH/Contents/Extensions/fskit-example-extension.appex"
17+
18+
19+
# Build host and extension binaries.
20+
cargo build -pfskit-example --bin fskit-example --bin fskit-example-extension --profile $CARGO_PROFILE
21+
22+
23+
# Create and sign app extension bundle.
24+
mkdir -p "$EXTENSION_BUNDLE_PATH/Contents/MacOS"
25+
26+
cp "$BUILD_DIR/$CARGO_PROFILE_DIR/fskit-example-extension" "$EXTENSION_BUNDLE_PATH/Contents/MacOS/fskit-example-extension"
27+
cp "$SOURCE_DIR/extension/Info.plist" "$EXTENSION_BUNDLE_PATH/Contents/Info.plist"
28+
29+
if [ "$IDENTITY" = "-" ]; then
30+
codesign --force --sign "$IDENTITY" --timestamp=none --options runtime --entitlements "$SOURCE_DIR/extension/adhoc.entitlements" --generate-entitlement-der "$EXTENSION_BUNDLE_PATH"
31+
else
32+
codesign --force --sign "$IDENTITY" --timestamp=none --options runtime --entitlements "$SOURCE_DIR/extension/main.entitlements" --generate-entitlement-der "$EXTENSION_BUNDLE_PATH"
33+
fi
34+
35+
touch "$EXTENSION_BUNDLE_PATH" # Update creation time.
36+
37+
38+
# Create and sign host app bundle.
39+
mkdir -p "$BUNDLE_PATH/Contents/MacOS"
40+
41+
cp "$BUILD_DIR/$CARGO_PROFILE_DIR/fskit-example" "$BUNDLE_PATH/Contents/MacOS/fskit-example"
42+
cp "$SOURCE_DIR/host/Info.plist" "$BUNDLE_PATH/Contents/Info.plist"
43+
44+
codesign --force --sign "$IDENTITY" --timestamp=none --options runtime --entitlements "$SOURCE_DIR/host/main.entitlements" --generate-entitlement-der "$BUNDLE_PATH"
45+
46+
touch "$BUNDLE_PATH" # Update creation time.
47+
48+
49+
# Register the application with launch services.
50+
#
51+
# This makes the application show up in Spotlight and allows launching it with `open -a fskit-example`.
52+
#
53+
# More importantly, it also makes the app extension available immediately in settings without having
54+
# to launch the host application first.
55+
/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -R -trusted "$BUNDLE_PATH"

0 commit comments

Comments
 (0)