Documentation
Developing tweaks
How a tweak is laid out on disk, how targeting is evaluated, how to store settings from inside a sandboxed host, and how to publish to the store.
Tweak formats & installation paths
A tweak is a Mach-O plus a filter describing which processes it applies
to. The loader is injected into a starting process, evaluates every
installed tweak's filter, and calls dlopen on those that
match. Your code runs from a constructor, before the host's main.
TweakInject installs and discovers tweaks in dedicated directories under
/Library/TweakInject/:
Bundles · /Library/TweakInject/Tweaks/Bundles/
Bundle tweaks are standard macOS bundles installed in
/Library/TweakInject/Tweaks/Bundles/. The loader locates the
executable Mach-O inside Contents/MacOS/ and reads the targeting
filter from Contents/Info.plist or Contents/Resources/Filter.plist.
Bundles are the recommended format for tweaks with assets, helper tools,
or localized resources. When disabled via the app or CLI, the bundle is moved to
BundlesDisabled/ so the loader ignores it.
MyTweak.bundle/
Contents/
Info.plist identifier, version, and optionally the Filter
MacOS/MyTweak arm64 / arm64e Mach-O
Resources/
Filter.plist targeting, if not inlined in Info.plist
Plain dynamic libraries · /Library/TweakInject/Tweaks/DynamicLibraries/
Standalone .dylib tweaks install directly into
/Library/TweakInject/Tweaks/DynamicLibraries/. Each dylib
must be accompanied by a filter plist with the exact same base name
alongside it. The loader enumerates plists in this folder,
not dylibs — so a .dylib with no matching plist is never
loaded and reports no error. When disabled, both files are moved to
DynamicLibrariesDisabled/.
/Library/TweakInject/Tweaks/DynamicLibraries/ MyTweak.dylib MyTweak.plist required -- same base name, alongside the binary
Preference bundles · /Library/TweakInject/Preferences/PreferenceBundles/
If your tweak includes configuration UI for System Settings or the
in-app preference viewer, its preference bundle installs to
/Library/TweakInject/Preferences/PreferenceBundles/. These
are discovered by PreferenceLoaderX and render either standard
specifier-driven preference lists (Root.plist) or custom
view controllers.
__attribute__((constructor))
static void init(void) {
// Runs inside the target process, before its main().
// Install hooks here. This is on the launch path; keep it cheap.
}
Filters
For a bundle, the filter may be inlined in
Contents/Info.plist under a Filter key, or
supplied as a separate file. The loader checks, in order:
Contents/Info.plist— used only if it contains aFilterdictionaryContents/Resources/Filter.plistContents/Filter.plistContents/Resources/<TweakName>.plistContents/<TweakName>.plistContents/Info.plistagain, this time without requiring the key
The condition on the first entry is what makes that order workable:
every bundle has an Info.plist , so an unconditional check
there would shadow the dedicated locations beneath it.
Every entry is inside the bundle. A filter placed next to a bundle rather than within it is never consulted, so a bundle's targeting is always exactly what the bundle itself carries.
In practice an installed tweak always resolves at the second entry. When
TweakInject installs a bundle it writes the filter it extracted to
Contents/Resources/Filter.plist , so that file is present
regardless of where the filter was originally authored.
A filter file may carry its criteria at the root, or nested under a
Filter key. Both are accepted.
Keys and accepted values
| Key | Type | Accepted values and matching |
|---|---|---|
| Bundles | array | Bundle identifiers. Exact match, case-insensitive. Matches the main bundle or any bundle already loaded in the process, so a framework identifier matches too. |
| Executables | array | Executable file names. Exact match on the last path component, case-insensitive. Not a substring match. |
| Classes | array | Objective-C class names. Matches when the class exists in the host. Exact and case-sensitive. |
| ExcludeBundles | array | Bundle identifiers that must never match. Evaluated before everything else; same comparison as Bundles. |
| Type | string | Only App and Binary are tested. App requires the host to be an application, Binary requires that it is not. Any other value, including Any, imposes no restriction. |
| Privilege | string | Only Root and User are tested, case-insensitively, against the effective user ID. Root requires euid 0, User requires non-zero. Any other value imposes no restriction. |
| CoreFoundationVersion | array | One or two numbers, [minimum, maximum) . The minimum is inclusive and the maximum is exclusive. Elements past the second are ignored. |
<key>Filter</key>
<dict>
<key>Bundles</key>
<array>
<string>com.apple.dock</string>
</array>
<key>Type</key>
<string>Any</string>
</dict>
Packaging
A .deb is a control file plus a tree that is
copied onto the filesystem. Everything installs under
/Library/TweakInject .
DEBIAN/control Library/TweakInject/Tweaks/Bundles/MyTweak.bundle Library/TweakInject/Preferences/PreferenceBundles/MyTweakPrefs.bundle
Package: com.yourname.mytweak Name: MyTweak Version: 1.0.0 Architecture: darwin-arm64e Description: One line, shown in the store listing. Author: You <you@example.com> Depends: com.doraorak.preferenceloaderx
Declare a dependency on com.doraorak.preferenceloaderx if
your tweak ships a preference bundle and you want its settings to appear
in System Settings.
Preferences
CFPreferences is not usable from inside a sandboxed host:
reads return nothing and writes go somewhere the tweak cannot read back.
TI_PreferenceSupport provides a store outside the sandbox
at /Library/TweakInject/Preferences/Defaults/<domain>.plist ,
with two interchangeable interfaces over it.
Headers and linking
The headers are published beside the app on the
releases page.
Point your include path at them and link against
TI_PreferenceSupport .
# Headers ship beside the app on the releases page. # PSPreferences.h C API # PSUserDefaults.h Objective-C API # PSPrefsCore.h lower-level NSString/NSDictionary calls # PSPreferenceController.h protocol for a custom preference pane clang ... \ -I/path/to/TI_PreferenceSupport/headers \ -L/Library/TweakInject -lTI_PreferenceSupport # Theos: # MyTweak_CFLAGS = -I/path/to/TI_PreferenceSupport/headers # MyTweak_LDFLAGS = -L/Library/TweakInject -lTI_PreferenceSupport
-L only tells the linker where to find the library while
building. At load time dyld resolves the install name
recorded in your tweak, which is the absolute
/Library/TweakInject/TI_PreferenceSupport.dylib , so no
search path is involved and nothing has to be co-located with the tweak.
PSPreferences — C
A direct replacement for the CFPreferences functions.
Porting existing code is a prefix change.
CFPropertyListRef PSPreferencesCopyAppValue(CFStringRef key, CFStringRef domain); void PSPreferencesSetAppValue(CFStringRef key, CFPropertyListRef value, CFStringRef domain); Boolean PSPreferencesGetAppBooleanValue(CFStringRef key, CFStringRef domain, Boolean *exists); CFIndex PSPreferencesGetAppIntegerValue(CFStringRef key, CFStringRef domain, Boolean *exists); Boolean PSPreferencesAppSynchronize(CFStringRef domain); CFArrayRef PSPreferencesCopyKeyList(CFStringRef domain);
PSUserDefaults — Objective-C
A Foundation-shaped interface over the same store. It is suite-based:
initWithSuiteName: is the only initialiser, and there is
deliberately no standardUserDefaults equivalent. A tweak is
a guest in another process, and naming the domain is what makes its
settings identical in every host. An invalid domain returns
nil rather than raising.
PSUserDefaults *d = [[PSUserDefaults alloc] initWithSuiteName:@"com.yourname.mytweak"];
// Layered UNDER the store, so this never overwrites a user's choice.
[d registerDefaults:@{ @"enabled": @YES, @"intensity": @0.5 }];
BOOL on = [d boolForKey:@"enabled"];
[d setDouble:0.8 forKey:@"intensity"];
id raw = d[@"enabled"]; // subscripting is supported
Typed accessors cover BOOL , NSInteger ,
double , float , NSString ,
NSArray , NSDictionary and
NSData , alongside dictionaryRepresentation
and keyed subscripting.
registerDefaults: is the one capability
PSPreferences does not have, and the main reason to prefer
this interface. Registered values sit beneath the store rather than in
it, so registering a default never overwrites a choice the user has
made.
Reacting to changes
Writing through either interface posts a Darwin notification named
<domain>/prefsChanged . Observe it and your tweak
updates as the user moves a control, with no polling and no restart.
Preference panes
Settings are always editable inside TweakInject itself, which renders
your Root.plist directly. They additionally appear as a
pane in System Settings when PreferenceLoaderX is
installed, which is the component that adds that section. Install the
bundle to
/Library/TweakInject/Preferences/PreferenceBundles/ .
Declarative
<key>title</key> <string>MyTweak</string>
<key>defaults</key> <string>com.yourname.mytweak</string>
<key>items</key>
<array>
<dict>
<key>cell</key> <string>PSGroupCell</string>
<key>label</key> <string>General</string>
</dict>
<dict>
<key>cell</key> <string>PSSwitchCell</string>
<key>label</key> <string>Enabled</string>
<key>key</key> <string>enabled</string>
<key>default</key> <true/>
</dict>
</array>
| cell | Control |
|---|---|
| PSGroupCell | Section heading; takes an optional footerText |
| PSSwitchCell | Toggle |
| PSSliderCell | Slider; takes min and max |
| PSSegmentCell | Segmented control over validValues |
| PSLinkListCell | Pop-up menu over validValues and validTitles |
| PSEditTextCell | Text field; takes an optional placeholder |
| PSButtonCell | Button bound to an action |
View controller
Where the declarative form is insufficient — interdependent controls,
live previews, custom drawing — supply a principal class instead.
Declare it as NSPrincipalClass in the preference bundle's
Info.plist and subclass NSViewController .
@protocol PSPreferenceController <NSObject> @optional /// The defaults domain this tweak's preferences live in. Called once, after /// init, before the view is first requested. - (void)setPreferencesDomain:(NSString *)domain; - (void)preferencesDidAppear; - (void)preferencesDidDisappear; @end
The host owns horizontal placement and supplies the width. Lay out from
x = 0 across self.view.bounds.size.width and
do not apply centring of your own. Report height through
preferredContentSize ; the host observes it and adjusts the
scroll area, so a pane that changes height stays correct.
Safe Mode and testing
Safe Mode engages automatically when the Dock, Finder, Spotlight, the
window server or the wallpaper components terminate on a fatal signal
while tweaks are loaded. A single crash is enough — there is no repeat
threshold. Internally it is a marker file at
/Library/TweakInject/SafeMode/.safemode that the loader
checks before loading anything, though users engage and clear it from
the application or the menu bar item.
When developing against one of those processes, expect to meet it. Install your build, restart userspace from the application, and check Logs — it records which tweaks matched which processes, which is the quickest way to discover that a filter never matched at all.
Store submission
Submission is handled in the application. Sign in with GitHub and supply
a .deb or a .bundle . A bundle is wrapped into
a .deb for you; a .deb is taken as it is, and
either way the identifier, version, architecture and dependencies are
read out of it to fill in the form.
Attach your files directly. Icon, artwork, screenshots and video are uploaded with the submission — there is nothing to host yourself and no URLs to paste. They are held privately until a moderator reviews them: they are not public while your submission is pending, and they are published alongside your listing only if it is accepted. A submission that is rejected or withdrawn has its files deleted.
Developer Center
Signing in adds Developer Center to the sidebar. It has two lists: the tweaks you have published, and every submission you have made with its current state.
- Pending — waiting for a moderator. You can withdraw it, which deletes the submission and its files from the server.
- Rejected — shown with the moderator's reason, so you know what to change before submitting again.
- Approved — published, and the tweak now appears under your published tweaks.
Publishing a tweak makes you its publisher. That is recorded when the
listing is published, not taken from anything inside the package — the
Maintainer control field is packaging metadata and grants
nothing.
Updating a published tweak
Submit Update appears on your own tweaks, both in Developer Center and in the store's context menu. It opens the submission form filled in with the listing as published — description, artwork, screenshots, video and the existing package.
Change only what you want to change. Anything you leave alone stays as it is, so correcting a sentence does not mean re-uploading a package. The update arrives in the review queue attached to the tweak it updates, and replaces that listing when it is approved.
What review involves
A moderator downloads your files and runs the tweak before deciding, so a submission that installs cleanly and does what its description says is the one that gets through quickly. Publishing is a deliberate step requiring a hardware key, which is why approval is not instant.
| Asset | Specification |
|---|---|
| Icon | 1:1, 512×512 PNG. Shown in the catalogue and in Installed Tweaks. |
| Hero | 3:1, 1920×640. The featured carousel at the top of the store. |
| Banner | 3:1, 1920×640. The header of the tweak's own page. |
| Screenshots | 16:9 or 16:10. Show the tweak in use rather than its settings. |
| Video | Optional, one per tweak. .mp4 (H.264), .mov or .m4v, 8 MB maximum. Shown first in the gallery, ahead of the screenshots. Around a minute at 1080p and a modest bitrate fits comfortably; the cap is checked as you attach it and again on upload. |
| Depiction | Markdown for a plain description, or JSON for structured sections. |
Accepted listings are signed with a hardware key before publication,
which is what the store's Signed indicator reflects.
Build for both arm64 and arm64e where you can.
Examples
Two published tweaks, source and all. Between them they take the opposite choice at every point above — the two install formats, the two ways a filter can name its target, and the two kinds of preference page — so the pair is more useful than either alone.
| dockpid | Vanish | |
|---|---|---|
| Format | Plain dylib, Tweaks/DynamicLibraries/ with a sibling .plist |
Bundle, Tweaks/Bundles/ with the filter inside it |
| Filter | Bundles — com.apple.dock |
Executables — WindowServer, which has no bundle identifier to match |
| Preferences | Declarative Root.plist |
NSViewController subclass |
| Source | Logos .x |
C, plus Metal shaders |
-
dockpid
— appends each app's process id to its Dock tile. The smaller of the
two, and the one to read first.
Root.plist— a worked declarative preference page: a group, a switch, a segmented control and a pop-up menu, each posting a change notification the tweak acts on live. -
Vanish
— animates windows closing from the red button, entirely inside
WindowServer.
VanishPrefsViewController.m— a worked custom preference pane: a live animation preview beside the controls that drive it, which is the kind of thing the declarative form cannot express.