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.

/Library/TweakInject/Tweaks/Bundles/
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/.

Plain dylib and its filter
/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.

Entry point
__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:

  1. Contents/Info.plist — used only if it contains a Filter dictionary
  2. Contents/Resources/Filter.plist
  3. Contents/Filter.plist
  4. Contents/Resources/<TweakName>.plist
  5. Contents/<TweakName>.plist
  6. Contents/Info.plist again, 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

KeyTypeAccepted values and matching
BundlesarrayBundle identifiers. Exact match, case-insensitive. Matches the main bundle or any bundle already loaded in the process, so a framework identifier matches too.
ExecutablesarrayExecutable file names. Exact match on the last path component, case-insensitive. Not a substring match.
ClassesarrayObjective-C class names. Matches when the class exists in the host. Exact and case-sensitive.
ExcludeBundlesarrayBundle identifiers that must never match. Evaluated before everything else; same comparison as Bundles.
TypestringOnly 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.
PrivilegestringOnly 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.
CoreFoundationVersionarrayOne or two numbers, [minimum, maximum) . The minimum is inclusive and the maximum is exclusive. Elements past the second are ignored.
Contents/Info.plist — inlined filter
<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 .

Package layout
DEBIAN/control
Library/TweakInject/Tweaks/Bundles/MyTweak.bundle
Library/TweakInject/Preferences/PreferenceBundles/MyTweakPrefs.bundle
DEBIAN/control
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 .

Build flags
# 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.

PSPreferences.h
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
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

MyTweakPrefs.bundle/Root.plist
<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>
cellControl
PSGroupCellSection heading; takes an optional footerText
PSSwitchCellToggle
PSSliderCellSlider; takes min and max
PSSegmentCellSegmented control over validValues
PSLinkListCellPop-up menu over validValues and validTitles
PSEditTextCellText field; takes an optional placeholder
PSButtonCellButton 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 .

PSPreferenceController.h
@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.

AssetSpecification
Icon1:1, 512×512 PNG. Shown in the catalogue and in Installed Tweaks.
Hero3:1, 1920×640. The featured carousel at the top of the store.
Banner3:1, 1920×640. The header of the tweak's own page.
Screenshots16:9 or 16:10. Show the tweak in use rather than its settings.
VideoOptional, 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.
DepictionMarkdown 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.

dockpidVanish
Format Plain dylib, Tweaks/DynamicLibraries/ with a sibling .plist Bundle, Tweaks/Bundles/ with the filter inside it
Filter Bundlescom.apple.dock ExecutablesWindowServer, 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.