From 427045e634f50769f5bd637d5c59912c45890645 Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Mon, 27 Apr 2026 22:24:00 +0200 Subject: [PATCH] Package native mac app --- .gitignore | 1 + README.md | 5 +- START_HERE.md | 7 +- docs/CLI_REFERENCE.md | 2 + macos/BraiinsRatchet/README.md | 9 +- .../BraiinsRatchetMac/BraiinsRatchetApp.swift | 50 +++++- scripts/build_mac_app | 78 +++++++++ scripts/generate_mac_icon.py | 160 ++++++++++++++++++ scripts/ratchet | 10 ++ tests/test_mac_app.py | 38 +++++ 10 files changed, 343 insertions(+), 17 deletions(-) create mode 100755 scripts/build_mac_app create mode 100755 scripts/generate_mac_icon.py create mode 100644 tests/test_mac_app.py diff --git a/.gitignore b/.gitignore index b4bb73d..3541504 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ data/*.sqlite-wal data/raw/ *.log macos/BraiinsRatchet/.build/ +macos/build/ diff --git a/README.md b/README.md index 24c90f6..eb61019 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,11 @@ Close it only when finished: For the native macOS SwiftUI shell: ```bash -cd macos/BraiinsRatchet -swift run BraiinsRatchetMac +./scripts/ratchet app ``` +This builds `macos/build/Braiins Ratchet.app` and opens the real app bundle. Do not use `swift run` for normal operation. + For a 6-hour monitoring session: ```bash diff --git a/START_HERE.md b/START_HERE.md index 6032945..ef72993 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -132,13 +132,14 @@ The native SwiftUI shell is in: macos/BraiinsRatchet ``` -Run it from source: +Build and open the real app bundle: ```bash -cd macos/BraiinsRatchet -swift run BraiinsRatchetMac +./scripts/ratchet app ``` +This creates `macos/build/Braiins Ratchet.app`. After that, you can open that app bundle directly from Finder or pin it in the Dock. + The app is a native cockpit over the same durable Python lifecycle engine. The app includes controls to record and close manual exposure, but the same rule applies: it never places Braiins orders. diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 44ca19c..cf56d79 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -11,6 +11,8 @@ Most users should use the wrapper: Use `./scripts/ratchet raw-cycle` only when debugging the machine-readable cycle output. +Use `./scripts/ratchet app` to build and open the packaged native macOS app. It creates `macos/build/Braiins Ratchet.app`; `swift run` is only a developer fallback. + The raw Python CLI is documented below for debugging and development. All raw commands should be run from the repository root. diff --git a/macos/BraiinsRatchet/README.md b/macos/BraiinsRatchet/README.md index 221db36..d56feb3 100644 --- a/macos/BraiinsRatchet/README.md +++ b/macos/BraiinsRatchet/README.md @@ -4,13 +4,14 @@ Native SwiftUI shell for the durable Braiins Ratchet lifecycle engine. The Python supervisor remains the source of truth. This app reads the same repository-local SQLite state through `./scripts/ratchet`. -## Run From Source +## Normal Run ```bash -cd macos/BraiinsRatchet -swift run BraiinsRatchetMac +./scripts/ratchet app ``` +This builds `macos/build/Braiins Ratchet.app` and opens the packaged app. Use this path for normal operation. + ## Current Scope - Native macOS SwiftUI cockpit. @@ -21,4 +22,4 @@ swift run BraiinsRatchetMac ## Product Direction -The next production step is packaging this SwiftUI shell as a signed `.app` and wiring LaunchAgent controls for the durable supervisor. +The next production step is wiring LaunchAgent controls for the durable supervisor. diff --git a/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift b/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift index 3b210f5..59e5fa7 100644 --- a/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift +++ b/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift @@ -312,14 +312,21 @@ enum AppIconFactory { enum RatchetProcess { static func run(arguments: [String], input: String? = nil) async -> String { await Task.detached { - let packageRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - let repoRoot = packageRoot - .deletingLastPathComponent() - .deletingLastPathComponent() + guard let repoRoot = findRepoRoot() else { + return """ + Braiins Ratchet cannot find its repository. + + Expected to find: + scripts/ratchet + + Start the packaged app through: + ./scripts/ratchet app + + Or open this bundle from inside the BraiinsRatchet repository: + macos/build/Braiins Ratchet.app + """ + } + let script = repoRoot.appendingPathComponent("scripts/ratchet").path let process = Process() @@ -349,6 +356,33 @@ enum RatchetProcess { }.value } + private static func findRepoRoot() -> URL? { + let fileManager = FileManager.default + let candidates = [ + URL(fileURLWithPath: #filePath), + Bundle.main.bundleURL, + URL(fileURLWithPath: fileManager.currentDirectoryPath) + ] + + for candidate in candidates { + var current = candidate.hasDirectoryPath ? candidate : candidate.deletingLastPathComponent() + for _ in 0..<16 { + let script = current.appendingPathComponent("scripts/ratchet").path + if fileManager.isExecutableFile(atPath: script) { + return current + } + + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + break + } + current = parent + } + } + + return nil + } + private static func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } diff --git a/scripts/build_mac_app b/scripts/build_mac_app new file mode 100755 index 0000000..e15f872 --- /dev/null +++ b/scripts/build_mac_app @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PACKAGE_DIR="$ROOT_DIR/macos/BraiinsRatchet" +BUILD_DIR="$ROOT_DIR/macos/build" +APP_NAME="Braiins Ratchet.app" +APP_DIR="$BUILD_DIR/$APP_NAME" +CONTENTS_DIR="$APP_DIR/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +RESOURCES_DIR="$CONTENTS_DIR/Resources" +EXECUTABLE_NAME="BraiinsRatchetMac" +ICONSET_DIR="$BUILD_DIR/BraiinsRatchet.iconset" +ICNS_FILE="$RESOURCES_DIR/BraiinsRatchet.icns" + +mkdir -p "$BUILD_DIR/home" "$BUILD_DIR/clang-module-cache" +export HOME="$BUILD_DIR/home" +export CLANG_MODULE_CACHE_PATH="$BUILD_DIR/clang-module-cache" + +cd "$PACKAGE_DIR" +swift build -c release + +EXECUTABLE_PATH="$PACKAGE_DIR/.build/release/$EXECUTABLE_NAME" +if [[ ! -x "$EXECUTABLE_PATH" ]]; then + EXECUTABLE_PATH="$(find "$PACKAGE_DIR/.build" -path "*/release/$EXECUTABLE_NAME" -type f | head -n 1)" +fi + +if [[ -z "${EXECUTABLE_PATH:-}" || ! -x "$EXECUTABLE_PATH" ]]; then + echo "Could not find release executable $EXECUTABLE_NAME after swift build." >&2 + exit 1 +fi + +rm -rf "$APP_DIR" "$ICONSET_DIR" +mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" + +cp "$EXECUTABLE_PATH" "$MACOS_DIR/$EXECUTABLE_NAME" +chmod +x "$MACOS_DIR/$EXECUTABLE_NAME" + +python3 "$ROOT_DIR/scripts/generate_mac_icon.py" "$ICONSET_DIR" +iconutil -c icns "$ICONSET_DIR" -o "$ICNS_FILE" +rm -rf "$ICONSET_DIR" + +cat > "$CONTENTS_DIR/Info.plist" <<'PLIST' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Braiins Ratchet + CFBundleExecutable + BraiinsRatchetMac + CFBundleIconFile + BraiinsRatchet + CFBundleIdentifier + com.saymrwulf.BraiinsRatchet + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Braiins Ratchet + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 15.0 + NSHighResolutionCapable + + + +PLIST + +touch "$APP_DIR" + +echo "$APP_DIR" diff --git a/scripts/generate_mac_icon.py b/scripts/generate_mac_icon.py new file mode 100755 index 0000000..dab0551 --- /dev/null +++ b/scripts/generate_mac_icon.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Generate a simple PNG iconset using only the Python standard library.""" + +from __future__ import annotations + +import math +import struct +import sys +import zlib +from pathlib import Path + + +def clamp(value: float) -> int: + return max(0, min(255, int(round(value)))) + + +def blend(base: tuple[int, int, int, int], over: tuple[int, int, int, int]) -> tuple[int, int, int, int]: + br, bg, bb, ba = [channel / 255 for channel in base] + or_, og, ob, oa = [channel / 255 for channel in over] + out_a = oa + ba * (1 - oa) + if out_a == 0: + return 0, 0, 0, 0 + out_r = (or_ * oa + br * ba * (1 - oa)) / out_a + out_g = (og * oa + bg * ba * (1 - oa)) / out_a + out_b = (ob * oa + bb * ba * (1 - oa)) / out_a + return clamp(out_r * 255), clamp(out_g * 255), clamp(out_b * 255), clamp(out_a * 255) + + +def rounded_rect_mask(x: float, y: float, size: int, margin: float, radius: float) -> bool: + left = margin + right = size - margin + bottom = margin + top = size - margin + if x < left or x > right or y < bottom or y > top: + return False + + cx = left + radius if x < left + radius else right - radius if x > right - radius else x + cy = bottom + radius if y < bottom + radius else top - radius if y > top - radius else y + return (x - cx) ** 2 + (y - cy) ** 2 <= radius**2 + + +def polygon_contains(point: tuple[float, float], polygon: list[tuple[float, float]]) -> bool: + x, y = point + inside = False + previous_x, previous_y = polygon[-1] + for current_x, current_y in polygon: + intersects = (current_y > y) != (previous_y > y) + if intersects: + slope_x = (previous_x - current_x) * (y - current_y) / (previous_y - current_y) + current_x + if x < slope_x: + inside = not inside + previous_x, previous_y = current_x, current_y + return inside + + +def pixel(size: int, x: int, y: int) -> tuple[int, int, int, int]: + nx = x / max(size - 1, 1) + ny = y / max(size - 1, 1) + margin = size * 0.04 + radius = size * 0.22 + + if not rounded_rect_mask(x, y, size, margin, radius): + return 0, 0, 0, 0 + + top = (8, 24, 26) + mid = (10, 68, 66) + bottom = (140, 219, 128) + mix = (nx * 0.45) + (1 - ny) * 0.55 + if mix < 0.58: + local = mix / 0.58 + color = tuple(clamp(top[i] * (1 - local) + mid[i] * local) for i in range(3)) + else: + local = (mix - 0.58) / 0.42 + color = tuple(clamp(mid[i] * (1 - local) + bottom[i] * local) for i in range(3)) + + result = (*color, 255) + + cx = size * 0.5 + cy = size * 0.5 + distance = math.hypot(x - cx, y - cy) + if size * 0.23 <= distance <= size * 0.33: + result = blend(result, (186, 255, 199, 96)) + if distance < size * 0.21: + result = blend(result, (4, 18, 21, 208)) + + pick = [ + (size * 0.30, size * 0.30), + (size * 0.68, size * 0.70), + (size * 0.76, size * 0.62), + (size * 0.38, size * 0.22), + ] + if polygon_contains((x, y), pick): + result = blend(result, (255, 196, 89, 248)) + + spark = [ + (size * 0.55, size * 0.28), + (size * 0.62, size * 0.42), + (size * 0.76, size * 0.48), + (size * 0.62, size * 0.54), + (size * 0.55, size * 0.68), + (size * 0.48, size * 0.54), + (size * 0.34, size * 0.48), + (size * 0.48, size * 0.42), + ] + if polygon_contains((x, y), spark): + result = blend(result, (194, 255, 189, 242)) + + border = rounded_rect_mask(x, y, size, margin + size * 0.012, max(1, radius - size * 0.012)) + if not border: + result = blend(result, (255, 255, 255, 48)) + + return result + + +def write_png(path: Path, size: int) -> None: + rows = [] + for y in range(size): + row = bytearray([0]) + for x in range(size): + row.extend(pixel(size, x, size - 1 - y)) + rows.append(bytes(row)) + + raw = b"".join(rows) + + def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + + png = b"\x89PNG\r\n\x1a\n" + png += chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) + png += chunk(b"IDAT", zlib.compress(raw, 9)) + png += chunk(b"IEND", b"") + path.write_bytes(png) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: generate_mac_icon.py ICONSET_DIR", file=sys.stderr) + return 2 + + iconset = Path(sys.argv[1]) + iconset.mkdir(parents=True, exist_ok=True) + variants = { + "icon_16x16.png": 16, + "icon_16x16@2x.png": 32, + "icon_32x32.png": 32, + "icon_32x32@2x.png": 64, + "icon_128x128.png": 128, + "icon_128x128@2x.png": 256, + "icon_256x256.png": 256, + "icon_256x256@2x.png": 512, + "icon_512x512.png": 512, + "icon_512x512@2x.png": 1024, + } + for name, size in variants.items(): + write_png(iconset / name, size) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ratchet b/scripts/ratchet index 835f760..bb6d8a8 100755 --- a/scripts/ratchet +++ b/scripts/ratchet @@ -15,6 +15,7 @@ Commands: watch [hours] Run repeated monitor cycles for N hours. Default: 6. pipeline Propose timed automation, then ask yes/no. supervise Run the durable forever lifecycle supervisor. + app Build and open the native macOS app. position Record/list/close manually executed Braiins exposure. report Print the latest stored report without fetching new data. experiments Print the Karpathy-style experiment ledger. @@ -31,6 +32,7 @@ Examples: ./scripts/ratchet watch 6 ./scripts/ratchet pipeline ./scripts/ratchet supervise + ./scripts/ratchet app ./scripts/ratchet position list ./scripts/ratchet report ./scripts/ratchet experiments @@ -119,6 +121,13 @@ cmd_supervise() { run_python -m braiins_ratchet.cli supervise "$@" } +cmd_app() { + local app_path + app_path="$("$ROOT_DIR/scripts/build_mac_app" | tail -n 1)" + echo "Opening $app_path" + open "$app_path" +} + cmd_position() { run_python -m braiins_ratchet.cli position "$@" } @@ -165,6 +174,7 @@ main() { watch) cmd_watch "$@" ;; pipeline|auto) cmd_pipeline "$@" ;; supervise|daemon) cmd_supervise "$@" ;; + app|mac-app) cmd_app "$@" ;; position|positions) cmd_position "$@" ;; report) cmd_report "$@" ;; experiments) cmd_experiments "$@" ;; diff --git a/tests/test_mac_app.py b/tests/test_mac_app.py new file mode 100644 index 0000000..c79302c --- /dev/null +++ b/tests/test_mac_app.py @@ -0,0 +1,38 @@ +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class MacAppPackagingTest(unittest.TestCase): + def test_ratchet_wrapper_has_native_app_command(self): + wrapper = ROOT / "scripts" / "ratchet" + text = wrapper.read_text() + + self.assertIn("app|mac-app", text) + self.assertIn("cmd_app", text) + self.assertNotIn("swift run BraiinsRatchetMac", text) + + def test_mac_app_builder_creates_bundle_contract(self): + builder = ROOT / "scripts" / "build_mac_app" + text = builder.read_text() + + self.assertTrue(builder.stat().st_mode & 0o111) + self.assertIn("Braiins Ratchet.app", text) + self.assertIn("CFBundlePackageType", text) + self.assertIn("APPL", text) + self.assertIn("CFBundleIconFile", text) + self.assertIn("iconutil -c icns", text) + + def test_native_app_docs_use_packaged_launcher(self): + docs = [ + ROOT / "README.md", + ROOT / "START_HERE.md", + ROOT / "macos" / "BraiinsRatchet" / "README.md", + ] + + for path in docs: + text = path.read_text() + self.assertIn("./scripts/ratchet app", text) + self.assertNotIn("swift run BraiinsRatchetMac", text)