‹ back

Cyanide: Writing Tweaks

Developer reference for writing Cyanide tweaks — RemoteCall sessions, the remote_objc API, a minimal tweak walkthrough, wiring into Settings, and porting from Theos/Substrate.


How tweaks work

Cyanide tweaks are app-side drivers. No SpringBoard dylibs, no Substrate hooks, no swizzled methods. The app reaches into the target from outside.

A RemoteCall session is the bridge. From inside one you send Objective-C messages, read and write memory, and call C symbols in the target process.

Settings holds the SpringBoard channel during Apply Tweaks. Your code runs inside it under settings_rc_lock(), via three entrypoints: apply_in_session, optional stop_in_session, and forget_remote_state.

The remote_objc API

Import remote_objc.h and ../TaskRop/RemoteCall.h. Helpers assume an active session — don’t call init_remote_call yourself unless you need a private channel.

remote_objc.h
r_class("UILabel")                  // remote Class *
r_sel("setHidden:")                 // remote SEL
r_msg2(obj, "setHidden:", 1,0,0,0)  // objc_msgSend in target
r_msg2_main(label, "setText:", text,
            0,0,0)                   // UIKit/main-thread send
r_msg2_main_raw(obj, "setFrame:",
  &rect, sizeof(rect), NULL,0,
  NULL,0, NULL,0)                    // pass a struct by value
r_msg2_main_struct_ret(obj, "bounds",
  &out, sizeof(out), NULL,0,
  NULL,0, NULL,0, NULL,0)            // copy a struct return

r_alloc_str("hi") / r_free(ptr)     // C string into remote
r_nsstr_retained("hi")              // NSString*, caller releases
r_cfstr("hi")                       // CFStringRef, caller CFReleases
r_settle_us(1000)                    // tune helper delay; restore old value

r_dlsym_call(R_TIMEOUT,
  "objc_setAssociatedObject",
  obj, key, val, policy, 0,0,0,0)    // any C function
r_is_objc_ptr(p)                     // sanity check
r_ivar_value(obj, "_name")          // read ivar
r_responds_main(obj, "sel:")        // -respondsToSelector:
remote_read / remote_write           // raw memory helpers
init_remote_call("SpringBoard", false)
destroy_remote_call()                // one-shot sessions only
abandon_remote_call()                // remote task is already gone

A minimal tweak

A complete RemoteCall-only tweak: paints an 80×80 red square on a SpringBoard window.

Idempotent on reapply, undoes itself on stop, drops cached pointers on respawn.

hello_tweak.h
#ifndef hello_tweak_h
#define hello_tweak_h
#include <stdbool.h>
bool hello_tweak_apply_in_session(void);
bool hello_tweak_stop_in_session(void);
void hello_tweak_forget_remote_state(void);
#endif
hello_tweak.m
#import "hello_tweak.h"
#import "remote_objc.h"
#import "../TaskRop/RemoteCall.h"
#import <stdint.h>

static const uint64_t kHelloTag = 0xC0A11DE;
static uint64_t gHelloView = 0;

static uint64_t hello_first_window(void) {
    uint64_t UIApplication = r_class("UIApplication");
    uint64_t app = r_msg2_main(UIApplication, "sharedApplication",
                               0, 0, 0, 0);
    if (!r_is_objc_ptr(app)) return 0;

    uint64_t keyWindow = r_msg2_main(app, "keyWindow", 0, 0, 0, 0);
    if (r_is_objc_ptr(keyWindow)) return keyWindow;

    uint64_t windows = r_msg2_main(app, "windows", 0, 0, 0, 0);
    uint64_t count = r_msg2_main(windows, "count", 0, 0, 0, 0);
    for (uint64_t i = 0; r_is_objc_ptr(windows) && i < count && i < 16; i++) {
        uint64_t window = r_msg2_main(windows, "objectAtIndex:", i, 0, 0, 0);
        if (r_is_objc_ptr(window)) return window;
    }
    return 0;
}

static uint64_t hello_existing_view(uint64_t window) {
    if (!r_is_objc_ptr(window)) return 0;
    uint64_t view = r_msg2_main(window, "viewWithTag:", kHelloTag, 0, 0, 0);
    if (r_is_objc_ptr(view)) gHelloView = view;
    return r_is_objc_ptr(view) ? view : 0;
}

