diff --git a/.gitignore b/.gitignore index cc8b2a28..ffd26e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ __pycache__/ *.egg-info/ dist/ build/ +!mac/build/ +mac/launcher/.build/ *.db .superpowers/ .worktrees/ diff --git a/mac/README.md b/mac/README.md new file mode 100644 index 00000000..b246ae90 --- /dev/null +++ b/mac/README.md @@ -0,0 +1,17 @@ +# taOS Mac App build + +Build pipeline for the macOS `.app` bundle (Apple Silicon, macOS 26+). + +See `docs/superpowers/specs/2026-04-28-taos-mac-app-c1-design.md` for the full design. + +## Quick start + + ./mac/build/build.sh --version 0.1.0 --output dist/ + +Produces `dist/taOS.app`, `dist/taOS-0.1.0.dmg`, and `dist/taOS-0.1.0.dmg.sig`. + +## Layout + +- `launcher/` — Swift Package for the native launcher +- `build/` — shell pipeline (orchestrator + step scripts) +- `appcast/` — Sparkle appcast.xml + EdDSA public key diff --git a/mac/appcast/appcast.xml b/mac/appcast/appcast.xml new file mode 100644 index 00000000..f7b9f8f4 --- /dev/null +++ b/mac/appcast/appcast.xml @@ -0,0 +1,9 @@ + + + + taOS Updates + https://taos.app/appcast.xml + Sparkle feed for taOS + en + + diff --git a/mac/build/RELEASE_TESTING.md b/mac/build/RELEASE_TESTING.md new file mode 100644 index 00000000..1b789b48 --- /dev/null +++ b/mac/build/RELEASE_TESTING.md @@ -0,0 +1,61 @@ +# taOS Mac App — Release Testing Checklist + +Run on a fresh macOS 26 install (or a wiped data dir) before tagging a release. + +## 1. Fresh install + +- [ ] Open `taOS-X.Y.Z.dmg` → drag to Applications. +- [ ] Right-click `/Applications/taOS.app` → Open → confirm "open" in Gatekeeper dialog. +- [ ] Status-bar `taOS` icon appears. +- [ ] First-run wizard opens in **phone-shape mode**. +- [ ] Setup completes; Dock icon disappears after closing the window. + +## 2. Container creation + +- [ ] Create a project, add an agent. +- [ ] Open Terminal: `container ls` shows the new container. +- [ ] Send chat → response received. +- [ ] Trace store entry shows `host.containers.internal:4000` reachable. + +## 3. Window-mode toggle + +- [ ] `Cmd+Shift+M` switches phone ↔ fullscreen. +- [ ] In fullscreen, `Cmd+Q` is intercepted (no quit). +- [ ] In fullscreen, `Ctrl+→` moves to the next Space. +- [ ] In fullscreen, three-finger swipe moves Spaces. + +## 4. Quit + relaunch + +- [ ] Close window with the red traffic light. +- [ ] Dock icon hides; status-bar icon stays. +- [ ] `~/Library/Logs/taOS/server.log` keeps writing. +- [ ] "Quit taOS" from status bar → graceful shutdown logged → process gone. +- [ ] Relaunch → restores last window mode + last project. + +## 5. Update path + +- [ ] Stage a fake v0.1.1 DMG. +- [ ] Serve a local appcast: `cd staging && python -m http.server 8000`. +- [ ] In test build, set `SUFeedURL` to `http://127.0.0.1:8000/appcast.xml`. +- [ ] Sparkle prompts → EdDSA verifies → relaunches at v0.1.1. +- [ ] Re-run with a tampered DMG → install refused; `~/Library/Logs/taOS/sparkle.log` has a verification-failed entry; app stays on v0.1.0. + +## 6. Failure isolation + +- [ ] Kill FastAPI from Activity Monitor → status-bar icon goes yellow → "Restart Server" works. +- [ ] Stop the container daemon → existing containers listed but new creates fail with the documented note → restart daemon → recovers. +- [ ] Disconnect network → Sparkle silently skips next scheduled check. + +## 7. Uninstall + +- [ ] Trash `/Applications/taOS.app`. +- [ ] `~/Library/Application Support/taOS/` untouched. +- [ ] Reinstall the same DMG → no setup wizard, picks up where it left off. +- [ ] `brew uninstall --cask --zap taos` (when Cask exists) wipes the four `~/Library/...` dirs. + +## Sign-off + +Tester: ______________ Date: ______________ +Version: ______________ All boxes checked: yes / no + +Failures: file as GitHub issues with `mac-release` label and block release until resolved. diff --git a/mac/build/assemble_bundle.sh b/mac/build/assemble_bundle.sh new file mode 100755 index 00000000..06b2bc8f --- /dev/null +++ b/mac/build/assemble_bundle.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Build taOS.app/Contents/ from staging dirs. +# +# Args: --version --staging --launcher-binary --output +set -euo pipefail + +VERSION="" +STAGING="" +LAUNCHER_BINARY="" +OUTPUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --staging) STAGING="$2"; shift 2 ;; + --launcher-binary) LAUNCHER_BINARY="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + *) echo "assemble_bundle.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$VERSION" && -n "$STAGING" && -n "$LAUNCHER_BINARY" && -n "$OUTPUT" ]] \ + || { echo "all args required" >&2; exit 2; } + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +APP="$OUTPUT/taOS.app" +CONTENTS="$APP/Contents" + +rm -rf "$APP" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$CONTENTS/Frameworks" + +# Info.plist +ED_KEY_FILE="$REPO_ROOT/mac/appcast/ed_public.pem" +if [[ -f "$ED_KEY_FILE" ]]; then + SU_PUBLIC_ED_KEY="$(grep -v '^-----' "$ED_KEY_FILE" | tr -d '\n')" +else + echo "[assemble_bundle] no ed_public.pem — Sparkle will be disabled in this build" + SU_PUBLIC_ED_KEY="" +fi +sed -e "s|\${VERSION}|$VERSION|g" \ + -e "s|\${SU_PUBLIC_ED_KEY}|$SU_PUBLIC_ED_KEY|g" \ + "$REPO_ROOT/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in" \ + > "$CONTENTS/Info.plist" + +echo -n "APPL????" > "$CONTENTS/PkgInfo" + +# Launcher binary +cp "$LAUNCHER_BINARY" "$CONTENTS/MacOS/taOS" +chmod +x "$CONTENTS/MacOS/taOS" + +# Python distribution +cp -R "$STAGING/python" "$CONTENTS/Resources/python" + +# taOS source tree +mkdir -p "$CONTENTS/Resources/taos" +cp -R "$REPO_ROOT/tinyagentos" "$CONTENTS/Resources/taos/tinyagentos" +find "$CONTENTS/Resources/taos" -type d -name __pycache__ -exec rm -rf {} + +find "$CONTENTS/Resources/taos" -type f -name "*.pyc" -delete +cp "$REPO_ROOT/pyproject.toml" "$CONTENTS/Resources/taos/pyproject.toml" + +# Bundle the data/ skeleton (config example, seed agents, templates) so the +# launcher can copy it into ~/Library/Application Support/taOS on first run. +if [[ -d "$REPO_ROOT/data" ]]; then + cp -R "$REPO_ROOT/data" "$CONTENTS/Resources/taos/data" +fi +# Bundle the app-catalog so backend auto-registration can find service manifests. +if [[ -d "$REPO_ROOT/app-catalog" ]]; then + cp -R "$REPO_ROOT/app-catalog" "$CONTENTS/Resources/taos/app-catalog" +fi + +# Frontend +cp -R "$STAGING/frontend" "$CONTENTS/Resources/frontend" + +# Apple container CLI + libexec plugins (image/network/runtime) +mkdir -p "$CONTENTS/Resources/bin" +cp "$STAGING/bin/container" "$CONTENTS/Resources/bin/container" +chmod +x "$CONTENTS/Resources/bin/container" +if [[ -d "$STAGING/libexec/container" ]]; then + mkdir -p "$CONTENTS/Resources/libexec" + cp -R "$STAGING/libexec/container" "$CONTENTS/Resources/libexec/container" +fi + +# Sparkle.framework — fetched/extracted by build.sh prior +if [[ -d "$STAGING/Sparkle.framework" ]]; then + cp -R "$STAGING/Sparkle.framework" "$CONTENTS/Frameworks/Sparkle.framework" +fi + +# AppIcon +if [[ -f "$STAGING/AppIcon.icns" ]]; then + cp "$STAGING/AppIcon.icns" "$CONTENTS/Resources/AppIcon.icns" +fi + +echo "[assemble_bundle] done: $APP" diff --git a/mac/build/build.sh b/mac/build/build.sh new file mode 100755 index 00000000..5f516264 --- /dev/null +++ b/mac/build/build.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Orchestrate the full build: launcher, python, frontend, container CLI, +# bundle, sign, DMG, sparkle-sign, notarize. +# +# Args: +# --version +# --python-version +# --container-cli-version +# --output +set -euo pipefail + +VERSION="" +PYTHON_VER="3.12.13" +CLI_VER="0.12.0" +OUTPUT="dist" +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --python-version) PYTHON_VER="$2"; shift 2 ;; + --container-cli-version) CLI_VER="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + *) echo "build.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$VERSION" ]] || { echo "--version required" >&2; exit 2; } + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SCRIPT_DIR="$REPO_ROOT/mac/build" +STAGING="$REPO_ROOT/$OUTPUT/staging" + +echo "[build] env validation" +command -v swift >/dev/null || { echo "swift not found" >&2; exit 1; } +command -v create-dmg >/dev/null || { echo "create-dmg not found (brew install create-dmg)" >&2; exit 1; } + +mkdir -p "$REPO_ROOT/$OUTPUT" +rm -rf "$STAGING" +mkdir -p "$STAGING" + +echo "[build] (1/9) launcher" +cd "$REPO_ROOT/mac/launcher" +swift build -c release --arch arm64 +LAUNCHER_BINARY="$REPO_ROOT/mac/launcher/.build/arm64-apple-macosx/release/taOSLauncher" +cd "$REPO_ROOT" + +echo "[build] (2/9) python" +"$SCRIPT_DIR/build_python.sh" --version "$PYTHON_VER" --output "$STAGING" + +echo "[build] (3/9) frontend" +"$SCRIPT_DIR/build_frontend.sh" --output "$STAGING" + +echo "[build] (4/9) container CLI" +"$SCRIPT_DIR/fetch_container_cli.sh" --version "$CLI_VER" --output "$STAGING" + +echo "[build] (5/9) assemble bundle" +"$SCRIPT_DIR/assemble_bundle.sh" \ + --version "$VERSION" \ + --staging "$STAGING" \ + --launcher-binary "$LAUNCHER_BINARY" \ + --output "$REPO_ROOT/$OUTPUT" + +APP="$REPO_ROOT/$OUTPUT/taOS.app" + +echo "[build] (6/9) sign" +"$SCRIPT_DIR/sign.sh" --app "$APP" + +echo "[build] (7/9) package DMG" +"$SCRIPT_DIR/package_dmg.sh" --app "$APP" --version "$VERSION" --output "$REPO_ROOT/$OUTPUT" +DMG="$REPO_ROOT/$OUTPUT/taOS-$VERSION.dmg" + +echo "[build] (8/9) notarize" +"$SCRIPT_DIR/notarize.sh" --dmg "$DMG" + +echo "[build] (9/9) sparkle-sign" +SPARKLE_KEY="${SPARKLE_ED_PRIVATE_KEY:-$HOME/.taos/sparkle_ed_private.pem}" +if [[ -f "$SPARKLE_KEY" ]]; then + "$SCRIPT_DIR/sparkle_sign.sh" --dmg "$DMG" --version "$VERSION" --output "$REPO_ROOT/$OUTPUT" +else + echo "[sparkle-sign] skipped — no Sparkle EdDSA private key at $SPARKLE_KEY" +fi + +# Clean staging on success +rm -rf "$STAGING" + +echo "[build] done" +echo " app: $APP" +echo " dmg: $DMG" +echo " sig: ${DMG}.sig" +echo " appcast snippet: $REPO_ROOT/$OUTPUT/appcast-snippet.xml" diff --git a/mac/build/build_frontend.sh b/mac/build/build_frontend.sh new file mode 100755 index 00000000..c68e37c0 --- /dev/null +++ b/mac/build/build_frontend.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build the Vite frontend and copy output to staging. +# +# Args: --output +set -euo pipefail + +OUTPUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT="$2"; shift 2 ;; + *) echo "build_frontend.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$OUTPUT" ]] || { echo "--output required" >&2; exit 2; } + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +cd "$REPO_ROOT/desktop" +echo "[build_frontend] npm ci" +npm ci +echo "[build_frontend] npm run build" +npm run build + +# Vite is configured (desktop/vite.config.ts) to write to ../static/desktop +DIST="$REPO_ROOT/static/desktop" +[[ -d "$DIST" ]] || { echo "[build_frontend] missing $DIST" >&2; exit 1; } + +mkdir -p "$OUTPUT/frontend" +cp -R "$DIST"/. "$OUTPUT/frontend/" +echo "[build_frontend] done: $OUTPUT/frontend" diff --git a/mac/build/build_python.sh b/mac/build/build_python.sh new file mode 100755 index 00000000..e88d5f30 --- /dev/null +++ b/mac/build/build_python.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Build the embedded Python distribution from python-build-standalone. +# +# Args: --version --output +# Output: $STAGING_DIR/python/{bin,lib,...} +set -euo pipefail + +PYTHON_VER="" +OUTPUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --version) PYTHON_VER="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + *) echo "build_python.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$PYTHON_VER" ]] || { echo "--version required" >&2; exit 2; } +[[ -n "$OUTPUT" ]] || { echo "--output required" >&2; exit 2; } + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +# Latest stable release naming convention from astral-sh/python-build-standalone +TAG="20260414" +CHECKSUM_FILE="$REPO_ROOT/mac/build/checksums/python-build-standalone-${PYTHON_VER}+${TAG}.sha256" +[[ -f "$CHECKSUM_FILE" ]] || { + echo "[build_python] missing checksum file for ${PYTHON_VER}+${TAG}: $CHECKSUM_FILE" >&2 + exit 2 +} +URL="https://github.com/astral-sh/python-build-standalone/releases/download/${TAG}/cpython-${PYTHON_VER}+${TAG}-aarch64-apple-darwin-install_only.tar.gz" + +mkdir -p "$OUTPUT" +TARBALL="$OUTPUT/python-${PYTHON_VER}.tar.gz" + +echo "[build_python] downloading $URL" +curl -L --fail -o "$TARBALL" "$URL" + +EXPECTED_SHA="$(cat "$CHECKSUM_FILE")" +ACTUAL_SHA="$(shasum -a 256 "$TARBALL" | awk '{print $1}')" +if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then + echo "[build_python] SHA mismatch: expected $EXPECTED_SHA got $ACTUAL_SHA" >&2 + exit 1 +fi + +echo "[build_python] extracting" +mkdir -p "$OUTPUT/python" +tar -xzf "$TARBALL" -C "$OUTPUT/python" --strip-components=1 +rm "$TARBALL" + +PYBIN="$OUTPUT/python/bin/python3" +"$PYBIN" -m pip install --no-deps -r "$REPO_ROOT/tinyagentos/requirements.lock" + +echo "[build_python] done: $OUTPUT/python/bin/python3" diff --git a/mac/build/bump_version.sh b/mac/build/bump_version.sh new file mode 100755 index 00000000..edd93330 --- /dev/null +++ b/mac/build/bump_version.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Bump version across Info.plist.in, pyproject.toml, frontend/package.json, CHANGELOG.md. +# +# Usage: bump_version.sh +set -euo pipefail + +NEW_VER="${1:-}" +[[ -n "$NEW_VER" ]] || { echo "usage: bump_version.sh " >&2; exit 2; } + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +# Info.plist.in: replace ${VERSION} marker is left for assemble_bundle. Real version in CFBundleShortVersionString is templated. We bump a sidecar file to track. +echo "$NEW_VER" > mac/build/.version + +# pyproject.toml: update version = "..." +sed -i.bak -E "s/^version = \".*\"/version = \"$NEW_VER\"/" pyproject.toml +rm -f pyproject.toml.bak + +# frontend/package.json +node -e "const f='frontend/package.json'; const j=require('./'+f); j.version='$NEW_VER'; require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n');" + +# CHANGELOG.md: prepend a new section if not present +if ! grep -q "^## $NEW_VER" CHANGELOG.md 2>/dev/null; then + TMP="$(mktemp)" + { + echo "## $NEW_VER" + echo "" + echo "- TODO: fill in release notes" + echo "" + [[ -f CHANGELOG.md ]] && cat CHANGELOG.md + } > "$TMP" + mv "$TMP" CHANGELOG.md +fi + +echo "[bump_version] -> $NEW_VER" diff --git a/mac/build/checksums/.gitkeep b/mac/build/checksums/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/mac/build/checksums/apple-container-cli-0.12.0.sha256 b/mac/build/checksums/apple-container-cli-0.12.0.sha256 new file mode 100644 index 00000000..37866f71 --- /dev/null +++ b/mac/build/checksums/apple-container-cli-0.12.0.sha256 @@ -0,0 +1 @@ +f56598208aedb65e5d6664e767b3926a3ca3af6057935df336e7d854ac344a44 diff --git a/mac/build/checksums/python-build-standalone-3.12.13+20260414.sha256 b/mac/build/checksums/python-build-standalone-3.12.13+20260414.sha256 new file mode 100644 index 00000000..e18b90d2 --- /dev/null +++ b/mac/build/checksums/python-build-standalone-3.12.13+20260414.sha256 @@ -0,0 +1 @@ +8966b2bcd9fa03ba22c080ad15a86bc12e41a00122b16f4b3740e302261124d9 diff --git a/mac/build/fetch_container_cli.sh b/mac/build/fetch_container_cli.sh new file mode 100755 index 00000000..74341ae4 --- /dev/null +++ b/mac/build/fetch_container_cli.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Fetch and verify the apple/container CLI release pkg, extract the +# bundled binary + libexec plugins. +# +# Args: --version --output +set -euo pipefail + +CLI_VER="" +OUTPUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --version) CLI_VER="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + *) echo "fetch_container_cli.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$CLI_VER" ]] || { echo "--version required" >&2; exit 2; } +[[ -n "$OUTPUT" ]] || { echo "--output required" >&2; exit 2; } + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CHECKSUM_FILE="$REPO_ROOT/mac/build/checksums/apple-container-cli-${CLI_VER}.sha256" +[[ -f "$CHECKSUM_FILE" ]] || { + echo "[fetch_container_cli] missing checksum file for ${CLI_VER}: $CHECKSUM_FILE" >&2 + exit 2 +} + +URL="https://github.com/apple/container/releases/download/${CLI_VER}/container-${CLI_VER}-installer-signed.pkg" +mkdir -p "$OUTPUT" +PKG="$OUTPUT/container-${CLI_VER}.pkg" + +echo "[fetch_container_cli] downloading $URL" +curl -L --fail -o "$PKG" "$URL" + +EXPECTED_SHA="$(cat "$CHECKSUM_FILE")" +ACTUAL_SHA="$(shasum -a 256 "$PKG" | awk '{print $1}')" +if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then + echo "[fetch_container_cli] SHA mismatch: expected $EXPECTED_SHA got $ACTUAL_SHA" >&2 + exit 1 +fi + +WORK="$OUTPUT/.container-extract" +rm -rf "$WORK" +mkdir -p "$WORK" + +echo "[fetch_container_cli] extracting pkg" +(cd "$WORK" && xar -xf "$PKG") +# The pkg's Payload is a gzipped cpio archive at $WORK/Payload (case-sensitive +# name — note this collides with a directory called "payload" on case-insensitive +# APFS, so we extract into a separately-named sibling directory). +mkdir -p "$WORK/extracted" +(cd "$WORK/extracted" && gunzip -dc "$WORK/Payload" | cpio -i --quiet) + +mkdir -p "$OUTPUT/bin" "$OUTPUT/libexec" +cp "$WORK/extracted/bin/container" "$OUTPUT/bin/container" +chmod +x "$OUTPUT/bin/container" +cp -R "$WORK/extracted/libexec/container" "$OUTPUT/libexec/container" + +rm -rf "$WORK" "$PKG" + +echo "[fetch_container_cli] done: $OUTPUT/bin/container" diff --git a/mac/build/generate_icon_placeholder.sh b/mac/build/generate_icon_placeholder.sh new file mode 100755 index 00000000..ef9fe6d8 --- /dev/null +++ b/mac/build/generate_icon_placeholder.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Generate placeholder AppIcon.icns from a 1024x1024 PNG. +# +# Args: --source --output +set -euo pipefail + +SRC="" +OUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --source) SRC="$2"; shift 2 ;; + --output) OUT="$2"; shift 2 ;; + *) echo "generate_icon_placeholder.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$SRC" && -n "$OUT" ]] || { echo "all args required" >&2; exit 2; } + +ICONSET="$OUT/AppIcon.iconset" +mkdir -p "$ICONSET" +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$SRC" --out "$ICONSET/icon_${size}x${size}.png" + double=$((size * 2)) + sips -z "$double" "$double" "$SRC" --out "$ICONSET/icon_${size}x${size}@2x.png" +done +iconutil -c icns "$ICONSET" -o "$OUT/AppIcon.icns" +rm -rf "$ICONSET" +echo "[icon] $OUT/AppIcon.icns" diff --git a/mac/build/notarize.sh b/mac/build/notarize.sh new file mode 100755 index 00000000..59a2176d --- /dev/null +++ b/mac/build/notarize.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Notarise the DMG via xcrun notarytool. v0.1: stub when no Dev ID. +# +# Args: --dmg +set -euo pipefail + +DMG="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dmg) DMG="$2"; shift 2 ;; + *) echo "notarize.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$DMG" ]] || { echo "--dmg required" >&2; exit 2; } + +if [[ -z "${DEV_ID:-}" || -z "${NOTARY_PROFILE:-}" ]]; then + echo "[notarize] skipped — no DEV_ID / NOTARY_PROFILE configured (v0.1)" + exit 0 +fi + +echo "[notarize] submitting $DMG" +xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$DMG" +echo "[notarize] stapled" diff --git a/mac/build/package_dmg.sh b/mac/build/package_dmg.sh new file mode 100755 index 00000000..5a1088e7 --- /dev/null +++ b/mac/build/package_dmg.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Package taOS.app into a DMG via create-dmg. +# +# Args: --app --version --output +set -euo pipefail + +APP="" +VERSION="" +OUTPUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --app) APP="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + *) echo "package_dmg.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$APP" && -n "$VERSION" && -n "$OUTPUT" ]] || { echo "all args required" >&2; exit 2; } + +command -v create-dmg >/dev/null || { echo "create-dmg not installed (brew install create-dmg)" >&2; exit 1; } + +DMG="$OUTPUT/taOS-$VERSION.dmg" +rm -f "$DMG" + +create-dmg \ + --volname "taOS $VERSION" \ + --window-size 600 400 \ + --icon-size 96 \ + --icon "taOS.app" 150 200 \ + --app-drop-link 450 200 \ + --hdiutil-quiet \ + "$DMG" \ + "$APP" + +echo "[package_dmg] done: $DMG" diff --git a/mac/build/sign.sh b/mac/build/sign.sh new file mode 100755 index 00000000..de44d29e --- /dev/null +++ b/mac/build/sign.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Recursive ad-hoc codesign of taOS.app. +# When DEV_ID env var is set, signs with the Developer ID identity instead. +# +# Args: --app +set -euo pipefail + +APP="" +while [[ $# -gt 0 ]]; do + case "$1" in + --app) APP="$2"; shift 2 ;; + *) echo "sign.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$APP" ]] || { echo "--app required" >&2; exit 2; } +[[ -d "$APP" ]] || { echo "no such bundle: $APP" >&2; exit 1; } + +if [[ -n "${DEV_ID:-}" ]]; then + IDENTITY="Developer ID Application: $DEV_ID" + EXTRA_ARGS=(--options runtime --timestamp) + echo "[sign] using Dev ID: $DEV_ID" +else + IDENTITY="-" + EXTRA_ARGS=() + echo "[sign] ad-hoc signing (v0.1, no Dev ID)" +fi + +# Empty-array expansion is unbound-safe with this guard (macOS ships bash 3.2) +EA=("${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}") + +# Sign nested binaries deepest-first. We use a while-read loop instead of +# `xargs ... 2>/dev/null || true` so any codesign failure surfaces and exits. +while IFS= read -r -d '' bin; do + codesign --force --sign "$IDENTITY" "${EA[@]+"${EA[@]}"}" "$bin" +done < <( + find "$APP/Contents" -depth \( -type f -perm -u+x -o -type f -name "*.dylib" \) -print0 +) + +# Sign frameworks +find "$APP/Contents/Frameworks" -name "*.framework" -maxdepth 2 -type d -print0 \ + | xargs -0 -I{} codesign --force --sign "$IDENTITY" "${EA[@]+"${EA[@]}"}" {} + +# Sign the bundle itself last +codesign --force --deep --sign "$IDENTITY" "${EA[@]+"${EA[@]}"}" "$APP" + +echo "[sign] verifying" +codesign --verify --deep --strict --verbose=2 "$APP" + +echo "[sign] done" diff --git a/mac/build/sparkle_sign.sh b/mac/build/sparkle_sign.sh new file mode 100755 index 00000000..52d68f50 --- /dev/null +++ b/mac/build/sparkle_sign.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Sparkle-sign the DMG and produce an appcast snippet. +# +# Args: --dmg --version --output +set -euo pipefail + +DMG="" +VERSION="" +OUTPUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dmg) DMG="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + *) echo "sparkle_sign.sh: unknown arg $1" >&2; exit 2 ;; + esac +done + +[[ -n "$DMG" && -n "$VERSION" && -n "$OUTPUT" ]] || { echo "all args required" >&2; exit 2; } + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PRIVATE_KEY="${SPARKLE_ED_PRIVATE_KEY:-$HOME/.taos/sparkle_ed_private.pem}" +[[ -f "$PRIVATE_KEY" ]] || { echo "Sparkle private key not at $PRIVATE_KEY" >&2; exit 1; } + +# Locate Sparkle's sign_update tool — bundled with Sparkle release tarball +SIGN_UPDATE="$(command -v sign_update || true)" +if [[ -z "$SIGN_UPDATE" ]]; then + for c in "$REPO_ROOT/mac/build/staging/Sparkle.framework/Versions/B/Resources/sign_update" \ + "/Applications/Sparkle.framework/Versions/B/Resources/sign_update"; do + [[ -x "$c" ]] && { SIGN_UPDATE="$c"; break; } + done +fi +[[ -n "$SIGN_UPDATE" ]] || { echo "sign_update tool not found" >&2; exit 1; } + +SIGNATURE_LINE="$("$SIGN_UPDATE" "$DMG" "$PRIVATE_KEY")" +# sign_update prints e.g.: sparkle:edSignature="..." length="N" +echo "[sparkle_sign] $SIGNATURE_LINE" + +# Write the .sig sidecar +SIG_FIELD="$(echo "$SIGNATURE_LINE" | sed -nE 's/.*sparkle:edSignature="([^"]+)".*/\1/p')" +[[ -n "$SIG_FIELD" ]] || { echo "failed to parse sparkle:edSignature" >&2; exit 1; } +echo -n "$SIG_FIELD" > "${DMG}.sig" + +# Build the appcast snippet +mkdir -p "$OUTPUT" +SNIPPET="$OUTPUT/appcast-snippet.xml" +NOTES_FILE="$REPO_ROOT/CHANGELOG.md" +NOTES="$(awk -v v="$VERSION" 'BEGIN{p=0} /^## /{p=($2==v)} p' "$NOTES_FILE" 2>/dev/null || echo "")" + +cat > "$SNIPPET" < + v${VERSION} + ${VERSION} + 26.0 + + + +XML + +echo "[sparkle_sign] done: $SNIPPET" diff --git a/mac/launcher/Package.swift b/mac/launcher/Package.swift new file mode 100644 index 00000000..17e828b9 --- /dev/null +++ b/mac/launcher/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.2 +import PackageDescription + +// Sparkle 2.6.0 is wired in by the build pipeline (mac/build/build.sh) when +// the build host has network access to GitHub releases. SparkleBridge.swift +// gates its real code path behind `#if canImport(Sparkle)`, so the package +// builds and the launcher functions identically without it. +// +// To enable Sparkle for a local development build, append: +// +// .binaryTarget( +// name: "Sparkle", +// url: "https://github.com/sparkle-project/Sparkle/releases/download/2.6.0/Sparkle-for-Swift-Package-Manager.zip", +// checksum: "a5088d48a37ba415081335502e009dece75acae9d130705fee6c6988b90d0877" +// ), +// +// to the targets list and add "Sparkle" to the executableTarget dependencies. + +let package = Package( + name: "taOSLauncher", + platforms: [.macOS(.v26)], + targets: [ + .executableTarget( + name: "taOSLauncher", + dependencies: [], + path: "Sources/taOSLauncher", + resources: [.process("Resources")] + ), + .testTarget( + name: "taOSLauncherTests", + dependencies: ["taOSLauncher"], + path: "Tests/taOSLauncherTests" + ), + ] +) diff --git a/mac/launcher/Sources/taOSLauncher/App.swift b/mac/launcher/Sources/taOSLauncher/App.swift new file mode 100644 index 00000000..219e7e9e --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/App.swift @@ -0,0 +1,143 @@ +import AppKit +import Foundation + +@main +struct AppEntry { + static func main() { + let app = NSApplication.shared + let delegate = TaOSAppDelegate() + app.delegate = delegate + app.setActivationPolicy(.accessory) + app.run() + } +} + +@MainActor +final class TaOSAppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem! + private var menuBar: NSMenu! + private var serverProcess: ServerProcess? + private var windowController: WindowController! + private var keyboardMonitor = KeyboardMonitor() + private var sparkle = SparkleBridge() + private let port: Int = 7117 + private var signalSources: [DispatchSourceSignal] = [] + + func applicationDidFinishLaunching(_ notification: Notification) { + installStatusItem() + startServer() + windowController = WindowController(serverPort: port) + keyboardMonitor.install() + sparkle.startAutomaticUpdates() + installSignalHandlers() + + // NSWindow.willCloseNotification is posted on the default center, not + // the workspace one. Object filter limits the observer to the taOS main + // window so Sparkle / future auxiliary windows can't reset our state. + NotificationCenter.default.addObserver( + self, selector: #selector(windowWillClose(_:)), + name: NSWindow.willCloseNotification, object: nil + ) + } + + // Route shell-delivered SIGTERM/SIGINT through the normal NSApp.terminate + // path so applicationWillTerminate runs and the Python child is reaped. + // Without this, `pkill -TERM` on the launcher orphans the server. + private func installSignalHandlers() { + for sig in [SIGTERM, SIGINT] { + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) + source.setEventHandler { NSApp.terminate(nil) } + source.resume() + signalSources.append(source) + } + } + + func applicationWillTerminate(_ notification: Notification) { + serverProcess?.stop(gracefulTimeoutSeconds: 5) + } + + private func installStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem.button?.title = "taOS" + let actions = MenuBar.Actions( + openDesktop: { [weak self] in self?.openDesktop() }, + openMobile: { [weak self] in self?.openMobile() }, + togglePause: { }, + openPreferences: { }, + checkForUpdates: { [weak self] in self?.sparkle.checkForUpdates() }, + quit: { NSApp.terminate(nil) } + ) + menuBar = MenuBar.buildMenu(actions: actions, isPaused: false) + statusItem.menu = menuBar + } + + private func startServer() { + let resources = Bundle.main.resourceURL! + let python = resources.appendingPathComponent("python/bin/python3") + let taosRoot = resources.appendingPathComponent("taos") + let containerBin = resources.appendingPathComponent("bin/container") + + let dataDir = FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/taOS") + let logDir = FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/taOS") + + let env: [String: String] = [ + "PYTHONPATH": taosRoot.path, + // Keep .pyc files out of the bundle — Python writes them next to + // sources by default, which would invalidate the codesign seal. + "PYTHONDONTWRITEBYTECODE": "1", + "TAOS_DATA_DIR": dataDir.path, + "TAOS_HOST": "127.0.0.1", + "TAOS_PORT": "\(port)", + "TAOS_CONTAINER_BIN": containerBin.path, + ] + + let server = ServerProcess( + executable: python, + arguments: ["-m", "tinyagentos"], + env: env, + logFile: logDir.appendingPathComponent("server.log") + ) + do { + try server.start() + self.serverProcess = server + } catch { + NSLog("[taOS] server failed to start: \(error)") + statusItem.button?.title = "taOS ⚠" + return + } + + Task { @MainActor in + let url = URL(string: "http://127.0.0.1:\(port)/api/health")! + let ready = await server.waitForReady(timeoutSeconds: 15, healthURL: url) + if !ready { + self.statusItem.button?.title = "taOS ⚠" + } + } + } + + private func openDesktop() { + if windowController.mode != .fullscreen { windowController.toggleMode() } + NSApp.setActivationPolicy(.regular) + windowController.showWindow() + keyboardMonitor.fullscreen = true + } + + private func openMobile() { + if windowController.mode != .phone { windowController.toggleMode() } + NSApp.setActivationPolicy(.regular) + windowController.showWindow() + keyboardMonitor.fullscreen = false + } + + @objc private func windowWillClose(_ notification: Notification) { + guard let closingWindow = notification.object as? NSWindow, + closingWindow == windowController.window else { return } + NSApp.setActivationPolicy(.accessory) + keyboardMonitor.fullscreen = false + } +} diff --git a/mac/launcher/Sources/taOSLauncher/KeyboardMonitor.swift b/mac/launcher/Sources/taOSLauncher/KeyboardMonitor.swift new file mode 100644 index 00000000..ce6370df --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/KeyboardMonitor.swift @@ -0,0 +1,48 @@ +import AppKit + +/// Local NSEvent monitor that swallows kiosk-busting shortcuts when the +/// app is in fullscreen mode. Spaces gestures (Cmd+Tab, Ctrl+arrow) are +/// always allowed through. +public final class KeyboardMonitor { + public var fullscreen: Bool = false + private var monitor: Any? + + private let blockedCmdKeys: Set = [ + 12, // Q + 13, // W + 4, // H + 46, // M + 7, // X + ] + + private let alwaysPassCmd: Set = [ + 48, // Tab + 50, // ` + ] + + public init() {} + + public func shouldIntercept(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + guard fullscreen else { return false } + if modifiers.contains(.control) { return false } + if modifiers.contains(.command), alwaysPassCmd.contains(keyCode) { return false } + if modifiers.contains(.command), blockedCmdKeys.contains(keyCode) { return true } + return false + } + + public func install() { + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + if self.shouldIntercept(keyCode: event.keyCode, + modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask)) { + return nil + } + return event + } + } + + public func uninstall() { + if let m = monitor { NSEvent.removeMonitor(m) } + monitor = nil + } +} diff --git a/mac/launcher/Sources/taOSLauncher/MenuBar.swift b/mac/launcher/Sources/taOSLauncher/MenuBar.swift new file mode 100644 index 00000000..ce8a927a --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/MenuBar.swift @@ -0,0 +1,58 @@ +import AppKit + +/// Builds and owns the NSStatusItem menu. +public enum MenuBar { + public struct Actions { + public var openDesktop: () -> Void + public var openMobile: () -> Void + public var togglePause: () -> Void + public var openPreferences: () -> Void + public var checkForUpdates: () -> Void + public var quit: () -> Void + + public init(openDesktop: @escaping () -> Void, + openMobile: @escaping () -> Void, + togglePause: @escaping () -> Void, + openPreferences: @escaping () -> Void, + checkForUpdates: @escaping () -> Void, + quit: @escaping () -> Void) { + self.openDesktop = openDesktop + self.openMobile = openMobile + self.togglePause = togglePause + self.openPreferences = openPreferences + self.checkForUpdates = checkForUpdates + self.quit = quit + } + } + + public static func buildMenu(actions: Actions, isPaused: Bool) -> NSMenu { + let menu = NSMenu() + menu.addItem(makeItem(title: "Open taOS", action: actions.openDesktop)) + menu.addItem(makeItem(title: "Open Mobile View", action: actions.openMobile)) + menu.addItem(makeItem(title: isPaused ? "Resume Agents" : "Pause Agents", + action: actions.togglePause)) + menu.addItem(NSMenuItem.separator()) + menu.addItem(makeItem(title: "Preferences…", action: actions.openPreferences)) + menu.addItem(makeItem(title: "Check for Updates…", action: actions.checkForUpdates)) + menu.addItem(NSMenuItem.separator()) + menu.addItem(makeItem(title: "Quit taOS", action: actions.quit, keyEquivalent: "q")) + return menu + } + + private static func makeItem(title: String, + action: @escaping () -> Void, + keyEquivalent: String = "") -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: keyEquivalent) + let target = ActionWrapper(action: action) + item.target = target + item.action = #selector(ActionWrapper.invoke) + item.representedObject = target + return item + } + + private final class ActionWrapper: NSObject { + let action: () -> Void + init(action: @escaping () -> Void) { self.action = action } + @objc func invoke() { action() } + } +} diff --git a/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a9802be3 --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { "idiom" : "mac", "scale" : "1x", "size" : "16x16", "filename" : "icon_16x16.png" }, + { "idiom" : "mac", "scale" : "2x", "size" : "16x16", "filename" : "icon_16x16@2x.png" }, + { "idiom" : "mac", "scale" : "1x", "size" : "32x32", "filename" : "icon_32x32.png" }, + { "idiom" : "mac", "scale" : "2x", "size" : "32x32", "filename" : "icon_32x32@2x.png" }, + { "idiom" : "mac", "scale" : "1x", "size" : "128x128","filename" : "icon_128x128.png" }, + { "idiom" : "mac", "scale" : "2x", "size" : "128x128","filename" : "icon_128x128@2x.png" }, + { "idiom" : "mac", "scale" : "1x", "size" : "256x256","filename" : "icon_256x256.png" }, + { "idiom" : "mac", "scale" : "2x", "size" : "256x256","filename" : "icon_256x256@2x.png" }, + { "idiom" : "mac", "scale" : "1x", "size" : "512x512","filename" : "icon_512x512.png" }, + { "idiom" : "mac", "scale" : "2x", "size" : "512x512","filename" : "icon_512x512@2x.png" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/Contents.json b/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in b/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in new file mode 100644 index 00000000..52ec1943 --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in @@ -0,0 +1,36 @@ + + + + + CFBundleIdentifier + com.taos.app + CFBundleName + taOS + CFBundleDisplayName + taOS + CFBundleExecutable + taOS + CFBundleVersion + ${VERSION} + CFBundleShortVersionString + ${VERSION} + CFBundlePackageType + APPL + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 26.0 + LSUIElement + + NSHighResolutionCapable + + SUFeedURL + https://taos.app/appcast.xml + SUPublicEDKey + ${SU_PUBLIC_ED_KEY} + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 + + diff --git a/mac/launcher/Sources/taOSLauncher/ServerProcess.swift b/mac/launcher/Sources/taOSLauncher/ServerProcess.swift new file mode 100644 index 00000000..95865579 --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/ServerProcess.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Manages the lifecycle of the embedded uvicorn FastAPI server process. +/// +/// Spawns a child Process, polls /api/health to confirm readiness, and +/// terminates with SIGTERM -> SIGKILL fallback on shutdown. +// Lifecycle is single-threaded under the launcher: start() runs on main during +// applicationDidFinishLaunching, waitForReady polls async, stop() runs synchronously +// from applicationWillTerminate. No concurrent mutation, so unchecked Sendable +// is correct here. +final class ServerProcess: @unchecked Sendable { + let executable: URL + let arguments: [String] + let env: [String: String] + let logFile: URL + + private var process: Process? + private var logHandle: FileHandle? + + init(executable: URL, arguments: [String], env: [String: String], logFile: URL) { + self.executable = executable + self.arguments = arguments + self.env = env + self.logFile = logFile + } + + var isRunning: Bool { + process?.isRunning ?? false + } + + func start() throws { + guard process == nil else { return } + + let dir = logFile.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: logFile.path) { + FileManager.default.createFile(atPath: logFile.path, contents: nil) + } + let handle = try FileHandle(forWritingTo: logFile) + try handle.seekToEnd() + self.logHandle = handle + + let proc = Process() + proc.executableURL = executable + proc.arguments = arguments + proc.environment = env + proc.standardOutput = handle + proc.standardError = handle + try proc.run() + self.process = proc + } + + /// Polls the given health URL until it returns 200 or timeout elapses. + func waitForReady(timeoutSeconds: Double, healthURL: URL) async -> Bool { + let deadline = Date().addingTimeInterval(timeoutSeconds) + let session = URLSession(configuration: .ephemeral) + var request = URLRequest(url: healthURL) + request.timeoutInterval = 1.0 + + while Date() < deadline { + if !isRunning { return false } + do { + let (_, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + return true + } + } catch { + // server not ready yet — keep polling + } + try? await Task.sleep(nanoseconds: 250_000_000) + } + return false + } + + /// Sends SIGTERM, waits up to graceful timeout, then SIGKILL if still running. + /// Synchronous so it can run on the main thread during applicationWillTerminate + /// without deadlocking on a Task that needs the main actor. + func stop(gracefulTimeoutSeconds: Double) { + guard let proc = process, proc.isRunning else { + cleanup() + return + } + + proc.terminate() + let deadline = Date().addingTimeInterval(gracefulTimeoutSeconds) + while proc.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.1) + } + if proc.isRunning { + kill(proc.processIdentifier, SIGKILL) + proc.waitUntilExit() + } + cleanup() + } + + private func cleanup() { + try? logHandle?.close() + logHandle = nil + process = nil + } +} diff --git a/mac/launcher/Sources/taOSLauncher/SparkleBridge.swift b/mac/launcher/Sources/taOSLauncher/SparkleBridge.swift new file mode 100644 index 00000000..1cb8b02c --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/SparkleBridge.swift @@ -0,0 +1,48 @@ +import Foundation +#if canImport(Sparkle) +import Sparkle +#endif + +/// Thin wrapper around Sparkle. Test-friendly because the controller is +/// owned only after the real bundle keys are present. +public final class SparkleBridge { + public let feedURL: URL? + public let publicKey: String? + #if canImport(Sparkle) + private var controller: SPUStandardUpdaterController? + #endif + + public init(infoDict: [String: Any]) { + if let s = infoDict["SUFeedURL"] as? String { + self.feedURL = URL(string: s) + } else { + self.feedURL = nil + } + self.publicKey = infoDict["SUPublicEDKey"] as? String + } + + public convenience init() { + self.init(infoDict: Bundle.main.infoDictionary ?? [:]) + } + + public var canCheckForUpdates: Bool { + guard feedURL != nil else { return false } + let key = publicKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !key.isEmpty + } + + public func startAutomaticUpdates() { + #if canImport(Sparkle) + guard canCheckForUpdates else { return } + controller = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil + ) + #endif + } + + public func checkForUpdates() { + #if canImport(Sparkle) + controller?.checkForUpdates(nil) + #endif + } +} diff --git a/mac/launcher/Sources/taOSLauncher/WindowController.swift b/mac/launcher/Sources/taOSLauncher/WindowController.swift new file mode 100644 index 00000000..60939c6d --- /dev/null +++ b/mac/launcher/Sources/taOSLauncher/WindowController.swift @@ -0,0 +1,83 @@ +import AppKit +import WebKit + +public enum WindowMode: String { + case fullscreen + case phone +} + +public final class WindowController { + public private(set) var mode: WindowMode + private let serverPort: Int + private let defaults: UserDefaults + public private(set) var window: NSWindow? + private var webView: WKWebView? + + public init(serverPort: Int, defaults: UserDefaults = .standard) { + self.serverPort = serverPort + self.defaults = defaults + let raw = defaults.string(forKey: "lastWindowMode") ?? "phone" + self.mode = WindowMode(rawValue: raw) ?? .phone + } + + public static func route(for mode: WindowMode, port: Int) -> URL { + let path = (mode == .fullscreen) ? "/" : "/mobile" + return URL(string: "http://127.0.0.1:\(port)\(path)")! + } + + public func toggleMode() { + mode = (mode == .phone) ? .fullscreen : .phone + defaults.set(mode.rawValue, forKey: "lastWindowMode") + applyMode() + } + + public func showWindow() { + if window == nil { buildWindow() } + applyMode() + window?.makeKeyAndOrderFront(nil) + } + + public func hideWindow() { + window?.orderOut(nil) + } + + private func buildWindow() { + let frame = NSRect(x: 100, y: 100, width: 393, height: 852) + let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + let w = NSWindow(contentRect: frame, styleMask: style, backing: .buffered, defer: false) + w.title = "taOS" + w.titlebarAppearsTransparent = true + w.minSize = NSSize(width: 320, height: 694) + w.maxSize = NSSize(width: 480, height: 1040) + + let webView = WKWebView(frame: w.contentView!.bounds) + webView.autoresizingMask = [.width, .height] + webView.layer?.cornerRadius = 47 + webView.layer?.masksToBounds = true + w.contentView?.addSubview(webView) + + self.window = w + self.webView = webView + } + + private func applyMode() { + guard let w = window, let webView = webView else { return } + let url = Self.route(for: mode, port: serverPort) + webView.load(URLRequest(url: url)) + + switch mode { + case .fullscreen: + if !(w.styleMask.contains(.fullScreen)) { + w.toggleFullScreen(nil) + } + w.collectionBehavior = [.fullScreenPrimary] + case .phone: + if w.styleMask.contains(.fullScreen) { + w.toggleFullScreen(nil) + } + let target = NSRect(x: w.frame.origin.x, y: w.frame.origin.y, + width: 393, height: 852) + w.setFrame(target, display: true, animate: true) + } + } +} diff --git a/mac/launcher/Tests/taOSLauncherTests/KeyboardMonitorTests.swift b/mac/launcher/Tests/taOSLauncherTests/KeyboardMonitorTests.swift new file mode 100644 index 00000000..094b669b --- /dev/null +++ b/mac/launcher/Tests/taOSLauncherTests/KeyboardMonitorTests.swift @@ -0,0 +1,35 @@ +import XCTest +import AppKit +@testable import taOSLauncher + +final class KeyboardMonitorTests: XCTestCase { + func testInterceptsCmdQInFullscreen() { + let monitor = KeyboardMonitor() + monitor.fullscreen = true + XCTAssertTrue(monitor.shouldIntercept(keyCode: 12, modifiers: .command)) + } + + func testInterceptsCmdWInFullscreen() { + let monitor = KeyboardMonitor() + monitor.fullscreen = true + XCTAssertTrue(monitor.shouldIntercept(keyCode: 13, modifiers: .command)) + } + + func testDoesNotInterceptInPhoneMode() { + let monitor = KeyboardMonitor() + monitor.fullscreen = false + XCTAssertFalse(monitor.shouldIntercept(keyCode: 12, modifiers: .command)) + } + + func testCmdTabAlwaysPassesThrough() { + let monitor = KeyboardMonitor() + monitor.fullscreen = true + XCTAssertFalse(monitor.shouldIntercept(keyCode: 48, modifiers: .command)) + } + + func testCtrlArrowAlwaysPassesThrough() { + let monitor = KeyboardMonitor() + monitor.fullscreen = true + XCTAssertFalse(monitor.shouldIntercept(keyCode: 124, modifiers: .control)) + } +} diff --git a/mac/launcher/Tests/taOSLauncherTests/MenuBarTests.swift b/mac/launcher/Tests/taOSLauncherTests/MenuBarTests.swift new file mode 100644 index 00000000..278f7203 --- /dev/null +++ b/mac/launcher/Tests/taOSLauncherTests/MenuBarTests.swift @@ -0,0 +1,46 @@ +import XCTest +import AppKit +@testable import taOSLauncher + +final class MenuBarTests: XCTestCase { + func testMenuItemsInOrder() { + let actions = MenuBar.Actions( + openDesktop: {}, + openMobile: {}, + togglePause: {}, + openPreferences: {}, + checkForUpdates: {}, + quit: {} + ) + let menu = MenuBar.buildMenu(actions: actions, isPaused: false) + let titles = menu.items.map { $0.title } + XCTAssertEqual(titles, [ + "Open taOS", + "Open Mobile View", + "Pause Agents", + "", + "Preferences…", + "Check for Updates…", + "", + "Quit taOS", + ]) + } + + func testPauseTogglesTitle() { + let actions = MenuBar.Actions( + openDesktop: {}, openMobile: {}, togglePause: {}, + openPreferences: {}, checkForUpdates: {}, quit: {} + ) + let paused = MenuBar.buildMenu(actions: actions, isPaused: true) + XCTAssertEqual(paused.items[2].title, "Resume Agents") + } + + func testQuitHasCommandQ() { + let actions = MenuBar.Actions( + openDesktop: {}, openMobile: {}, togglePause: {}, + openPreferences: {}, checkForUpdates: {}, quit: {} + ) + let menu = MenuBar.buildMenu(actions: actions, isPaused: false) + XCTAssertEqual(menu.items.last?.keyEquivalent, "q") + } +} diff --git a/mac/launcher/Tests/taOSLauncherTests/PlaceholderTests.swift b/mac/launcher/Tests/taOSLauncherTests/PlaceholderTests.swift new file mode 100644 index 00000000..f6c4ae13 --- /dev/null +++ b/mac/launcher/Tests/taOSLauncherTests/PlaceholderTests.swift @@ -0,0 +1,7 @@ +import XCTest + +final class PlaceholderTests: XCTestCase { + func testPackageBuilds() { + XCTAssertTrue(true) + } +} diff --git a/mac/launcher/Tests/taOSLauncherTests/ServerProcessTests.swift b/mac/launcher/Tests/taOSLauncherTests/ServerProcessTests.swift new file mode 100644 index 00000000..4e87f5ff --- /dev/null +++ b/mac/launcher/Tests/taOSLauncherTests/ServerProcessTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import taOSLauncher + +final class ServerProcessTests: XCTestCase { + func testSpawnAndStop() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("taos-server-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + let script = tmp.appendingPathComponent("fake-server.sh") + try """ + #!/bin/bash + echo "Uvicorn running on http://127.0.0.1:7117" + sleep 60 + """.write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: script.path) + + let log = tmp.appendingPathComponent("server.log") + let server = ServerProcess( + executable: URL(fileURLWithPath: "/bin/bash"), + arguments: [script.path], + env: ProcessInfo.processInfo.environment, + logFile: log + ) + + try server.start() + XCTAssertTrue(server.isRunning) + + try await Task.sleep(nanoseconds: 200_000_000) + server.stop(gracefulTimeoutSeconds: 2.0) + XCTAssertFalse(server.isRunning) + } +} diff --git a/mac/launcher/Tests/taOSLauncherTests/SparkleBridgeTests.swift b/mac/launcher/Tests/taOSLauncherTests/SparkleBridgeTests.swift new file mode 100644 index 00000000..50d8c52e --- /dev/null +++ b/mac/launcher/Tests/taOSLauncherTests/SparkleBridgeTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import taOSLauncher + +final class SparkleBridgeTests: XCTestCase { + func testFeedURLReadFromBundle() { + let bridge = SparkleBridge(infoDict: [ + "SUFeedURL": "https://taos.app/appcast.xml", + "SUPublicEDKey": "fakekey==" + ]) + XCTAssertEqual(bridge.feedURL, URL(string: "https://taos.app/appcast.xml")) + XCTAssertEqual(bridge.publicKey, "fakekey==") + } + + func testMissingFeedURLDisablesUpdates() { + let bridge = SparkleBridge(infoDict: [:]) + XCTAssertNil(bridge.feedURL) + XCTAssertFalse(bridge.canCheckForUpdates) + } +} diff --git a/mac/launcher/Tests/taOSLauncherTests/WindowControllerTests.swift b/mac/launcher/Tests/taOSLauncherTests/WindowControllerTests.swift new file mode 100644 index 00000000..6f4c5ba5 --- /dev/null +++ b/mac/launcher/Tests/taOSLauncherTests/WindowControllerTests.swift @@ -0,0 +1,36 @@ +import XCTest +import AppKit +@testable import taOSLauncher + +final class WindowControllerTests: XCTestCase { + func testInitialModeFromDefaults() { + let suite = "test_\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.set("phone", forKey: "lastWindowMode") + + let wc = WindowController(serverPort: 6969, defaults: defaults) + XCTAssertEqual(wc.mode, .phone) + } + + func testFirstLaunchDefaultsToPhone() { + let suite = "test_\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + let wc = WindowController(serverPort: 6969, defaults: defaults) + XCTAssertEqual(wc.mode, .phone) + } + + func testToggleSwitchesModeAndPersists() { + let suite = "test_\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + let wc = WindowController(serverPort: 6969, defaults: defaults) + XCTAssertEqual(wc.mode, .phone) + wc.toggleMode() + XCTAssertEqual(wc.mode, .fullscreen) + XCTAssertEqual(defaults.string(forKey: "lastWindowMode"), "fullscreen") + } + + func testRouteForMode() { + XCTAssertEqual(WindowController.route(for: .fullscreen, port: 6969).path, "/") + XCTAssertEqual(WindowController.route(for: .phone, port: 6969).path, "/mobile") + } +} diff --git a/tests/test_app_apple_backend_wiring.py b/tests/test_app_apple_backend_wiring.py new file mode 100644 index 00000000..52a701d7 --- /dev/null +++ b/tests/test_app_apple_backend_wiring.py @@ -0,0 +1,20 @@ +"""Smoke test: when detect_runtime returns 'apple', create_app installs AppleContainerBackend.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +@pytest.mark.asyncio +async def test_create_app_installs_apple_backend_on_darwin(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_CONTAINER_BIN", "/usr/local/bin/container") + with patch("tinyagentos.containers.backend.detect_runtime", return_value="apple"): + from tinyagentos.app import create_app + from tinyagentos.containers.backend import get_backend + from tinyagentos.containers.apple_backend import AppleContainerBackend + + app = create_app(data_dir=tmp_path) + async with app.router.lifespan_context(app): + backend = get_backend() + assert isinstance(backend, AppleContainerBackend) diff --git a/tests/test_apple_backend.py b/tests/test_apple_backend.py new file mode 100644 index 00000000..bfd1c87e --- /dev/null +++ b/tests/test_apple_backend.py @@ -0,0 +1,343 @@ +"""Unit tests for AppleContainerBackend (subprocess mocked).""" +from __future__ import annotations + +import json +import os +from unittest.mock import AsyncMock, patch + +import pytest + + +def _backend(monkeypatch, bin_path: str | None = None): + from tinyagentos.containers.apple_backend import AppleContainerBackend + if bin_path is None: + monkeypatch.delenv("TAOS_CONTAINER_BIN", raising=False) + else: + monkeypatch.setenv("TAOS_CONTAINER_BIN", bin_path) + return AppleContainerBackend() + + +def test_resolves_cli_from_env(monkeypatch): + b = _backend(monkeypatch, "/Applications/taOS.app/Contents/Resources/bin/container") + assert b.binary == "/Applications/taOS.app/Contents/Resources/bin/container" + + +def test_falls_back_to_path(monkeypatch): + b = _backend(monkeypatch) + assert b.binary == "container" + + +@pytest.mark.asyncio +async def test_run_invokes_subprocess(monkeypatch): + b = _backend(monkeypatch, "/usr/local/bin/container") + + async def fake_exec(*cmd, **kwargs): + class P: + returncode = 0 + async def communicate(self): + return (b"hello", b"") + return P() + + with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as m: + code, out = await b._run([b.binary, "ls"]) + assert code == 0 + assert out == "hello" + # First positional arg is the binary + assert m.call_args.args[0] == "/usr/local/bin/container" + + +@pytest.mark.asyncio +async def test_list_containers_filters_by_prefix(monkeypatch): + b = _backend(monkeypatch) + payload = json.dumps([ + {"name": "taos-agent-alice", "status": "running", + "ip": "192.168.65.3", "memory": "2GB", "cpus": 2}, + {"name": "other-thing", "status": "running", + "ip": "192.168.65.4", "memory": "1GB", "cpus": 1}, + ]) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, payload) + items = await b.list_containers() + + assert len(items) == 1 + assert items[0].name == "taos-agent-alice" + assert items[0].status == "running" + assert items[0].ip == "192.168.65.3" + assert items[0].memory_mb == 2048 + assert items[0].cpu_cores == 2 + + +@pytest.mark.asyncio +async def test_list_containers_returns_empty_on_failure(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (1, "container daemon not running") + items = await b.list_containers() + assert items == [] + + +@pytest.mark.asyncio +async def test_list_containers_returns_empty_on_bad_json(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "not json {{{") + items = await b.list_containers() + assert items == [] + + +@pytest.mark.asyncio +async def test_create_container_builds_argv(monkeypatch): + b = _backend(monkeypatch, "/usr/local/bin/container") + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "abc123") + result = await b.create_container( + name="taos-agent-bob", + image="docker.io/library/debian:bookworm", + memory_limit="2GB", + cpu_limit=2, + mounts=[("/host/data", "/data")], + env={"FOO": "bar"}, + ) + + assert result["success"] is True + argv = m.call_args.args[0] + assert argv[0] == "/usr/local/bin/container" + assert argv[1] == "run" + assert "-d" in argv + assert "--name" in argv and "taos-agent-bob" in argv + assert "--memory" in argv and "2g" in argv + assert "--cpus" in argv and "2" in argv + assert "-v" in argv and "/host/data:/data" in argv + assert "-e" in argv and "FOO=bar" in argv + assert "docker.io/library/debian:bookworm" in argv + + +@pytest.mark.asyncio +async def test_create_container_returns_failure(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (125, "image not found") + result = await b.create_container(name="taos-agent-bad", image="nonexistent") + assert result["success"] is False + assert "image not found" in result["output"] + + +@pytest.mark.asyncio +async def test_create_container_with_root_size_gib_does_not_orphan(monkeypatch): + """set_root_quota returns accounting-only success; create_container still succeeds.""" + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "abc123") + result = await b.create_container( + name="taos-agent-quota", + image="docker.io/library/debian:bookworm", + root_size_gib=10, + ) + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_exec_in_container(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "hello world\n") + code, output = await b.exec_in_container("taos-agent-x", ["echo", "hi"]) + assert code == 0 + assert "hello" in output + argv = m.call_args.args[0] + assert argv[1] == "exec" + assert "taos-agent-x" in argv + assert "echo" in argv and "hi" in argv + + +@pytest.mark.asyncio +async def test_push_file(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "") + code, output = await b.push_file("taos-agent-x", "/tmp/foo", "/etc/foo") + argv = m.call_args.args[0] + assert argv[1] == "cp" + assert "/tmp/foo" in argv + assert "taos-agent-x:/etc/foo" in argv + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method,verb,extra", + [ + ("start_container", "start", {}), + ("restart_container", "restart", {}), + ("destroy_container", "rm", {}), + ], +) +async def test_lifecycle_simple(monkeypatch, method, verb, extra): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "ok") + result = await getattr(b, method)("taos-agent-x", **extra) + argv = m.call_args.args[0] + assert argv[1] == verb + assert "taos-agent-x" in argv + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_stop_uses_kill_when_force(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "ok") + await b.stop_container("x", force=True) + assert m.call_args.args[0][1] == "kill" + + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "ok") + await b.stop_container("x", force=False) + assert m.call_args.args[0][1] == "stop" + + +@pytest.mark.asyncio +async def test_destroy_force_removes_running(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "ok") + await b.destroy_container("x") + argv = m.call_args.args[0] + assert "-f" in argv # rm -f to remove running containers + + +@pytest.mark.asyncio +async def test_get_container_logs(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "line1\nline2\n") + out = await b.get_container_logs("x", lines=42) + argv = m.call_args.args[0] + assert argv[1] == "logs" + assert "--tail" in argv and "42" in argv + assert "x" in argv + assert "line1" in out + + +@pytest.mark.asyncio +async def test_rename_container(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "") + result = await b.rename_container("old", "new") + argv = m.call_args.args[0] + assert argv[1] == "rename" + assert argv[-2:] == ["old", "new"] + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_get_container_logs_returns_error_message_on_failure(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (1, "no such container: x") + out = await b.get_container_logs("x") + assert out.startswith("Error getting logs:") + assert "no such container" in out + + +@pytest.mark.asyncio +async def test_snapshot_create(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "sha256:deadbeef") + result = await b.snapshot_create("x", "v1") + argv = m.call_args.args[0] + assert argv[1] == "commit" + assert "x" in argv + assert "taos/v1:latest" in argv + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_snapshot_restore_unsupported(monkeypatch): + b = _backend(monkeypatch) + result = await b.snapshot_restore("x", "v1") + assert result["success"] is False + assert "not supported" in result["note"] + + +@pytest.mark.asyncio +async def test_snapshot_list_parses_taos_images(monkeypatch): + b = _backend(monkeypatch) + payload = '[{"reference":"taos/v1:latest"},{"reference":"taos/v2:latest"}]' + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, payload) + result = await b.snapshot_list("x") + assert result["success"] is True + assert result["snapshots"] == ["v1", "v2"] + + +@pytest.mark.asyncio +async def test_snapshot_create_failure(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (1, "no such container: x") + result = await b.snapshot_create("x", "v1") + assert result["success"] is False + assert "no such container" in result["output"] + + +@pytest.mark.asyncio +async def test_snapshot_list_returns_failure_on_nonzero_exit(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (1, "images daemon error") + result = await b.snapshot_list("x") + assert result["success"] is False + assert result["snapshots"] == [] + + +@pytest.mark.asyncio +async def test_snapshot_list_returns_failure_on_bad_json(monkeypatch): + b = _backend(monkeypatch) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, "not json {{{") + result = await b.snapshot_list("x") + assert result["success"] is False + assert result["snapshots"] == [] + + +@pytest.mark.asyncio +async def test_snapshot_list_excludes_non_taos_images(monkeypatch): + b = _backend(monkeypatch) + payload = ( + '[{"reference":"taos/v1:latest"},' + '{"reference":"library/debian:bookworm"},' + '{"reference":"taos/v2:latest"}]' + ) + with patch.object(b, "_run", new_callable=AsyncMock) as m: + m.return_value = (0, payload) + result = await b.snapshot_list("x") + assert result["success"] is True + assert result["snapshots"] == ["v1", "v2"] + + +@pytest.mark.asyncio +async def test_add_proxy_device_is_noop(monkeypatch): + b = _backend(monkeypatch) + result = await b.add_proxy_device("x", "litellm", "tcp:127.0.0.1:4000", + "tcp:127.0.0.1:4000") + assert result["success"] is True + assert "host.containers.internal" in result["note"] + + +@pytest.mark.asyncio +async def test_set_env_unsupported(monkeypatch): + b = _backend(monkeypatch) + result = await b.set_env("x", "KEY", "value") + assert result["success"] is False + assert "recreate" in result["note"] + + +@pytest.mark.asyncio +async def test_set_root_quota_accounting_only(monkeypatch): + b = _backend(monkeypatch) + result = await b.set_root_quota("x", 5) + assert result["success"] is True + assert "accounting-only" in result["note"] diff --git a/tests/test_container_detection.py b/tests/test_container_detection.py index db53f78e..ab451c46 100644 --- a/tests/test_container_detection.py +++ b/tests/test_container_detection.py @@ -1,9 +1,15 @@ +import os +import sys import pytest from unittest.mock import patch from tinyagentos.containers.backend import detect_runtime class TestDetectRuntime: + @pytest.fixture(autouse=True) + def clear_apple_env(self, monkeypatch): + monkeypatch.delenv("TAOS_CONTAINER_BIN", raising=False) + def test_detect_lxc(self): with patch("shutil.which", side_effect=lambda x: "/usr/bin/incus" if x == "incus" else None): assert detect_runtime() == "lxc" @@ -27,3 +33,35 @@ def which(cmd): return None with patch("shutil.which", side_effect=which): assert detect_runtime() == "lxc" + + +class TestDetectRuntimeApple: + def test_apple_selected_on_darwin_with_env(self): + with patch.object(sys, "platform", "darwin"), \ + patch.dict(os.environ, {"TAOS_CONTAINER_BIN": "/x/container"}, clear=False), \ + patch("shutil.which", return_value=None): + assert detect_runtime() == "apple" + + def test_apple_wins_over_docker_on_darwin(self): + with patch.object(sys, "platform", "darwin"), \ + patch.dict(os.environ, {"TAOS_CONTAINER_BIN": "/x/container"}, clear=False), \ + patch("shutil.which", side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None): + assert detect_runtime() == "apple" + + def test_no_apple_without_env_var(self, monkeypatch): + monkeypatch.delenv("TAOS_CONTAINER_BIN", raising=False) + with patch.object(sys, "platform", "darwin"), \ + patch("shutil.which", side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None): + assert detect_runtime() == "docker" + + def test_apple_not_selected_on_linux_even_with_env(self, monkeypatch): + monkeypatch.setenv("TAOS_CONTAINER_BIN", "/x/container") + with patch.object(sys, "platform", "linux"), \ + patch("shutil.which", side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None): + assert detect_runtime() == "docker" + + def test_apple_wins_over_lxc_on_darwin(self): + with patch.object(sys, "platform", "darwin"), \ + patch.dict(os.environ, {"TAOS_CONTAINER_BIN": "/x/container"}, clear=False), \ + patch("shutil.which", side_effect=lambda x: "/usr/bin/incus" if x == "incus" else None): + assert detect_runtime() == "apple" diff --git a/tests/test_main_entry.py b/tests/test_main_entry.py new file mode 100644 index 00000000..4ae9e511 --- /dev/null +++ b/tests/test_main_entry.py @@ -0,0 +1,42 @@ +"""Verify python -m tinyagentos respects TAOS_HOST/TAOS_PORT env vars.""" +from __future__ import annotations + +from unittest.mock import patch + + +def test_main_uses_env_host_port(monkeypatch): + monkeypatch.setenv("TAOS_HOST", "127.0.0.1") + monkeypatch.setenv("TAOS_PORT", "7117") + from tinyagentos import __main__ as m + + captured = {} + + def fake_run(app, host, port, **kwargs): + captured["host"] = host + captured["port"] = port + + with patch("uvicorn.run", side_effect=fake_run), \ + patch.object(m, "create_app", return_value=object()): + m.main() + + assert captured["host"] == "127.0.0.1" + assert captured["port"] == 7117 + + +def test_main_falls_back_to_config_when_env_unset(monkeypatch): + monkeypatch.delenv("TAOS_HOST", raising=False) + monkeypatch.delenv("TAOS_PORT", raising=False) + from tinyagentos import __main__ as m + + captured = {} + + def fake_run(app, host, port, **kwargs): + captured["host"] = host + captured["port"] = port + + with patch("uvicorn.run", side_effect=fake_run), \ + patch.object(m, "create_app", return_value=object()): + m.main() + + assert captured["host"] == "0.0.0.0" + assert captured["port"] == 6969 diff --git a/tests/test_routes_settings.py b/tests/test_routes_settings.py index 9c0fdee0..581d723c 100644 --- a/tests/test_routes_settings.py +++ b/tests/test_routes_settings.py @@ -104,6 +104,16 @@ async def test_set_container_runtime(self, client): assert resp.status_code == 200 assert resp.json()["status"] == "updated" + @pytest.mark.asyncio + async def test_set_apple_runtime(self, client): + resp = await client.put( + "/api/settings/container-runtime", + content='{"runtime": "apple"}', + headers={"content-type": "application/json"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "updated" + @pytest.mark.asyncio async def test_set_invalid_runtime(self, client): resp = await client.put( diff --git a/tinyagentos/__main__.py b/tinyagentos/__main__.py new file mode 100644 index 00000000..07d3902d --- /dev/null +++ b/tinyagentos/__main__.py @@ -0,0 +1,64 @@ +"""Module entry: ``python -m tinyagentos``. + +Honours ``TAOS_HOST`` / ``TAOS_PORT`` env vars (used by the Mac launcher +to bind to a private 127.0.0.1 port) and falls back to ``data/config.yaml`` +when they are unset (preserves the existing console-script behaviour). +""" +from __future__ import annotations + +import os +from pathlib import Path + +from tinyagentos.app import PROJECT_DIR, create_app, load_config + + +def main() -> None: + import uvicorn + + env_host = os.environ.get("TAOS_HOST") + env_port = os.environ.get("TAOS_PORT") + env_data_dir = os.environ.get("TAOS_DATA_DIR") + + data_dir = Path(env_data_dir) if env_data_dir else None + if data_dir is not None: + _seed_data_dir(data_dir) + + config_path = (data_dir or (PROJECT_DIR / "data")) / "config.yaml" + + if env_host or env_port: + host = env_host or "127.0.0.1" + port = int(env_port) if env_port else 6969 + else: + config = load_config(config_path) + host = config.server.get("host", "0.0.0.0") + port = config.server.get("port", 6969) + + app = create_app(data_dir=data_dir) + uvicorn.run(app, host=host, port=port) + + +def _seed_data_dir(target: Path) -> None: + """Copy bundled data/ skeleton into target on first run. + + Existing files are preserved; only missing ones get copied. This lets the + embedded server boot in ~/Library/Application Support/taOS without the + user supplying a config.yaml. + """ + import shutil + + target.mkdir(parents=True, exist_ok=True) + source = PROJECT_DIR / "data" + if not source.exists(): + return + for entry in source.rglob("*"): + rel = entry.relative_to(source) + dest = target / rel + if entry.is_dir(): + dest.mkdir(parents=True, exist_ok=True) + elif not dest.exists(): + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(entry, dest) + + +if __name__ == "__main__": + main() diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 0f3e9685..bb89f517 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -591,7 +591,10 @@ async def _reload_llm_proxy_on_catalog_change() -> None: runtime = getattr(config, "container_runtime", "auto") if runtime == "auto": runtime = detect_runtime() - if runtime == "lxc": + if runtime == "apple": + from tinyagentos.containers.apple_backend import AppleContainerBackend + set_backend(AppleContainerBackend()) + elif runtime == "lxc": set_backend(LXCBackend()) elif runtime in ("docker", "podman"): set_backend(DockerBackend(binary=runtime)) @@ -790,7 +793,10 @@ async def _reload_llm_proxy_on_catalog_change() -> None: _runtime = getattr(config, "container_runtime", "auto") if _runtime == "auto": _runtime = detect_runtime() - if _runtime == "lxc": + if _runtime == "apple": + from tinyagentos.containers.apple_backend import AppleContainerBackend + set_backend(AppleContainerBackend()) + elif _runtime == "lxc": set_backend(LXCBackend()) elif _runtime in ("docker", "podman"): set_backend(DockerBackend(binary=_runtime)) diff --git a/tinyagentos/containers/apple_backend.py b/tinyagentos/containers/apple_backend.py new file mode 100644 index 00000000..026444ea --- /dev/null +++ b/tinyagentos/containers/apple_backend.py @@ -0,0 +1,229 @@ +"""Apple Containerization backend — shells out to apple/container CLI. + +The Mac .app launcher injects ``TAOS_CONTAINER_BIN`` pointing at the +bundled CLI under ``Contents/Resources/bin/container``. On developer +machines without the .app, falls back to ``container`` on ``PATH``. + +All ``subprocess`` calls go through ``asyncio.create_subprocess_exec`` +(no shell). Failure shape matches the other backends: +``{success: bool, output: str, note?: str}``. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os + +from .backend import ContainerBackend, ContainerInfo, _parse_memory + +logger = logging.getLogger(__name__) + + +class AppleContainerBackend(ContainerBackend): + def __init__(self) -> None: + self.binary = os.environ.get("TAOS_CONTAINER_BIN", "container") + + async def _run(self, cmd: list[str], timeout: int = 120) -> tuple[int, str]: + """Run a command and return a normalised (returncode, output). + + Callers expect a tuple even when the binary is missing or the command + times out, so both failure modes are mapped to non-zero exit codes + instead of propagating exceptions. ``asyncio.wait_for`` cancels the + coroutine but does not stop the child, so we terminate it explicitly + on timeout to avoid leaking processes. + """ + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + except FileNotFoundError as exc: + return 127, f"{self.binary}: {exc}" + + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=5) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 124, f"command timed out after {timeout}s: {' '.join(cmd)}" + + return proc.returncode, stdout.decode(errors="replace") if stdout else "" + + async def list_containers(self, prefix: str = "taos-agent-") -> list[ContainerInfo]: + """List all containers whose name starts with prefix.""" + code, output = await self._run([self.binary, "ls", "-a", "--format", "json"]) + if code != 0: + logger.error("apple container ls failed: %s", output) + return [] + try: + items = json.loads(output) if output.strip() else [] + except json.JSONDecodeError: + logger.error("apple container ls returned non-JSON: %s", output[:200]) + return [] + + results: list[ContainerInfo] = [] + for it in items: + name = it.get("name", "") + if not name.startswith(prefix): + continue + results.append( + ContainerInfo( + name=name, + status=it.get("status", "unknown"), + ip=it.get("ip"), + memory_mb=_parse_memory(str(it.get("memory", "0"))), + cpu_cores=int(it.get("cpus", 0) or 0), + ) + ) + return results + + async def set_root_quota(self, name: str, size_gib: int) -> dict: + return { + "success": True, + "output": "", + "note": "apple container CLI exposes no rootfs quota; accounting-only", + } + + async def create_container( + self, + name: str, + image: str = "docker.io/library/debian:bookworm", + memory_limit: str | None = None, + cpu_limit: int | None = None, + mounts: list[tuple[str, str]] | None = None, + env: dict[str, str] | None = None, + host_uid: int | None = None, + root_size_gib: int | None = None, + ) -> dict: + # Apple's container CLI does not currently expose host UID mapping for + # bind mounts. Reject the request explicitly so callers don't silently + # get root-owned files inside the container. + if host_uid is not None: + return { + "success": False, + "output": "", + "note": "apple container backend does not support host_uid mapping yet", + } + argv = [self.binary, "run", "-d", "--name", name] + if memory_limit: + # Convert "2GB"/"512MB" → "2g"/"512m" for Apple CLI + ml = memory_limit.strip().lower().replace("gb", "g").replace("mb", "m") + argv += ["--memory", ml] + if cpu_limit: + argv += ["--cpus", str(cpu_limit)] + for host_path, guest_path in mounts or []: + argv += ["-v", f"{host_path}:{guest_path}"] + for key, value in (env or {}).items(): + argv += ["-e", f"{key}={value}"] + argv.append(image) + + code, output = await self._run(argv) + if code != 0: + return {"success": False, "output": output} + + if root_size_gib is not None: + quota_result = await self.set_root_quota(name, root_size_gib) + if isinstance(quota_result, dict) and not quota_result.get("success"): + logger.warning( + "set_root_quota for %s did not succeed: %s", + name, + quota_result.get("note") or quota_result.get("output"), + ) + + return {"success": True, "output": output.strip()} + + async def exec_in_container( + self, name: str, cmd: list[str], timeout: int = 300 + ) -> tuple[int, str]: + return await self._run([self.binary, "exec", name, *cmd], timeout=timeout) + + async def push_file( + self, name: str, local_path: str, remote_path: str + ) -> tuple[int, str]: + return await self._run( + [self.binary, "cp", local_path, f"{name}:{remote_path}"] + ) + + async def start_container(self, name: str) -> dict: + code, output = await self._run([self.binary, "start", name]) + return {"success": code == 0, "output": output} + + async def stop_container(self, name: str, force: bool = False) -> dict: + verb = "kill" if force else "stop" + code, output = await self._run([self.binary, verb, name]) + return {"success": code == 0, "output": output} + + async def restart_container(self, name: str) -> dict: + code, output = await self._run([self.binary, "restart", name]) + return {"success": code == 0, "output": output} + + async def destroy_container(self, name: str) -> dict: + code, output = await self._run([self.binary, "rm", "-f", name]) + return {"success": code == 0, "output": output} + + async def get_container_logs(self, name: str, lines: int = 100) -> str: + code, output = await self._run( + [self.binary, "logs", "--tail", str(lines), name] + ) + return output if code == 0 else f"Error getting logs: {output}" + + async def rename_container(self, old_name: str, new_name: str) -> dict: + code, output = await self._run( + [self.binary, "rename", old_name, new_name] + ) + return {"success": code == 0, "output": output} + + async def add_proxy_device( + self, name: str, device_name: str, listen: str, connect: str, + bind_mode: str | None = None, + ) -> dict: + return { + "success": True, + "output": "", + "note": "apple containers reach host services via host.containers.internal", + } + + async def snapshot_create(self, name: str, snapshot_name: str) -> dict: + code, output = await self._run( + [self.binary, "commit", name, f"taos/{snapshot_name}:latest"] + ) + return {"success": code == 0, "output": output} + + async def snapshot_restore(self, name: str, snapshot_name: str) -> dict: + return { + "success": False, + "output": "", + "note": "apple container snapshot restore not supported", + } + + async def snapshot_list(self, name: str) -> dict: + code, output = await self._run( + [self.binary, "images", "ls", "--format", "json"] + ) + if code != 0: + return {"success": False, "snapshots": [], "output": output} + try: + items = json.loads(output) if output.strip() else [] + except json.JSONDecodeError: + return {"success": False, "snapshots": [], "output": output} + + snapshots: list[str] = [] + for it in items: + ref = it.get("reference", "") + if ref.startswith("taos/"): + tag = ref[len("taos/"):].split(":", 1)[0] + snapshots.append(tag) + return {"success": True, "snapshots": snapshots, "output": output} + + async def set_env(self, name: str, key: str, value: str) -> dict: + return { + "success": False, + "output": "", + "note": "apple container env change requires recreate", + } diff --git a/tinyagentos/containers/backend.py b/tinyagentos/containers/backend.py index 79553283..778e5da4 100644 --- a/tinyagentos/containers/backend.py +++ b/tinyagentos/containers/backend.py @@ -2,7 +2,9 @@ from __future__ import annotations import logging +import os import shutil +import sys from abc import ABC, abstractmethod from dataclasses import dataclass @@ -201,14 +203,16 @@ async def set_env(self, name: str, key: str, value: str) -> dict: def detect_runtime() -> str: """Detect the available container runtime. - Checks for incus, docker, podman in priority order. - Returns 'lxc', 'docker', 'podman', or 'none'. + On macOS, the Mac launcher signals its bundled apple/container CLI by + setting ``TAOS_CONTAINER_BIN``. When present, the apple backend wins + over every other runtime (Mac .app is a self-contained product). - Policy: LXC (incus) is preferred when multiple runtimes are present. - Docker and podman are supported for app-store services but never take - precedence over LXC for agent deployment. + Otherwise checks for incus, docker, podman in priority order. + Returns 'apple', 'lxc', 'docker', 'podman', or 'none'. """ available = [] + if sys.platform == "darwin" and os.environ.get("TAOS_CONTAINER_BIN"): + available.append("apple") if shutil.which("incus"): available.append("lxc") if shutil.which("docker"): @@ -216,9 +220,9 @@ def detect_runtime() -> str: if shutil.which("podman"): available.append("podman") - if "lxc" in available: - # LXC is preferred: full-OS containers align with taOS's host-owns-state - # model; Docker coexists for app-store services but never displaces LXC. + if "apple" in available: + selected = "apple" + elif "lxc" in available: selected = "lxc" elif available: selected = available[0] @@ -226,7 +230,7 @@ def detect_runtime() -> str: selected = "none" logger.info( - "detect_runtime: selected=%s, available=%s, policy=lxc-preferred", + "detect_runtime: selected=%s, available=%s, policy=apple-on-mac>lxc>others", selected, available, ) diff --git a/tinyagentos/requirements.lock b/tinyagentos/requirements.lock new file mode 100644 index 00000000..74edb855 --- /dev/null +++ b/tinyagentos/requirements.lock @@ -0,0 +1,144 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/tinyagentos/requirements.lock /Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml +# +aiosqlite==0.22.1 + # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +annotated-doc==0.0.4 + # via + # fastapi + # typer +annotated-types==0.7.0 + # via pydantic +anyio==4.13.0 + # via + # httpx + # starlette + # watchfiles +certifi==2026.4.22 + # via + # httpcore + # httpx +click==8.3.3 + # via + # typer + # uvicorn +fastapi==0.136.1 + # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +filelock==3.29.0 + # via huggingface-hub +flatbuffers==25.12.19 + # via onnxruntime +fsspec==2026.3.0 + # via huggingface-hub +h11==0.16.0 + # via + # httpcore + # uvicorn +hf-xet==1.4.3 + # via huggingface-hub +httpcore==1.0.9 + # via httpx +httptools==0.7.1 + # via uvicorn +httpx==0.28.1 + # via + # huggingface-hub + # tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +huggingface-hub==1.12.0 + # via + # tokenizers + # transformers +idna==3.13 + # via + # anyio + # httpx +jinja2==3.1.6 + # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +libtorrent==2.0.11 + # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +markdown-it-py==4.0.0 + # via rich +markupsafe==3.0.3 + # via jinja2 +mdurl==0.1.2 + # via markdown-it-py +numpy==2.4.4 + # via + # onnxruntime + # taosmd + # transformers +onnxruntime==1.25.1 + # via taosmd +packaging==26.2 + # via + # huggingface-hub + # onnxruntime + # transformers +protobuf==7.34.1 + # via onnxruntime +psutil==7.2.2 + # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +pydantic==2.13.3 + # via fastapi +pydantic-core==2.46.3 + # via pydantic +pygments==2.20.0 + # via rich +python-dotenv==1.2.2 + # via uvicorn +python-multipart==0.0.27 + # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +pyyaml==6.0.3 + # via + # huggingface-hub + # tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) + # transformers + # uvicorn +regex==2026.4.4 + # via transformers +rich==15.0.0 + # via typer +safetensors==0.7.0 + # via transformers +shellingham==1.5.4 + # via typer +starlette==1.0.0 + # via fastapi +taosmd @ git+https://github.com/jaylfc/taosmd.git@master + # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +tokenizers==0.22.2 + # via transformers +tqdm==4.67.3 + # via + # huggingface-hub + # transformers +transformers==5.6.2 + # via taosmd +typer==0.25.0 + # via + # huggingface-hub + # transformers +typing-extensions==4.15.0 + # via + # anyio + # fastapi + # huggingface-hub + # pydantic + # pydantic-core + # starlette + # typing-inspection +typing-inspection==0.4.2 + # via + # fastapi + # pydantic +uvicorn[standard]==0.46.0 + # via tinyagentos (/Volumes/NVMe/Users/jay/Development/tinyagentos-mac-c1/pyproject.toml) +uvloop==0.22.1 + # via uvicorn +watchfiles==1.1.1 + # via uvicorn +websockets==16.0 + # via uvicorn diff --git a/tinyagentos/routes/settings.py b/tinyagentos/routes/settings.py index 1f2d2e23..478a6d7f 100644 --- a/tinyagentos/routes/settings.py +++ b/tinyagentos/routes/settings.py @@ -409,7 +409,7 @@ async def set_container_runtime(request: Request): """Set the container runtime preference.""" body = await request.json() runtime = body.get("runtime", "auto") - if runtime not in ("auto", "lxc", "docker", "podman"): + if runtime not in ("auto", "apple", "lxc", "docker", "podman"): return JSONResponse({"error": f"Invalid runtime: {runtime}"}, status_code=400) config = request.app.state.config config.container_runtime = runtime @@ -421,7 +421,10 @@ async def set_container_runtime(request: Request): effective = runtime if runtime == "auto": effective = detect_runtime() - if effective == "lxc": + if effective == "apple": + from tinyagentos.containers.apple_backend import AppleContainerBackend + set_backend(AppleContainerBackend()) + elif effective == "lxc": set_backend(LXCBackend()) elif effective in ("docker", "podman"): set_backend(DockerBackend(binary=effective))