Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 19:34:32 +03:00
commit 53b4f60da1
158 changed files with 29442 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
// RenderOutfits.swift — standalone planche renderer for Mochi outfits
// Compile + run via: bash scripts/render-outfits.sh
// Output: /tmp/coucou-outfits.png
import Foundation
import SwiftUI
import AppKit
// MARK: - Stubs (substitutes for app-only types)
@MainActor
final class SoundEngine {
static let shared = SoundEngine()
var enabled: Bool = false
func play(_ name: String) {}
}
extension Notification.Name {
static let botDizzy = Notification.Name("notchBuddy.botDizzy")
static let botGreet = Notification.Name("notchBuddy.botGreet")
static let botBlink = Notification.Name("notchBuddy.botBlink")
static let botSetTgEs = Notification.Name("notchBuddy.botSetTgEs")
static let botGulp = Notification.Name("notchBuddy.botGulp")
static let botMorphTo = Notification.Name("notchBuddy.botMorphTo")
static let triggerEmote = Notification.Name("notchBuddy.triggerEmote")
static let triggerSlap = Notification.Name("notchBuddy.triggerSlap")
static let greetComplete = Notification.Name("notchBuddy.greetComplete")
static let greetingHover = Notification.Name("notchBuddy.greetingHover")
static let greetingInterrupt = Notification.Name("notchBuddy.greetingInterrupt")
static let islandAction = Notification.Name("notchBuddy.islandAction")
static let islandCollapse = Notification.Name("notchBuddy.islandCollapse")
static let hookReveal = Notification.Name("notchBuddy.hookReveal")
static let musicReveal = Notification.Name("notchBuddy.musicReveal")
static let openFullSettings = Notification.Name("notchBuddy.openFullSettings")
}
extension Color {
init(hex: String) {
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
let val = UInt64(h, radix: 16) ?? 0
let r = Double((val >> 16) & 0xFF) / 255
let g = Double((val >> 8) & 0xFF) / 255
let b = Double( val & 0xFF) / 255
self.init(red: r, green: g, blue: b)
}
}
// MARK: - Cell view — one outfit at one pose
struct MochiCell: View {
let outfit: Outfit
let yaw: CGFloat
let pitch: CGFloat
let tilt: CGFloat
let phys: (dx: CGFloat, dy: CGFloat)
let cellSize: CGFloat
var roll: CGFloat = 0
var presence: CGFloat = 1
var body: some View {
Canvas { context, sz in
let W = sz.width, H = sz.height
// scale=0.62, R = W * 0.62 * 0.3
let R = W * 0.62 * 0.3
let rx = R * 1.14
let ry = R * 0.88
let cx = W / 2
let cy = H / 2 + R * 0.06
let sx: CGFloat = 1, sy: CGFloat = 1
let morph: CGFloat = 0
let mH = MochiH(R: R, yaw: yaw, pitch: pitch, physDx: phys.dx, physDy: phys.dy, roll: roll)
// 1. Behind-body outfit
drawOutfitBehindStatic(
context: context, outfit: outfit, H: mH,
cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy,
roll: roll, morph: morph, isMini: false, presence: presence
)
// 2. Body (replicate BotEngine.drawBody for idle / pumpkin state)
var bCtx = context
bCtx.translateBy(x: cx, y: cy)
if tilt != 0 { bCtx.rotate(by: .radians(tilt)) }
bCtx.scaleBy(x: sx, y: sy)
let body = mochiOutfitPath(rx, ry)
// Base gradient (pumpkin-aware)
let pumpkin = outfit == .pumpkin
let cTop: Color = pumpkin ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937)
let cBot: Color = pumpkin ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792)
bCtx.fill(body, with: .linearGradient(
Gradient(colors: [cTop, cBot]),
startPoint: CGPoint(x: rx * 0.7, y: -ry * 0.85),
endPoint: CGPoint(x: -rx * 0.8, y: ry * 0.9)
))
// Shadow rim
bCtx.fill(body, with: .radialGradient(
Gradient(stops: [
.init(color: .clear, location: 0.6),
.init(color: Color.black.opacity(0.2), location: 1)
]),
center: .zero, startRadius: R * 0.15, endRadius: R * 1.25
))
// Top-left highlight
bCtx.fill(body, with: .radialGradient(
Gradient(stops: [
.init(color: Color.white.opacity(0.55), location: 0),
.init(color: .clear, location: 1)
]),
center: CGPoint(x: rx * 0.34, y: -ry * 0.46),
startRadius: 0, endRadius: R * 0.42
))
// 3. Eyes (clipped to body)
var eyeBase = bCtx
eyeBase.clip(to: body)
let ink = Color(red: 0.102, green: 0.082, blue: 0.071)
for f in mEyeFrames(mH) {
guard f.visible else { continue }
var eCtx = eyeBase
eCtx.translateBy(x: f.x, y: f.y)
eCtx.scaleBy(x: f.fx, y: f.fy)
let hh = max(f.h, f.w * 0.3)
var pill = Path()
pill.addRoundedRect(
in: CGRect(x: -f.w / 2, y: -hh / 2, width: f.w, height: hh),
cornerSize: CGSize(width: min(f.w / 2, hh / 2), height: min(f.w / 2, hh / 2))
)
eCtx.fill(pill, with: .color(ink))
}
// 4. Front outfit
drawOutfitFrontStatic(
context: context, outfit: outfit, H: mH,
cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy,
roll: roll, morph: morph, isMini: false, presence: presence
)
}
.frame(width: cellSize, height: cellSize)
}
}
// MARK: - Grid view
// Outfit order matching sheet.html
private let targetOutfits: [Outfit] = [
.none, .beanie, .santaHat, .partyHat, .crown, .witchHat,
.sunglasses, .roundGlasses, .scarf, .pumpkin, .bow,
]
private struct PoseSpec {
let label: String
let yaw: CGFloat
let pitch: CGFloat
let tilt: CGFloat
let phys: (dx: CGFloat, dy: CGFloat)
let size: CGFloat
}
// Columns matching sheet.html cols array + small pill-preview column (W=190, scale=0.62; small=64)
private let poses: [PoseSpec] = [
PoseSpec(label: "L", yaw: -0.5, pitch: 0, tilt: 0, phys: (0.6, 0), size: 190),
PoseSpec(label: "front", yaw: 0, pitch: 0, tilt: 0, phys: (0, 0), size: 190),
PoseSpec(label: "R", yaw: 0.5, pitch: 0, tilt: 0, phys: (-0.6, 0), size: 190),
PoseSpec(label: "up", yaw: 0.15, pitch: 0.4, tilt: 0, phys: (0, 0.4), size: 190),
PoseSpec(label: "down", yaw: -0.2, pitch: -0.5, tilt: 0, phys: (0, -0.4), size: 190),
PoseSpec(label: "tilt", yaw: 0.3, pitch: -0.25, tilt: 0.12, phys: (-0.3, 0), size: 190),
PoseSpec(label: "mini", yaw: 0, pitch: 0, tilt: 0, phys: (0, 0), size: 64),
]
private let labelW: CGFloat = 90
private let gap: CGFloat = 6
struct OutfitGrid: View {
var body: some View {
VStack(alignment: .leading, spacing: gap) {
// Column headers
HStack(spacing: gap) {
Spacer().frame(width: labelW)
ForEach(poses.indices, id: \.self) { i in
Text(poses[i].label)
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
.frame(width: poses[i].size, alignment: .center)
}
}
// One row per outfit
ForEach(targetOutfits.indices, id: \.self) { oi in
let outfit = targetOutfits[oi]
HStack(spacing: gap) {
Text(outfit.rawValue)
.font(.system(size: 10))
.foregroundColor(.white)
.frame(width: labelW, alignment: .trailing)
ForEach(poses.indices, id: \.self) { pi in
let p = poses[pi]
MochiCell(outfit: outfit, yaw: p.yaw, pitch: p.pitch,
tilt: p.tilt, phys: p.phys, cellSize: p.size)
.background(pi == poses.count - 1
? Color.black
: Color(red: 0.083, green: 0.090, blue: 0.106))
.clipShape(RoundedRectangle(cornerRadius: pi == poses.count - 1 ? 0 : 8))
}
}
}
}
.padding(12)
.background(Color(red: 0.043, green: 0.047, blue: 0.055))
}
}
// MARK: - Roll planche
private let rollOutfits: [Outfit] = [
.none, .beanie, .santaHat, .witchHat, .crown,
.sunglasses, .roundGlasses, .scarf, .bow, .pumpkin, .bunnyEars
]
private let rollValues: [CGFloat] = [0, CGFloat.pi/3, CGFloat.pi*2/3, CGFloat.pi, CGFloat.pi*4/3, CGFloat.pi*5/3]
struct RollGrid: View {
var body: some View {
VStack(alignment: .leading, spacing: gap) {
// Column headers
HStack(spacing: gap) {
Spacer().frame(width: labelW)
ForEach(rollValues.indices, id: \.self) { i in
Text(String(format: "%.2fπ", rollValues[i] / .pi))
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
.frame(width: 120, alignment: .center)
}
}
ForEach(rollOutfits.indices, id: \.self) { oi in
let outfit = rollOutfits[oi]
HStack(spacing: gap) {
Text(outfit.rawValue)
.font(.system(size: 10))
.foregroundColor(.white)
.frame(width: labelW, alignment: .trailing)
ForEach(rollValues.indices, id: \.self) { ri in
MochiCell(outfit: outfit, yaw: 0, pitch: 0, tilt: 0, phys: (0, 0),
cellSize: 120, roll: rollValues[ri])
.background(Color(red: 0.083, green: 0.090, blue: 0.106))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
.padding(12)
.background(Color(red: 0.043, green: 0.047, blue: 0.055))
}
}
// MARK: - Transition planche
private let transitionOutfits: [Outfit] = [.witchHat, .santaHat, .bunnyEars, .scarf]
private let presenceValues: [CGFloat] = [0, 0.3, 0.6, 1]
struct TransitionGrid: View {
var body: some View {
VStack(alignment: .leading, spacing: gap) {
// Column headers
HStack(spacing: gap) {
Spacer().frame(width: labelW)
ForEach(presenceValues.indices, id: \.self) { i in
Text(String(format: "p=%.1f", presenceValues[i]))
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
.frame(width: 120, alignment: .center)
}
}
ForEach(transitionOutfits.indices, id: \.self) { oi in
let outfit = transitionOutfits[oi]
HStack(spacing: gap) {
Text(outfit.rawValue)
.font(.system(size: 10))
.foregroundColor(.white)
.frame(width: labelW, alignment: .trailing)
ForEach(presenceValues.indices, id: \.self) { pi in
MochiCell(outfit: outfit, yaw: 0, pitch: 0, tilt: 0, phys: (0, 0),
cellSize: 120, presence: presenceValues[pi])
.background(Color(red: 0.083, green: 0.090, blue: 0.106))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
.padding(12)
.background(Color(red: 0.043, green: 0.047, blue: 0.055))
}
}
// MARK: - Helper to render and save a view
@MainActor
private func renderAndSave<V: View>(_ view: V, path: String) {
let renderer = ImageRenderer(content: view)
renderer.scale = 2.0
guard let nsImage = renderer.nsImage else {
print("Error: ImageRenderer returned nil for \(path)")
return
}
guard let tiff = nsImage.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:])
else {
print("Error: PNG conversion failed for \(path)")
return
}
do {
try png.write(to: URL(fileURLWithPath: path))
print("✓ \(path) — \(Int(nsImage.size.width))×\(Int(nsImage.size.height)) @ 2×")
} catch {
print("Error writing \(path): \(error)")
}
}
// MARK: - Entry point
@main
struct RenderOutfits {
static func main() {
MainActor.assumeIsolated {
renderAndSave(OutfitGrid(), path: "/tmp/coucou-outfits.png")
renderAndSave(RollGrid(), path: "/tmp/coucou-roll.png")
renderAndSave(TransitionGrid(), path: "/tmp/coucou-transition.png")
}
}
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# iPhone plan, step 2: builds the GitHub app (fr.louisraille.NotchBuddy) the
# way release.sh does — Release optimizations, Developer ID signature — plus
# the "Coucou Developer ID" provisioning profile, the iCloud entitlements and
# the PHONE_LINK probe. Not notarized, not published: it only proves that a
# Developer ID build can talk to iCloud. A Developer ID profile only allows the
# Production CloudKit environment: the Ping/Pong schema must be deployed to
# Production in the CloudKit Console first.
#
# ./scripts/cloud-test-build.sh
#
# The normal release (scripts/release.sh, Release configuration) is unchanged.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="/tmp/coucou-cloud-test"
APP="$BUILD_DIR/Coucou.app"
PROFILE_NAME="Coucou Developer ID"
APP_ID="256AUJ9555.fr.louisraille.NotchBuddy"
CONTAINER="iCloud.fr.louisraille.Coucou"
die() { echo "error: $*" >&2; exit 1; }
# ── 1. Developer ID identity ──────────────────────────────────────────────────
IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | sed 's/.*"\(Developer ID Application[^"]*\)".*/\1/' || true)
[ -n "$IDENTITY" ] || die "no 'Developer ID Application' certificate found. Install it via Xcode → Settings → Accounts."
echo "Signing with: $IDENTITY"
# ── 2. Provisioning profile ───────────────────────────────────────────────────
# Xcode looks in both folders depending on its version. Double-clicking a
# downloaded .provisionprofile on macOS doesn't reliably put it in either.
TMP_PLIST=$(mktemp)
trap 'rm -f "$TMP_PLIST"' EXIT
FOUND=""
for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" "$HOME/Library/MobileDevice/Provisioning Profiles"; do
[ -d "$dir" ] || continue
for f in "$dir"/*.provisionprofile; do
[ -f "$f" ] || continue
security cms -D -i "$f" > "$TMP_PLIST" 2>/dev/null || continue
NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST" 2>/dev/null || true)
[ "$NAME" = "$PROFILE_NAME" ] || continue
FOUND="$f"
break 2
done
done
[ -n "$FOUND" ] || die "no provisioning profile named '$PROFILE_NAME' is installed.
developer.apple.com → Profiles → + → Developer ID → App ID fr.louisraille.NotchBuddy,
name it exactly '$PROFILE_NAME' and download it. Then copy it for Xcode:
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\\ Profiles
cp ~/Downloads/*.provisionprofile ~/Library/Developer/Xcode/UserData/Provisioning\\ Profiles/"
echo "Profile: $FOUND"
PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.application-identifier" "$TMP_PLIST" 2>/dev/null || true)
[ "$PROFILE_APP_ID" = "$APP_ID" ] || die "the profile is for '$PROFILE_APP_ID', not $APP_ID"
/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.icloud-container-identifiers" "$TMP_PLIST" 2>/dev/null | grep -q "$CONTAINER" \
|| die "the profile has no iCloud container $CONTAINER. Turn on iCloud (CloudKit, with that container) on the App ID fr.louisraille.NotchBuddy, then regenerate and reinstall the profile."
EXPIRY=$(/usr/libexec/PlistBuddy -c "Print :ExpirationDate" "$TMP_PLIST")
echo "Profile expires: $EXPIRY"
# ── 3. Build ──────────────────────────────────────────────────────────────────
cd "$REPO_ROOT/NotchBuddy"
xcodegen generate
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR"
xcodebuild \
-project NotchBuddy.xcodeproj \
-scheme NotchBuddy \
-configuration ReleaseCloud \
build \
CODE_SIGN_IDENTITY="$IDENTITY" \
CONFIGURATION_BUILD_DIR="$BUILD_DIR" \
-quiet \
|| die "the build failed (see the errors above)"
# ── 4. Checks ─────────────────────────────────────────────────────────────────
[ -f "$APP/Contents/embedded.provisionprofile" ] || die "the profile was not embedded in the app"
codesign --verify --deep --strict "$APP" || die "the signature does not verify"
echo
echo "Entitlements of the built app:"
codesign -d --entitlements - --xml "$APP" 2>/dev/null | plutil -p - || true
echo
echo "✓ Built $APP (Developer ID, not notarized)."
echo
echo "To test:"
echo " 1. Quit any other Coucou (menu bar icon → Quit) and stop it in Xcode."
echo " 2. open $APP"
echo " 3. tail -f ~/Library/Logs/NotchBuddy/nb.log | grep PhoneLink"
echo " 4. Developer ID builds use the Production CloudKit environment: run the iPhone"
echo " app with the CoucouPhoneProduction scheme, pull to refresh, then tap Send pong."
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# Builds, notarizes and publishes Coucou for macOS (GitHub build).
#
# ./scripts/release.sh 0.1.2 build, sign, notarize, staple, tag, publish
# ./scripts/release.sh 0.1.2 --finish finish after an interrupted notarization wait
#
# Run it from a clean checkout of main. CFBundleShortVersionString in
# NotchBuddy/project.yml must match the version, and CHANGELOG.md needs a
# "## <version>" section: it becomes the release notes.
set -euo pipefail
VERSION="${1:?Usage: $0 <version> [--finish]}"
MODE="${2:-}"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="/tmp/coucou-release-$VERSION"
APP="$BUILD_DIR/Coucou.app"
ZIP="$BUILD_DIR/Coucou.zip"
COMMIT_FILE="$BUILD_DIR/commit"
TAG="v$VERSION"
die() { echo "error: $*" >&2; exit 1; }
case "$MODE" in
""|--finish) ;;
*) die "unknown option '$MODE' (the only option is --finish)" ;;
esac
cd "$REPO_ROOT"
# ── Checks shared by both modes ───────────────────────────────────────────────
command -v gh >/dev/null || die "gh is not installed (brew install gh)"
# The "## <version>" section of CHANGELOG.md, without leading or trailing blank lines.
CHANGES=$(awk -v head="## $VERSION" '
index($0, head " ") == 1 || $0 == head { found = 1; next }
found && /^## / { exit }
found {
if ($0 ~ /^[ \t\r]*$/) { if (started) blanks++; next }
while (blanks > 0) { print ""; blanks-- }
print; started = 1
}
' CHANGELOG.md)
[ -n "$CHANGES" ] || die "CHANGELOG.md has no '## $VERSION' section"
grep -q "| \[$VERSION\]" README.md || die "README.md has no row for $VERSION in the Versions table"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
die "tag $TAG already exists here"
fi
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
die "tag $TAG already exists on GitHub"
fi
if [ "$MODE" != "--finish" ]; then
# The release must match a commit: no uncommitted changes to tracked files.
if ! git diff --quiet || ! git diff --cached --quiet; then
die "commit or stash your changes first, the release has to match a commit"
fi
COMMIT=$(git rev-parse HEAD)
# ── 1. Developer ID identity ────────────────────────────────────────────────
# "|| true": with pipefail, grep finding nothing would end the script silently.
IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | sed 's/.*"\(Developer ID Application[^"]*\)".*/\1/' || true)
[ -n "$IDENTITY" ] || die "no 'Developer ID Application' certificate found. Install it via Xcode → Settings → Accounts."
echo "Signing with: $IDENTITY"
# ── 1b. Developer ID provisioning profile (iCloud for the iPhone sync) ──────
PROFILE_NAME="Coucou Developer ID"
PROFILE_FOUND=""
TMP_PLIST=$(mktemp)
for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" "$HOME/Library/MobileDevice/Provisioning Profiles"; do
[ -d "$dir" ] || continue
for f in "$dir"/*.provisionprofile; do
[ -f "$f" ] || continue
security cms -D -i "$f" > "$TMP_PLIST" 2>/dev/null || continue
[ "$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST" 2>/dev/null || true)" = "$PROFILE_NAME" ] || continue
PROFILE_FOUND="$f"
break 2
done
done
[ -n "$PROFILE_FOUND" ] || { rm -f "$TMP_PLIST"; die "no provisioning profile named '$PROFILE_NAME'. developer.apple.com → Profiles → Developer ID (Mac) for fr.louisraille.NotchBuddy, then copy it to ~/Library/Developer/Xcode/UserData/Provisioning Profiles/"; }
EXPIRY=$(/usr/libexec/PlistBuddy -c "Print :ExpirationDate" "$TMP_PLIST")
rm -f "$TMP_PLIST"
echo "Profile: $PROFILE_FOUND (expires $EXPIRY)"
# ── 2. xcodegen + Release build ─────────────────────────────────────────────
cd "$REPO_ROOT/NotchBuddy"
PLIST_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" Resources/Info.plist 2>/dev/null || true)
[ "$PLIST_VERSION" = "$VERSION" ] || die "NotchBuddy/Resources/Info.plist is version $PLIST_VERSION, not $VERSION: run xcodegen and commit Info.plist"
xcodegen generate
PLIST_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" Resources/Info.plist)
[ "$PLIST_VERSION" = "$VERSION" ] || die "project.yml says $PLIST_VERSION, not $VERSION: update CFBundleShortVersionString in NotchBuddy/project.yml"
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR"
echo "$COMMIT" > "$COMMIT_FILE"
xcodebuild \
-project NotchBuddy.xcodeproj \
-scheme NotchBuddy \
-configuration Release \
build \
CODE_SIGN_IDENTITY="$IDENTITY" \
CODE_SIGNING_REQUIRED=YES \
CODE_SIGNING_ALLOWED=YES \
CONFIGURATION_BUILD_DIR="$BUILD_DIR"
[ -f "$APP/Contents/embedded.provisionprofile" ] || die "the provisioning profile was not embedded in the app"
codesign -d --entitlements - --xml "$APP" 2>/dev/null | grep -q "iCloud.fr.louisraille.Coucou" \
|| die "the app is not signed with the iCloud entitlements"
# ── 3. Zip + notarize ───────────────────────────────────────────────────────
ditto -c -k --keepParent "$APP" "$ZIP"
echo
echo "Sending to Apple for notarization. If you stop the wait (Ctrl+C), Apple keeps going:"
echo " check it with xcrun notarytool history --keychain-profile coucou-notary"
echo " once Accepted ./scripts/release.sh $VERSION --finish"
echo
xcrun notarytool submit "$ZIP" --keychain-profile coucou-notary --wait \
|| die "notarization failed: see xcrun notarytool log <id> --keychain-profile coucou-notary (the id is printed above)"
cd "$REPO_ROOT"
else
[ -d "$APP" ] && [ -f "$COMMIT_FILE" ] || die "nothing to finish in $BUILD_DIR, run ./scripts/release.sh $VERSION first"
COMMIT=$(cat "$COMMIT_FILE")
fi
# ── 4. Staple + verify ────────────────────────────────────────────────────────
xcrun stapler staple "$APP" \
|| die "stapling failed: Apple has not accepted the build yet. Check xcrun notarytool history --keychain-profile coucou-notary, then run ./scripts/release.sh $VERSION --finish"
spctl -a -vv "$APP"
# ── 5. Re-zip (with the stapled app) ──────────────────────────────────────────
rm -f "$ZIP"
ditto -c -k --keepParent "$APP" "$ZIP"
echo "Release zip ready: $ZIP"
# ── 6. Tag the built commit + GitHub release ──────────────────────────────────
NOTES="Coucou $VERSION for macOS 15 or later (Apple silicon and Intel).
Signed with a Developer ID and notarized by Apple.
## What's new
$CHANGES
## Install
1. Download Coucou.zip below and unzip it.
2. Move Coucou.app to your Applications folder, replacing the old one if you have it.
3. Launch it, and click Open when macOS asks you to confirm.
Linux and Windows: see the [README](https://github.com/Louis-CFM/coucou#readme)."
echo
echo "──────── Release notes ────────"
echo "$NOTES"
echo "───────────────────────────────"
echo
read -r -p "Tag $(git rev-parse --short "$COMMIT") as $TAG and publish this release? [y/N] " ANSWER
case "$ANSWER" in
y|Y|yes|oui|o|O) ;;
*) die "stopped before tagging. Run ./scripts/release.sh $VERSION --finish to publish later." ;;
esac
git tag "$TAG" "$COMMIT"
git push origin "$TAG"
gh release create "$TAG" "$ZIP" \
--repo Louis-CFM/coucou \
--title "Coucou $VERSION" \
--latest \
--notes "$NOTES"
echo "✓ $TAG released: https://github.com/Louis-CFM/coucou/releases/tag/$TAG"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# render-outfits.sh — compile and run the Mochi outfit planche renderer
# NOT in CI. Run manually: bash scripts/render-outfits.sh
# Output: /tmp/coucou-outfits.png
set -e
cd "$(dirname "$0")/.."
SDK=$(xcrun --sdk macosx --show-sdk-path)
echo "Compiling renderer..."
swiftc \
-parse-as-library \
-sdk "$SDK" \
-target arm64-apple-macosx15.0 \
NotchBuddy/Sources/CoucouKit/IslandScreenGeometry.swift \
NotchBuddy/Sources/CoucouKit/IslandTypes.swift \
NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift \
NotchBuddy/Sources/CoucouKit/BotEngine.swift \
NotchBuddy/Sources/CoucouKit/MochiOutfitDrawing.swift \
scripts/RenderOutfits.swift \
-framework AppKit \
-framework SwiftUI \
-o /tmp/coucou-render-outfits \
2>&1
echo "Running renderer..."
/tmp/coucou-render-outfits
echo "Opening..."
open /tmp/coucou-outfits.png
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-ask-question.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/App/AskQuestion.swift \
tests/AskQuestionTests.swift -o "$TEST_DIR/ask-question-tests"
"$TEST_DIR/ask-question-tests"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-chat-parsing.XXXXXX")"
SERVER_PID=""
trap 'rm -rf "$TEST_DIR"; [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true' EXIT
PORT_FILE="$TEST_DIR/port.txt"
# ── Start fake LLM server ────────────────────────────────────────────────────
python3 tests/fake_local_llm.py "$PORT_FILE" &
SERVER_PID=$!
# ── Compile test binary (runs in parallel with server startup) ───────────────
# Compilation typically takes a few seconds, which gives the server plenty of
# time to bind and write its port — avoiding a busy-wait on fast machines.
swiftc \
NotchBuddy/Sources/App/LocalChat.swift \
NotchBuddy/Sources/App/ChatMarkdown.swift \
tests/ChatParsingTests.swift \
-o "$TEST_DIR/chat-parsing-tests"
# ── Wait for server port file (up to 30 s) ───────────────────────────────────
for i in $(seq 1 300); do
# Bail out immediately if the server process has already died
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "ERROR: fake LLM server process exited unexpectedly" >&2
exit 1
fi
[ -s "$PORT_FILE" ] && break
sleep 0.1
done
if [ ! -s "$PORT_FILE" ]; then
echo "ERROR: fake LLM server did not write its port within 30 s" >&2
exit 1
fi
PORT=$(cat "$PORT_FILE")
echo "Fake LLM server listening on port $PORT (PID $SERVER_PID)"
# ── Run tests ────────────────────────────────────────────────────────────────
"$TEST_DIR/chat-parsing-tests" "$PORT"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-desktop.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/App/DesktopMochiLogic.swift \
tests/DesktopMochiTests.swift -o "$TEST_DIR/desktop-mochi-tests"
"$TEST_DIR/desktop-mochi-tests"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-diff-engine.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/CoucouKit/DiffEngine.swift \
tests/DiffEngineTests.swift -o "$TEST_DIR/diff-engine-tests"
"$TEST_DIR/diff-engine-tests"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-github-activity.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/App/GitHubActivity.swift \
tests/GitHubActivityTests.swift -o "$TEST_DIR/github-activity-tests"
"$TEST_DIR/github-activity-tests"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-github-pulse.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/App/GitHubPulse.swift \
tests/GitHubPulseTests.swift -o "$TEST_DIR/github-pulse-tests"
"$TEST_DIR/github-pulse-tests"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-plan-gauge.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/App/ClaudePlanGauge.swift \
tests/ClaudePlanGaugeTests.swift -o "$TEST_DIR/plan-gauge-tests"
"$TEST_DIR/plan-gauge-tests"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-safe-links.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/App/SafeWebURL.swift \
tests/SafeWebURLTests.swift -o "$TEST_DIR/safe-links-tests"
"$TEST_DIR/safe-links-tests"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-geometry.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/CoucouKit/IslandScreenGeometry.swift \
tests/IslandScreenGeometryTests.swift -o "$TEST_DIR/geometry-tests"
"$TEST_DIR/geometry-tests"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-shortcuts.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/App/ShortcutLogic.swift \
tests/ShortcutTests.swift -o "$TEST_DIR/shortcut-tests"
"$TEST_DIR/shortcut-tests"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-wardrobe.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
swiftc NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift \
tests/MochiWardrobeTests.swift -o "$TEST_DIR/wardrobe-tests"
"$TEST_DIR/wardrobe-tests"