bool hello_tweak_apply_in_session(void) {
    uint64_t window = hello_first_window();
    if (!r_is_objc_ptr(window)) return false;

    uint64_t existing = hello_existing_view(window);
    if (r_is_objc_ptr(existing)) {
        r_msg2_main(existing, "setHidden:", 0, 0, 0, 0);
        r_msg2_main(window, "bringSubviewToFront:", existing, 0, 0, 0);
        return true;
    }

    uint64_t UIView = r_class("UIView");
    uint64_t view = r_msg2_main(r_msg2_main(UIView, "alloc", 0, 0, 0, 0),
                                "init", 0, 0, 0, 0);
    if (!r_is_objc_ptr(view)) return false;

    struct { double x, y, w, h; } frame = { 40.0, 120.0, 80.0, 80.0 };
    r_msg2_main_raw(view, "setFrame:",
                    &frame, sizeof(frame),
                    NULL, 0, NULL, 0, NULL, 0);

    uint64_t UIColor = r_class("UIColor");
    uint64_t color = r_msg2_main(UIColor, "systemRedColor", 0, 0, 0, 0);
    if (!r_is_objc_ptr(color)) color = r_msg2_main(UIColor, "redColor", 0, 0, 0, 0);
    r_msg2_main(view, "setBackgroundColor:", color, 0, 0, 0);
    r_msg2_main(view, "setTag:", kHelloTag, 0, 0, 0);
    r_msg2_main(window, "addSubview:", view, 0, 0, 0);
    r_msg2_main(view, "release", 0, 0, 0, 0);

    gHelloView = view;
    return true;
}

bool hello_tweak_stop_in_session(void) {
    uint64_t window = hello_first_window();
    uint64_t view = hello_existing_view(window);
    if (!r_is_objc_ptr(view)) return false;

    r_msg2_main(view, "setHidden:", 1, 0, 0, 0);
    r_msg2_main(view, "removeFromSuperview", 0, 0, 0, 0);
    gHelloView = 0;
    return true;
}

void hello_tweak_forget_remote_state(void) {
    // SpringBoard respawned or RemoteCall was abandoned; cached
    // remote pointers are from the old address space.
    gHelloView = 0;
}

Wiring into Settings

SettingsViewController.m is the orchestrator. Add five things: a defaults key, a switch row, a Run-path apply, a live-apply branch, and forget_remote_state in cleanup.

Every apply checks g_springboard_rc_ready inside @synchronized(settings_rc_lock()). settings_mark_tweak_applied() keeps package state honest. forget_remote_state() runs on respring and abandon.

SettingsViewController.m
#import "tweaks/hello_tweak.h"
NSString * const kSettingsHelloEnabled = @"HelloEnabled";

// Add kSettingsHelloEnabled to settings_register_defaults(),
// settings_rc_backed_tweak_keys(), settings_key_affects_package_state(),
// and the Settings rows that render the switch.
static BOOL settings_key_is_hello(NSString *key) {
    return [key isEqualToString:kSettingsHelloEnabled];
}

// In the Run path, after settings_ensure_springboard_remote_call_locked():
if ([d boolForKey:kSettingsHelloEnabled]) {
    bool ok = hello_tweak_apply_in_session();
    settings_mark_tweak_applied(kSettingsHelloEnabled,
                                ok && [d boolForKey:kSettingsHelloEnabled]);
    printf("[SETTINGS] Hello result=%d\n", ok);
}

// In settings_schedule_live_apply_for_key():
if (settings_key_is_hello(key)) {
    if ([d boolForKey:kSettingsHelloEnabled] && g_springboard_rc_ready) {
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            @synchronized (settings_rc_lock()) {
                if (settings_cleanup_in_progress() || !g_springboard_rc_ready) return;
                bool ok = hello_tweak_apply_in_session();
                settings_mark_tweak_applied(kSettingsHelloEnabled,
                                            ok && [d boolForKey:kSettingsHelloEnabled]);
            }
            settings_notify_package_queue_changed_async();
        });
    } else if (![d boolForKey:kSettingsHelloEnabled]) {
        settings_mark_tweak_applied(kSettingsHelloEnabled, NO);
        settings_notify_package_queue_changed_async();
        if (g_springboard_rc_ready) dispatch_async(dispatch_get_global_queue(0, 0), ^{
            @synchronized (settings_rc_lock()) {
                if (g_springboard_rc_ready) hello_tweak_stop_in_session();
            }
        });
    }
    return;
}

// In SpringBoard restart/abandon and manual cleanup paths:
hello_tweak_forget_remote_state();

Porting from Theos / Substrate

RemoteCall isn’t a hook framework. You can’t intercept a method or replace a C function in place.

Ports work when the effect is a finite mutation — set this property, call this controller method, add this view, hold this assertion, or refresh on a timer.

Theos → RemoteCall
%hook UIView                         not portable as a hook
- (void)setHidden:(BOOL)h { ... }    rewrite as explicit
                                     r_msg2_main(view,
                                     "setHidden:", h,0,0,0)

[%c(Foo) bar]                        r_msg2(r_class("Foo"),
                                           "bar", 0,0,0,0)

struct { double x,y,w,h; } r = {0};  r_msg2_main_struct_ret(view,
                                     "bounds", &r, sizeof(r),
                                     NULL,0, NULL,0, NULL,0, NULL,0)

%new -[X cyanideOverlay]             associated object via
                                     objc_setAssociatedObject
                                     through r_dlsym_call

MSHookFunction(...)                  not available here

Targeting another process? Open a separate session with init_remote_call(name, false), do the work, destroy_remote_call before switching back. Powercuff does this for thermalmonitord.

Contribute

Build with ./scripts/build.sh — the IPA is packaged under build/. Sideload, test on device, attach Log-tab output to your PR.

Source and issues: github.com/zeroxjf/cyanide-ios