mirror of
https://github.com/saymrwulf/BraiinsRatchet.git
synced 2026-05-14 20:37:52 +00:00
Package native mac app
This commit is contained in:
parent
f6ffb2ffc4
commit
427045e634
10 changed files with 343 additions and 17 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,3 +9,4 @@ data/*.sqlite-wal
|
|||
data/raw/
|
||||
*.log
|
||||
macos/BraiinsRatchet/.build/
|
||||
macos/build/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
78
scripts/build_mac_app
Executable 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
160
scripts/generate_mac_icon.py
Executable 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())
|
||||
|
|
@ -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
38
tests/test_mac_app.py
Normal 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)
|
||||
Loading…
Reference in a new issue