Package native mac app

This commit is contained in:
saymrwulf 2026-04-27 22:24:00 +02:00
parent f6ffb2ffc4
commit 427045e634
10 changed files with 343 additions and 17 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ data/*.sqlite-wal
data/raw/
*.log
macos/BraiinsRatchet/.build/
macos/build/

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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: "'\\''") + "'"
}

78
scripts/build_mac_app Executable file
View file

@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Braiins Ratchet</string>
<key>CFBundleExecutable</key>
<string>BraiinsRatchetMac</string>
<key>CFBundleIconFile</key>
<string>BraiinsRatchet</string>
<key>CFBundleIdentifier</key>
<string>com.saymrwulf.BraiinsRatchet</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Braiins Ratchet</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST
touch "$APP_DIR"
echo "$APP_DIR"

160
scripts/generate_mac_icon.py Executable file
View file

@ -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())

View file

@ -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 "$@" ;;

38
tests/test_mac_app.py Normal file
View file

@ -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)