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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+89
@@ -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."
|
||||
Executable
+173
@@ -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"
|
||||
@@ -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
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+44
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Reference in New Issue
Block a user