diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index c95d6e0..51bfe19 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -16,6 +16,18 @@ env: IOS_SDK_VERSION: "16.5" jobs: + guard-source-branch: + name: Guard master source branch + if: github.event_name == 'pull_request' && github.base_ref == 'master' + runs-on: ubuntu-latest + steps: + - name: Reject non-develop source for master PRs + run: | + if [ "${{ github.head_ref }}" != "develop" ]; then + echo "::error::PRs to master must come from the develop branch (got: ${{ github.head_ref }})" + exit 1 + fi + commitlint: name: Commit Lint if: github.event.action != 'closed' || github.event.pull_request.merged != true diff --git a/README.md b/README.md index eba77de..34026ae 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,19 @@ MSHookFunction is more flexible and easier to set up on a jailbroken device. Chi the rootless-jailbreak build (`libsubstrate`) and the sideload-injected build (`libdobby.a` statically linked). - `logging.h` / `logging.m` — `IPALog` + `IPALoggingInit`. Multiplexes - every log line to NSLog, `os_log` (subsystem-scoped), and an - append-only file inside the app sandbox. `IPA_LOG_TO_DOCUMENTS=1` at - build time routes the file destination to `/Documents/` so - Files.app can read it on a non-jailbroken device. + every log line to NSLog, `os_log` (subsystem-scoped), an append-only + file inside the app sandbox, and — when `FINAL_RELEASE` is not defined — + a LAN-visible TCP log stream on `0.0.0.0:18082`. `IPA_LOG_TO_DOCUMENTS=1` + at build time routes the file destination to `/Documents/` so + Files.app can read it on a non-jailbroken device. The TCP stream can be + tailed live from another machine on the same network with: + + ```sh + nc 18082 + ``` + + The LAN stream is debug-only and is compiled out entirely when + `FINAL_RELEASE=1`. - `chinlan.h` / `chinlan.m` — the core Chinlan helpers: - `IPAChinlanFindImage(name_substring)` walks `_dyld_image_count()` and returns the matching image's `mach_header` VA (call from a 1–2 s @@ -84,6 +93,27 @@ MSHookFunction is more flexible and easier to set up on a jailbroken device. Chi Pure runtime code. No Python tooling, no build scripts — the [IPA-Patch/Shared](https://github.com/IPA-Patch/Shared) repo holds those. +## Cave kinds + +Chinlan's runtime contract is shape-agnostic — `IPAChinlanResolveOrig` +only cares that the cave's last 8 bytes are +` + B `. Across IPA-Patch tweaks two +cave shapes have stabilised: + +- **`observer`** — peek before orig runs, then the cave executes orig + automatically. Cheap default for logging / state caching / one-way + observation. +- **`entry`** — REPLACE orig entirely. The hook receives pristine + `x0..x7`, decides whether and how to invoke orig (through the + cave-bypass entry exposed at `cave_va + 0x4C`), and the cave's RET + propagates the hook's `x0` straight back to the caller. Required when + you need to override the return value, substitute argument registers, + or hook a 7+ integer-arg function (observer caves clobber `W6`). + +[docs/CAVES.md](docs/CAVES.md) carries the full capability matrix, +annotated cave-byte layouts, and worked recipe / hook / dispatcher +examples for both kinds. + ## Usage Add as a git submodule inside `Sources/`: @@ -116,6 +146,7 @@ Include in source files: |---|---| | `IPA_JAILED` | `hookengine.h` uses Dobby (statically linked) instead of `libsubstrate`. | | `IPA_LOG_TO_DOCUMENTS` | `logging.m` writes the file log to `/Documents/.log` instead of `tmp/`. | +| `FINAL_RELEASE` | `logging.m` / `logserver.m` remove the LAN TCP log stream entirely; only NSLog, `os_log`, and the sandbox file sink remain. | ## License diff --git a/docs/CAVES.md b/docs/CAVES.md new file mode 100644 index 0000000..0324533 --- /dev/null +++ b/docs/CAVES.md @@ -0,0 +1,271 @@ +# Chinlan cave kinds + +Chinlan's runtime helpers (`chinlan.h` / `chinlan.m`) don't prescribe a +specific cave layout — they only own the slot-resolution + orig-trampoline +contract documented in `chinlan.h`. The cave bytes themselves are emitted +at patch time by each consumer's [Shared](https://github.com/IPA-Patch/Shared) +recipe, and across IPA-Patch tweaks two cave shapes have stabilised. This +page is the cross-tweak reference for picking the right kind and wiring +both sides correctly. + +> The 21-instruction / 84-byte envelope is a Bridge / KiouForge convention, +> not a Chinlan-runtime requirement. Other consumers can choose a different +> size as long as the cave's last 8 bytes stay ` +> + B ` so `IPAChinlanResolveOrig()` keeps working. + +## Kinds at a glance + +| Capability | `observer` | `entry` | +|---|---|---| +| Peek arguments before orig runs | ✅ | ✅ | +| Run orig automatically | ✅ (cave does it after the dispatcher returns) | ❌ (hook must call the cave-bypass entry itself) | +| Override the return value | ❌ (cave executes orig _after_ the hook) | ✅ (cave's tail is `RET`; the hook's `x0` propagates straight back) | +| Substitute argument registers | ❌ (cave restores `x0..x7` before `B orig+4`) | ✅ (cave passes pristine `x0..x7` through and never restores) | +| Hooks routed through a single shared dispatcher | ✅ (one slot, identified by `W6 = hook_id`) | ❌ (each site has its own slot under an entry-slot table) | +| `W6` (= 7th C arg) survives across the cave | ❌ (clobbered with `hook_id`) | ✅ (only `W9` is touched, and `x9–x15` are AAPCS64 call-clobbered scratch — never an argument slot) | +| Cave-bypass tail at `cave_va + 0x4C` still valid | ✅ | ✅ | + +### Decision flow + +Pick `observer` for almost everything. It's the cheap default — orig's +behavior is preserved byte-for-byte, the dispatcher only logs / latches +state, and you don't have to think about how to call orig back. + +Reach for `entry` only when one of these is true: + +- **You need to override orig's return value.** e.g. flipping + `AccountExists` to `false`, or rejecting a `MatchFound` reply. +- **You need orig to see different argument registers than the caller + passed in.** e.g. swapping a `deviceId` string before LoginArgs builds. +- **The hook target takes 7+ integer-class args** and `W6` carries real + data. Observer caves rewrite `W6` with the dispatcher hook id, so an + observer of a 7th-arg-bearing function reads garbage in the dispatcher + (and the cave restores W6 from the saved frame before `B orig+4`, so + orig itself still sees the right value — the dispatcher's view is the + one that's wrong). + +Anything else — single move observation, state machine peeks, side-effect +logging — stays `observer`. + +## Cave layouts + +Both caves are 21 instructions = 84 bytes, allocated contiguously from a +`CAVE_REGION_START` in `_SITES` order. The last two instructions are +identical (`displaced_insn` + `B orig+4`) so the cave-bypass entry at +`cave_va + 0x4C` works for both kinds — injection paths and entry hooks +use that to run orig without re-entering the cave. + +### `observer` + +```text +0x00 STP X29, X30, [SP, #-0x90]! ; save LR + reserve 0x90 of stack +0x04 STP X19, X20, [SP, #0x10] +0x08 STP X21, X22, [SP, #0x20] +0x0C STP X0, X1, [SP, #0x30] ; save x0..x7 so orig sees them +0x10 STP X2, X3, [SP, #0x40] +0x14 STP X4, X5, [SP, #0x50] +0x18 STP X6, X7, [SP, #0x60] +0x1C MOV X29, SP ; canonical frame setup +0x20 ADRP X16, page(HOOK_SLOT_RVA) +0x24 LDR X16, [X16, #lo12(HOOK_SLOT_RVA)] ; load dispatcher pointer +0x28 MOVZ W6, #hook_id ; pass hook id via W6 (clobbers arg #7!) +0x2C BLR X16 ; dispatcher(x0..x5, hook_id_in_w6, x7) +0x30 LDP X6, X7, [SP, #0x60] ; restore x0..x7 — orig must see originals +0x34 LDP X4, X5, [SP, #0x50] +0x38 LDP X2, X3, [SP, #0x40] +0x3C LDP X0, X1, [SP, #0x30] +0x40 LDP X21, X22, [SP, #0x20] +0x44 LDP X19, X20, [SP, #0x10] +0x48 LDP X29, X30, [SP], #0x90 ; tear down frame +0x4C ; orig's first 4 bytes, run verbatim +0x50 B ; continue into orig body +``` + +The dispatcher receives +`void dispatch(void *x0, void *x1, void *x2, void *x3, void *x4, + void *x5, uint32_t hook_id, void *x7)` — `x6` is sacrificed +to deliver `hook_id` even though `W6` is the 7th C integer arg under +AAPCS64. Hook bodies with up to six args are safe; anything more needs +`entry`. + +### `entry` + +```text +0x00 STP X29, X30, [SP, #-0x10]! ; minimal frame — no arg saving +0x04 ADRP X16, page(entry_slot_va) +0x08 LDR X16, [X16, #lo12(entry_slot_va)] ; load this site's hook fn ptr +0x0C MOVZ W9, #slot_index ; diagnostic; hook may ignore (W9 is AAPCS64 call-clobbered scratch, not an argument slot) +0x10 BLR X16 ; hook(x0..x7) — return ends up in x0 +0x14 LDP X29, X30, [SP], #0x10 +0x18 RET ; orig is NOT executed by the cave +0x1C NOP × 12 ; padding to keep tail at +0x4C +… +0x4C ; reachable only via the bypass entry +0x50 B ; (or as the cave-bypass trampoline) +``` + +The cave hands the hook pristine `x0..x7`. The hook is responsible for +running orig itself when it wants the original behavior — typically by +casting an entry in the consumer's bypass-entry table (already populated +with `cave_va + 0x4C`) and calling it as a function pointer. Whatever `x0` +the hook returns becomes the caller's return value because the cave's tail +is plain `RET`. + +`MOVZ W9, #slot_index` is debug-only: it lets you tell entry caves apart +in a register dump without touching any caller-supplied argument. Hooks +ignore it. + +## End-to-end examples + +The snippets below are abbreviated; for the real wiring see the consumer +tweaks ([Bridge](https://github.com/IPA-Patch/KiouEngineBridge), +[KiouForge](https://github.com/IPA-Patch/KiouForge)). + +### `observer` + +Useful when you want a free look at every call to a method. + +```python +# recipe (Python — Shared/tools) +_SITES = [ + # (rva, prologue_hex, hook_id_name, kind, label) + (0x5A2CD24, "ff4301d1", "HOOK_NOTIFY_PIECE_MOVED", CAVE_OBSERVER, + "GameStateStore.NotifyPieceMoved"), +] +``` + +```c +// Sources/Internal.h +enum hook_id { + HOOK_NOTIFY_PIECE_MOVED, + HOOK__COUNT, +}; +void HookNotifyPieceMovedObserve(void *self, uint32_t move, int32_t side); +``` + +```c +// Sources/Hook_BoardObserve.m +void HookNotifyPieceMovedObserve(void *self, uint32_t move, int32_t side) { + IPALog([NSString stringWithFormat: + @"[BOARD] notify self=%p move=0x%x side=%d", self, move, side]); + // No need to call orig — the cave will execute it after we return. +} +``` + +```c +// Sources/Dispatcher.m (one switch covering every observer site) +static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, + void *x5, uint32_t hook_id, void *x7) { + switch (hook_id) { + case HOOK_NOTIFY_PIECE_MOVED: + HookNotifyPieceMovedObserve(x0, (uint32_t)(uintptr_t)x1, + (int32_t)(intptr_t)x2); + break; + // … other observer cases … + } +} +``` + +### `entry` + +Useful when the hook decides whether and how orig runs, including +overriding its return. + +```python +# recipe +_SITES = [ + (0x591E860, "fd7bbfa9", "HOOK_ACCOUNT_EXISTS", CAVE_ENTRY, + "UserSaveDataExtensions.AccountExists"), +] + +_ENTRY_SLOT_INDEX = {"HOOK_ACCOUNT_EXISTS": 0} +ENTRY_SLOT_COUNT = 1 +ENTRY_SLOT_BASE_RVA = 0x8F90CD0 # contiguous bss tail +``` + +```c +// Sources/Internal.h +enum entry_slot_id { + ENTRY_SLOT_ACCOUNT_EXISTS = 0, + ENTRY_SLOT__COUNT, +}; +bool HookAccountExistsEntry(void *data); +``` + +```c +// Sources/Hook_AccountObserve.m +bool HookAccountExistsEntry(void *data) { + // 1. Pre-orig work (peek, log, decide on override). + bool forceRegister = SettingsForceRegisterArmed(); + + // 2. Run orig via the per-site cave-bypass entry. The consumer + // populates g_inject_entry[i] = cave_va + 0x4C at publish time. + typedef bool (*AccountExists_t)(void *); + AccountExists_t bypass = + (AccountExists_t)g_inject_entry[HOOK_ACCOUNT_EXISTS]; + bool origResult = bypass ? bypass(data) : false; + + // 3. Decide the actual return value the caller will see (x0 == this). + return forceRegister ? false : origResult; +} +``` + +```c +// Sources/Dispatcher.m +void PublishChinlanSlots(uintptr_t unityBase) { + // Observer dispatcher slot (one per consumer). + void *volatile *obs = (void * volatile *)(unityBase + HOOK_SLOT_RVA); + *obs = (void *)&dispatch_one; + + // Entry slot table — one slot per CAVE_ENTRY site. + void *volatile *ent = (void * volatile *)(unityBase + ENTRY_SLOT_BASE_RVA); + ent[ENTRY_SLOT_ACCOUNT_EXISTS] = (void *)&HookAccountExistsEntry; +} +``` + +### `entry` with argument substitution + +Same shape, but the hook rewrites an il2cpp string argument before +forwarding to orig. + +```c +void *HookLoginArgsCreateEntry(void *deviceId, void *distinctId) { + void *useDeviceId = deviceId; + NSString *pending = SettingsPendingDeviceId(); + if (pending.length > 0) { + void *swapped = il2cpp_string_new(pending.UTF8String); + if (swapped) useDeviceId = swapped; + } + typedef void *(*Create_t)(void *, void *); + Create_t bypass = (Create_t)g_inject_entry[HOOK_LOGIN_ARGS_CREATE]; + return bypass ? bypass(useDeviceId, distinctId) : NULL; +} +``` + +An `observer` here would fail silently: the cave restores `x0` (= +`deviceId`) from the saved frame before `B orig+4`, so the substituted +pointer is dropped on the way into orig. + +## Common pitfalls + +- **W6 clobber.** Picking `observer` for a 7-arg function corrupts the + dispatcher's view of the 7th argument. Use `entry` and avoid going + through the dispatcher at all. +- **Cave order = bypass-entry index.** Consumers compute the bypass entry + as `unityBase + CAVE_REGION_START + i * 84 + 0x4C`, where `i` is the + row's position in `_SITES`. Inserting / reordering rows shifts every + downstream bypass index. Append; don't rearrange. +- **Entry slot table must be in `__DATA,__bss`.** The cave's ADRP+LDR + resolves against the framework binary, not the dylib — the slot has to + live somewhere the framework's load address can reach by `ADRP/LDR`, + which means an `__bss` tail you've validated with the recipe's + `assert_slot_in_bss` helper. +- **`(void)useArg;` on JB.** Consumers that share their hook body across + JB and chinlan often have `KIOU_CALL_ORIG_RET(RET_T, ORIG, ...)` + expand to a no-op on chinlan (the cave runs orig itself). Without a + `(void)useArg;` or a `#if !KIOU_CHINLAN` wrapper, + `-Werror=unused-variable` will trip on the chinlan build. +- **`KIOU_CALL_ORIG_RET` drops varargs on chinlan.** Don't rely on it + to propagate substituted arguments into orig on the chinlan build — + for that case you have to call the bypass entry yourself (`entry` cave + pattern above). diff --git a/logging.h b/logging.h index b3d6563..74d0add 100644 --- a/logging.h +++ b/logging.h @@ -5,6 +5,14 @@ // =========================================================================== // logging.h — NSLog + os_log + sandbox file log destination. // +// In debug builds (i.e. when FINAL_RELEASE is not defined), logging.m also +// starts a LAN-visible TCP log stream on 0.0.0.0:18082. This lets jailed / +// non-SSH operators tail logs live from a PC on the same network with +// `nc 18082`, without changing the existing file destinations. + +// Because this is intentionally network-visible, FINAL_RELEASE removes the +// stream entirely and leaves only NSLog / os_log / sandbox-file logging. +// // Implementation in logging.m. Each tweak picks its own os_log subsystem at // init so console output stays distinguishable when several tweaks are // loaded into the same process. Typical subsystem strings follow diff --git a/logging.m b/logging.m index 6247045..1592b84 100644 --- a/logging.m +++ b/logging.m @@ -1,36 +1,131 @@ #import "logging.h" #import +#import +#ifndef FINAL_RELEASE +#import "logserver.h" +#endif // =========================================================================== // logging.m — implementation backing logging.h. // -// Three destinations on every IPALog(): +// Three destinations on every IPALog(), plus a fourth in debug builds: // * NSLog — Console.app, always on // * os_log — unified logging, subsystem-scoped // * g_logSandbox file — append-only file inside the host app's sandbox +// * LAN TCP — 0.0.0.0:18082 when FINAL_RELEASE is undefined + +// The LAN stream exists so operators can tail logs from a PC on the same +// network. Debug builds only; FINAL_RELEASE strips the sink back out. + +// The TCP stream is implemented in logserver.m so this file stays the +// shared logging facade rather than becoming a socket server itself. +// +// File destination layout — every flavor writes into a `Logs/` subdirectory +// of its base path (created on init), so operators can grab the whole +// directory at once instead of fishing individual files out of tmp/ or +// Documents/. The directory name follows iOS's own sandbox PascalCase +// convention (sibling to `Documents/`, `Library/`). The default base is +// NSTemporaryDirectory() (which resolves to +// /var/mobile/Containers/Data/Application//tmp/), so the full path is +// .../tmp/Logs/.log // -// File destination defaults to NSTemporaryDirectory()/.log, which -// resolves to /var/mobile/Containers/Data/Application//tmp/.log. +// When IPA_LOG_TO_DOCUMENTS=1 is defined at build time, the base moves to +// /Documents/ instead. That directory is exposed through Files.app +// once the host app's Info.plist carries UIFileSharingEnabled + +// LSSupportsOpeningDocumentsInPlace — typical for the statically-patched / +// sideload-injected distribution flavor where the operator has no SSH +// access. The same log can then be read over the Files app on a +// non-jailbroken device. // -// When IPA_LOG_TO_DOCUMENTS=1 is defined at build time, the file destination -// is moved to /Documents/.log instead. That directory is -// exposed through Files.app once the host app's Info.plist carries -// UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace — typical for -// the statically-patched / sideload-injected distribution flavor where the -// operator has no SSH access. The same log can then be read over the -// Files app on a non-jailbroken device. +// Rotation — to keep a runaway process from filling the sandbox with one +// multi-GB log file, the file destination rotates once it crosses +// IPA_LOG_MAX_BYTES (default 4 MiB): +// +// .log -> .1.log +// .1.log -> .2.log +// ... +// .{N-1}.log -> dropped +// +// where N = IPA_LOG_GENERATIONS (default 3). The size check is sampled +// every IPA_LOG_ROTATE_CHECK_EVERY writes (default 64) — exact bytes-on- +// disk control is intentionally relaxed so per-line stat() cost stays +// bounded. A stray double-rotate just leaves an empty .1.log instead of +// corrupting data. // // The sandbox file write is best-effort and silently swallows exceptions // so a flaky filesystem can't take down the host process. // =========================================================================== -static os_log_t g_log = NULL; -static NSString *g_logSandbox = nil; -static NSString *g_tag = @"tweak"; +#ifndef IPA_LOG_MAX_BYTES +#define IPA_LOG_MAX_BYTES (4 * 1024 * 1024) // 4 MiB per file +#endif +#ifndef IPA_LOG_GENERATIONS +#define IPA_LOG_GENERATIONS 3 // .log + .1.log + .2.log +#endif +#ifndef IPA_LOG_ROTATE_CHECK_EVERY +#define IPA_LOG_ROTATE_CHECK_EVERY 64 // stat() every N writes +#endif + +static os_log_t g_log = NULL; +static NSString *g_logSandbox = nil; +static NSString *g_tag = @"tweak"; +static atomic_uint g_writeCount __attribute__((unused)) = 0; + +// Slide .{N-2}.log → .{N-1}.log, ..., .log → .1.log, +// dropping anything past .{N-1}.log. Caller is the rare sampled +// rotation tick, so no explicit lock is needed. +static void IPALogRotate(NSString *path) { + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *dir = [path stringByDeletingLastPathComponent]; + NSString *file = [path lastPathComponent]; + NSString *stem = [file stringByDeletingPathExtension]; // "" + NSString *ext = [file pathExtension]; // "log" + int gens = IPA_LOG_GENERATIONS; + if (gens < 2) return; // nothing to rotate into + + @try { + // Drop the oldest generation (gens-1). + NSString *drop = [dir stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.%d.%@", stem, gens - 1, ext]]; + [fm removeItemAtPath:drop error:nil]; + + // Shift gens-2 .. 1 each down one slot. + for (int i = gens - 2; i >= 1; i--) { + NSString *src = [dir stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.%d.%@", stem, i, ext]]; + NSString *dst = [dir stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.%d.%@", stem, i + 1, ext]]; + if ([fm fileExistsAtPath:src]) + [fm moveItemAtPath:src toPath:dst error:nil]; + } + + // Current .log → .1.log. + NSString *first = [dir stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.1.%@", stem, ext]]; + [fm moveItemAtPath:path toPath:first error:nil]; + } @catch (NSException *e) {} +} + +// Check size every IPA_LOG_ROTATE_CHECK_EVERY writes and rotate if we're +// past IPA_LOG_MAX_BYTES. Sampling avoids a stat() per IPALog while still +// catching runaway logs within ~64 lines of the limit. +static void IPAMaybeRotate(NSString *path) { + unsigned n = atomic_fetch_add_explicit(&g_writeCount, 1, memory_order_relaxed); + if ((n % IPA_LOG_ROTATE_CHECK_EVERY) != 0) return; + + NSDictionary *attrs = + [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; + if (!attrs) return; + unsigned long long size = [attrs fileSize]; + if (size < (unsigned long long)IPA_LOG_MAX_BYTES) return; + + IPALogRotate(path); +} static void IPALogPath(NSString *path, NSString *msg) { if (!path) return; @try { + IPAMaybeRotate(path); NSDateFormatter *df = [[NSDateFormatter alloc] init]; df.dateFormat = @"HH:mm:ss.SSS"; NSString *line = [NSString stringWithFormat:@"%@ %@\n", @@ -53,6 +148,9 @@ void IPALog(NSString *msg) { os_log(g_log, "%{public}s", msg.UTF8String); } if (g_logSandbox) IPALogPath(g_logSandbox, msg); +#ifndef FINAL_RELEASE + IPALogServerPush(msg); +#endif } void IPALoggingInit(const char *subsystem) { @@ -71,20 +169,46 @@ void IPALoggingInit(const char *subsystem) { } NSString *filename = [g_tag stringByAppendingString:@".log"]; +#ifndef FINAL_RELEASE + IPALogServerStart(IPA_LOG_SERVER_DEFAULT_PORT); +#endif + + // Pick the base directory. Documents/ on the IPA-distribution flavor so + // Files.app can read it; tmp/ everywhere else. #if defined(IPA_LOG_TO_DOCUMENTS) && IPA_LOG_TO_DOCUMENTS - // Files.app-exposed log path. Used by the statically-patched build so - // operators on non-jailbroken devices can read the log without SSH. NSArray *docs = NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, YES); - if (docs.count > 0) { - g_logSandbox = [docs[0] stringByAppendingPathComponent:filename]; - } else { - // Fallback to tmp/ if NSDocumentDirectory resolves to nothing. - g_logSandbox = [NSTemporaryDirectory() - stringByAppendingPathComponent:filename]; - } + NSString *base = (docs.count > 0) + ? docs[0] + : NSTemporaryDirectory(); #else - g_logSandbox = [NSTemporaryDirectory() - stringByAppendingPathComponent:filename]; + NSString *base = NSTemporaryDirectory(); +#endif + + // Group every flavor's logs under `/Logs/` so the rotated set + // (.log, .1.log, .2.log…) lives in one folder operators + // can grab in one shot. PascalCase matches iOS's own sandbox + // conventions (`Documents/`, `Library/`, etc.). Best-effort mkdir — + // a stale symlink or perms issue downgrades us to writing into the + // base directly. + NSString *logsDir = [base stringByAppendingPathComponent:@"Logs"]; + NSFileManager *fm = [NSFileManager defaultManager]; + NSError *mkdirErr = nil; + BOOL ok = [fm createDirectoryAtPath:logsDir + withIntermediateDirectories:YES + attributes:nil + error:&mkdirErr]; + if (!ok) { + BOOL isDir = NO; + if (![fm fileExistsAtPath:logsDir isDirectory:&isDir] || !isDir) { + logsDir = base; // fall back to the parent + } + } + + g_logSandbox = [logsDir stringByAppendingPathComponent:filename]; +#ifndef FINAL_RELEASE + // Let the log server replay the tail of this file to clients that connect + // after launch — they see history instead of only the live stream. + IPALogServerSetReplayPath(g_logSandbox); #endif } diff --git a/logserver.h b/logserver.h new file mode 100644 index 0000000..d5766e2 --- /dev/null +++ b/logserver.h @@ -0,0 +1,49 @@ +#pragma once + +#import +#include + +#ifndef FINAL_RELEASE + +// =========================================================================== +// logserver.h — debug-only TCP log streaming server (Chinlan layer). +// +// Automatically started by IPALoggingInit() on the default port when +// FINAL_RELEASE is not defined. Every IPALog() call is forwarded to all +// connected clients, so logs can be tailed from any terminal with: +// +// nc 18082 +// +// No SSH, no Files.app, no jailbreak required — works on any Jailed build +// from a PC on the same network. +// Up to IPA_LOG_SERVER_MAX_CLIENTS simultaneous readers are supported; +// a new connection beyond that cap is silently dropped. +// +// Compile-time behaviour: +// FINAL_RELEASE undefined → server starts, push is live +// FINAL_RELEASE=1 → this header is empty; logserver.m compiles +// to nothing; zero overhead in the binary +// +// Called by logging.m; consumers do not call these directly. +// =========================================================================== + +#define IPA_LOG_SERVER_DEFAULT_PORT 18082 +#define IPA_LOG_SERVER_MAX_CLIENTS 4 + +// Start listening on 0.0.0.0:. Safe to call multiple times; +// subsequent calls after the first are silent no-ops. +void IPALogServerStart(uint16_t port); + +// Register the sandbox log file path for replay-on-connect. +// Must be called after IPALogServerStart(). When a new client connects, +// the server tails the last IPA_LOG_REPLAY_BYTES (default 100 KB) of this +// file and sends them before switching to the live stream. Safe to call +// with nil to disable replay. +void IPALogServerSetReplayPath(NSString *path); + +// Broadcast a log line to all connected clients. Called from IPALog(). +// line must be non-nil; the implementation appends a trailing newline if +// absent. Returns immediately when no clients are connected. +void IPALogServerPush(NSString *line); + +#endif // !FINAL_RELEASE diff --git a/logserver.m b/logserver.m new file mode 100644 index 0000000..917be25 --- /dev/null +++ b/logserver.m @@ -0,0 +1,295 @@ +#import "logserver.h" + +#ifndef FINAL_RELEASE + +#import "logging.h" + +#import +#import +#import +#import +#import +#import +#import +#import + +// =========================================================================== +// logserver.m — debug-only TCP log streaming server. +// +// Architecture mirrors Server_CSA.m but fans out to up to +// IPA_LOG_SERVER_MAX_CLIENTS simultaneous clients instead of one. The +// tradeoff vs. CSA's single-client design is intentional: for log reading +// you often want two terminals open at once (one raw, one grepped). +// +// Transport: +// - Binds to 0.0.0.0 so operators can tail from a PC on the same LAN. +// - Debug-build only; FINAL_RELEASE compiles this file down to nothing. +// - Line-oriented UTF-8. Each IPALogServerPush() sends one LF-terminated +// line to every client whose fd is live. +// - A new client beyond MAX_CLIENTS is closed immediately; the caller +// is never blocked. +// - Dead clients are detected on send failure and evicted inline. +// - A soft queue-depth cap (LOG_SERVER_QUEUE_DEPTH_MAX) prevents a +// slow reader from building up unbounded backlog; lines are dropped +// with a counter rather than blocking the IPALog() call chain. +// +// All operations that touch g_clients[] run on g_queue (serial), so no +// locking primitives beyond the atomic client-count are needed. +// =========================================================================== + +#define LOG_SERVER_RECV_CHUNK 256 +#define LOG_SERVER_QUEUE_DEPTH_MAX 256 + +// How many bytes from the end of the log file to replay to a new client. +// Aligned up to the next newline boundary so no partial lines are sent. +#ifndef IPA_LOG_REPLAY_BYTES +#define IPA_LOG_REPLAY_BYTES (100 * 1024) +#endif + +// --------------------------------------------------------------------------- +// Module state. +// --------------------------------------------------------------------------- +static dispatch_queue_t g_queue = NULL; +static dispatch_source_t g_listenSrc = NULL; +static int g_listenFd = -1; + +// Client fd array and atomic drop counter, both accessed on g_queue. +static int g_clients[IPA_LOG_SERVER_MAX_CLIENTS]; +static int g_clientCount = 0; +static _Atomic uint32_t g_dropped = 0; +static _Atomic uint32_t g_pending = 0; + +// Log file path for replay-on-connect. Set via IPALogServerSetReplayPath(). +// Accessed on g_queue only. +static NSString *g_replayPath = nil; + +// --------------------------------------------------------------------------- +// Socket helpers. +// --------------------------------------------------------------------------- +static void ls_set_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0) fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +static void ls_set_keepalive(int fd) { + int on = 1; + (void)setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); + int idle = 5, intvl = 3, count = 3; + (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPALIVE, &idle, sizeof(idle)); + (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl)); + (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); +} + +// Send all bytes to fd. Returns NO and closes fd on any error. +static BOOL ls_send_all(int fd, const uint8_t *buf, size_t len) { + size_t off = 0; + while (off < len) { + ssize_t n = send(fd, buf + off, len - off, 0); + if (n < 0) { + if (errno == EINTR) continue; + return NO; + } + if (n == 0) return NO; + off += (size_t)n; + } + return YES; +} + +// --------------------------------------------------------------------------- +// Replay history — send the tail of the log file to a freshly connected +// client, then return so the caller can add it to the live-stream set. +// Runs on g_queue so g_replayPath access is safe. +// --------------------------------------------------------------------------- +static void ls_replay_to(int fd) { + if (!g_replayPath) return; + + int fileFd = open(g_replayPath.UTF8String, O_RDONLY | O_NONBLOCK); + if (fileFd < 0) return; + + off_t fileSize = lseek(fileFd, 0, SEEK_END); + if (fileSize <= 0) { close(fileFd); return; } + + // Start IPA_LOG_REPLAY_BYTES before EOF (or at BOF if file is smaller). + off_t startOff = (fileSize > (off_t)IPA_LOG_REPLAY_BYTES) + ? fileSize - (off_t)IPA_LOG_REPLAY_BYTES + : 0; + + if (lseek(fileFd, startOff, SEEK_SET) < 0) { close(fileFd); return; } + + // Skip forward to the next newline so we don't send a partial first line. + if (startOff > 0) { + char ch; + while (read(fileFd, &ch, 1) == 1 && ch != '\n') {} + } + + // Stream the rest to the client in chunks. + uint8_t buf[4096]; + ssize_t n; + while ((n = read(fileFd, buf, sizeof(buf))) > 0) { + const uint8_t *p = buf; + size_t rem = (size_t)n; + while (rem > 0) { + ssize_t sent = send(fd, p, rem, 0); + if (sent <= 0) { close(fileFd); return; } + p += sent; + rem -= (size_t)sent; + } + } + close(fileFd); +} + +// --------------------------------------------------------------------------- +// Client management — must be called on g_queue. +// --------------------------------------------------------------------------- +static void ls_add_client(int fd) { + if (g_clientCount >= IPA_LOG_SERVER_MAX_CLIENTS) { + IPALog([NSString stringWithFormat: + @"[LOGSVR] max clients (%d) reached, dropping fd=%d", + IPA_LOG_SERVER_MAX_CLIENTS, fd]); + close(fd); + return; + } + // Replay history before admitting to the live stream so the client sees + // events that happened before it connected. + ls_replay_to(fd); + g_clients[g_clientCount++] = fd; + IPALog([NSString stringWithFormat: + @"[LOGSVR] client connected fd=%d (%d/%d)", + fd, g_clientCount, IPA_LOG_SERVER_MAX_CLIENTS]); +} + +// Evict the client at index i, compacting the array. Called on g_queue. +static void ls_evict_at(int i) { + int fd = g_clients[i]; + close(fd); + IPALog([NSString stringWithFormat:@"[LOGSVR] client disconnected fd=%d", fd]); + // Compact: move the last entry into the vacated slot. + g_clients[i] = g_clients[--g_clientCount]; +} + +// --------------------------------------------------------------------------- +// Drain inbound bytes from a client. We don't speak any protocol so we +// discard everything, but we need to detect EOF/RST to evict the client. +// Returns NO if the client has gone away. +// --------------------------------------------------------------------------- +static BOOL ls_drain_client(int fd) { + uint8_t buf[LOG_SERVER_RECV_CHUNK]; + ssize_t n = recv(fd, buf, sizeof(buf), MSG_DONTWAIT); + if (n > 0) return YES; // data discarded + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return YES; + return NO; // EOF or error — evict +} + +// --------------------------------------------------------------------------- +// accept() handler — fires on g_queue via dispatch source. +// --------------------------------------------------------------------------- +static void ls_handle_accept(void) { + struct sockaddr_in peer; + socklen_t peerLen = sizeof(peer); + int fd = accept(g_listenFd, (struct sockaddr *)&peer, &peerLen); + if (fd < 0) { + if (errno != EAGAIN && errno != EWOULDBLOCK) { + IPALog([NSString stringWithFormat: + @"[LOGSVR] accept errno=%d", errno]); + } + return; + } + + // Flip to non-blocking so push doesn't stall on a slow reader. + ls_set_nonblock(fd); + ls_set_keepalive(fd); + ls_add_client(fd); +} + +// --------------------------------------------------------------------------- +// Public API. +// --------------------------------------------------------------------------- +void IPALogServerSetReplayPath(NSString *path) { + if (!g_queue) return; + dispatch_async(g_queue, ^{ g_replayPath = [path copy]; }); +} + +void IPALogServerStart(uint16_t port) { + if (g_listenFd >= 0) return; // already running + + g_queue = dispatch_queue_create("io.kiou.logserver", DISPATCH_QUEUE_SERIAL); + + int s = socket(AF_INET, SOCK_STREAM, 0); + if (s < 0) { + IPALog([NSString stringWithFormat:@"[LOGSVR] socket errno=%d", errno]); + return; + } + + int one = 1; + setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_ANY); // LAN-visible debug stream + if (bind(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + IPALog([NSString stringWithFormat:@"[LOGSVR] bind errno=%d port=%u", + errno, (unsigned)port]); + close(s); + return; + } + if (listen(s, IPA_LOG_SERVER_MAX_CLIENTS) < 0) { + IPALog([NSString stringWithFormat:@"[LOGSVR] listen errno=%d", errno]); + close(s); + return; + } + ls_set_nonblock(s); + g_listenFd = s; + + g_listenSrc = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, + (uintptr_t)s, 0, g_queue); + dispatch_source_set_event_handler(g_listenSrc, ^{ ls_handle_accept(); }); + dispatch_resume(g_listenSrc); + + IPALog([NSString stringWithFormat: + @"[LOGSVR] listening on 0.0.0.0:%u (debug build)", (unsigned)port]); +} + +void IPALogServerPush(NSString *line) { + if (!line || !g_queue) return; + + uint32_t pending = atomic_load(&g_pending); + if (pending >= LOG_SERVER_QUEUE_DEPTH_MAX) { + uint32_t dropped = atomic_fetch_add(&g_dropped, 1) + 1; + if ((dropped % 64) == 0) { + // Can't call IPALog here (would recurse); use NSLog directly. + NSLog(@"[LOGSVR] drop backlog=%u dropped=%u", pending, dropped); + } + return; + } + + atomic_fetch_add(&g_pending, 1); + NSString *withNewline = [line hasSuffix:@"\n"] + ? [line copy] + : [line stringByAppendingString:@"\n"]; + + dispatch_async(g_queue, ^{ + atomic_fetch_sub(&g_pending, 1); + if (g_clientCount == 0) return; + + NSData *data = [withNewline dataUsingEncoding:NSUTF8StringEncoding]; + const uint8_t *bytes = (const uint8_t *)data.bytes; + size_t len = data.length; + + // Drain inbound bytes first to catch dead peers before we try to send. + for (int i = g_clientCount - 1; i >= 0; i--) { + if (!ls_drain_client(g_clients[i])) { + ls_evict_at(i); + } + } + + // Broadcast to surviving clients; evict on send failure. + for (int i = g_clientCount - 1; i >= 0; i--) { + if (!ls_send_all(g_clients[i], bytes, len)) { + ls_evict_at(i); + } + } + }); +} + +#endif // !FINAL_RELEASE