Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions imessage/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Generated by xcodegen — run `xcodegen generate` to recreate
*.xcodeproj/
BurrowCardsHost/Info.plist
BurrowCardsExtension/Info.plist
# Build output
build/
DerivedData/
45 changes: 45 additions & 0 deletions imessage/BurrowCardsExtension/ComposeGallery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// ComposeGallery.swift
// Shown in the extension when no card is selected — tap a sample to insert it
// into the thread. Lets you test the full send → bubble → tap loop by hand.
//

import SwiftUI

struct ComposeGallery: View {
let onInsert: (BurrowLayout) -> Void

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Insert a Burrow card")
.font(.headline)
.padding(.horizontal, 16)
.padding(.top, 12)

ForEach(BurrowSamples.all, id: \.name) { sample in
Button {
onInsert(sample.layout)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(sample.layout.title).font(.subheadline).fontWeight(.semibold)
if let s = sample.layout.subtitle {
Text(s).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "plus.circle.fill").foregroundStyle(.tint)
}
.padding(14)
.background(Color.secondary.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
}
}
.padding(.bottom, 16)
}
}
}
85 changes: 85 additions & 0 deletions imessage/BurrowCardsExtension/MessagesViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//
// MessagesViewController.swift
// Burrow Cards iMessage extension.
//
// Two jobs:
// 1. Render an incoming card — decode the selected message's `?p=` payload
// into a BurrowLayout and host the SwiftUI renderer.
// 2. Compose — when there's no card selected, show a gallery of sample cards
// to insert into the thread (so you can test send/tap without the sidecar).
//

import Messages
import SwiftUI

final class MessagesViewController: MSMessagesAppViewController {

/// Base URL the compose gallery encodes sample layouts into.
private let cardBase = URL(string: "https://burrow.henryzh.dev/card")!

override func willBecomeActive(with conversation: MSConversation) {
super.willBecomeActive(with: conversation)
present(for: conversation.selectedMessage)
}

override func didSelect(_ message: MSMessage, conversation: MSConversation) {
present(for: message)
}

override func didTransition(to presentationStyle: MSMessagesAppPresentationStyle) {
present(for: activeConversation?.selectedMessage)
}

// MARK: - Rendering

private func present(for message: MSMessage?) {
removeAllChildren()

let root: AnyView
if let url = message?.url, let layout = try? BurrowTransport.decode(url: url) {
root = AnyView(
ScrollView { BurrowLayoutView(layout: layout) { [weak self] in self?.open($0) } }
)
} else {
root = AnyView(ComposeGallery { [weak self] in self?.insert($0) })
}

let host = UIHostingController(rootView: root)
addChild(host)
host.view.frame = view.bounds
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
host.view.backgroundColor = .clear
view.addSubview(host.view)
host.didMove(toParent: self)
}

private func removeAllChildren() {
for child in children {
child.willMove(toParent: nil)
child.view.removeFromSuperview()
child.removeFromParent()
}
}

// MARK: - Compose / actions

private func insert(_ layout: BurrowLayout) {
guard let conversation = activeConversation else { return }

let template = MSMessageTemplateLayout()
template.caption = layout.title
template.subcaption = layout.subtitle

let message = MSMessage()
message.layout = template
message.url = (try? BurrowTransport.encode(base: cardBase, layout: layout)) ?? cardBase

conversation.insert(message) { _ in }
requestPresentationStyle(.compact)
}

private func open(_ action: BurrowAction) {
guard let url = URL(string: action.deepLinkURL) else { return }
extensionContext?.open(url, completionHandler: nil)
}
}
85 changes: 85 additions & 0 deletions imessage/BurrowCardsHost/BurrowCardsHostApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//
// BurrowCardsHostApp.swift
// Host app for the Burrow Cards iMessage extension. Doubles as a debug harness:
// flip through sample layouts or paste live JSON and watch it render — no
// Messages, no sending required. Fastest way to iterate on the renderer.
//

import SwiftUI

@main
struct BurrowCardsHostApp: App {
var body: some Scene {
WindowGroup { HarnessView() }
}
}

struct HarnessView: View {
@State private var index = 0
@State private var jsonText = ""
@State private var pasted: BurrowLayout?
@State private var parseError: String?

private var current: BurrowLayout {
pasted ?? BurrowSamples.all[index].layout
}

var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Picker("Sample", selection: $index) {
ForEach(Array(BurrowSamples.all.enumerated()), id: \.offset) { i, s in
Text(s.name).tag(i)
}
}
.pickerStyle(.segmented)
.disabled(pasted != nil)

// Card preview in a bubble-ish container.
BurrowLayoutView(layout: current) { action in
parseError = "Tapped action: \(action.id) → \(action.deepLinkURL)"
}
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))

DisclosureGroup("Paste live JSON") {
VStack(alignment: .leading, spacing: 8) {
TextEditor(text: $jsonText)
.font(.system(.footnote, design: .monospaced))
.frame(height: 180)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(.quaternary))

HStack {
Button("Render") { render() }
.buttonStyle(.borderedProminent)
Button("Clear") { pasted = nil; parseError = nil; jsonText = "" }
.buttonStyle(.bordered)
}

if let parseError {
Text(parseError)
.font(.caption)
.foregroundStyle(pasted == nil ? .red : .secondary)
}
}
.padding(.top, 8)
}
}
.padding(16)
}
.navigationTitle("Burrow Cards")
}
}

private func render() {
guard let data = jsonText.data(using: .utf8) else { return }
do {
pasted = try JSONDecoder().decode(BurrowLayout.self, from: data)
parseError = "Rendered ✓"
} catch {
pasted = nil
parseError = "Parse error: \(error)"
}
}
}
74 changes: 74 additions & 0 deletions imessage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Burrow Cards — iMessage extension

Native iMessage cards for Burrow alerts, described by JSON, rendered on-device by a signed SwiftUI renderer.

The [alert sidecar](../tools/burrow-alerts) (or any agent) emits a **BurrowLayout** JSON document per message; this fixed, Apple-signed extension draws it as native SwiftUI. The JSON describes *what* to show (a disk gauge, a free-space row, a "clean" button) — it never executes code. Sent over Photon `customizedMiniApp()`, base64url-encoded in the message URL as `?p=<payload>`.

> **Why not just send SwiftUI from the server?** Apple doesn't let apps run downloaded UI code. So we ship a fixed renderer and send declarative JSON that selects from a known vocabulary — the [Scriptable](https://scriptable.app)/[HermesShare](https://github.com/time-attack/HermesShare) model. App Store legal, no web views, no eval.

## Status

Buildable end-to-end — `xcodegen generate` + the host scheme compiles clean for the iOS Simulator (`** BUILD SUCCEEDED **`). Set your Team ID to sideload to a device.

| Piece | State |
|---|---|
| `tools/burrow-alerts/src/burrowlayout.ts` | ✅ schema + `?p=` transport, unit-tested (round-trip) |
| `Shared/Sources/BurrowCards/BurrowLayout.swift` | ✅ matching Codable schema + base64url encode/decode |
| `Shared/Sources/BurrowCards/BurrowLayoutRenderer.swift` | ✅ JSON tree → native SwiftUI (fixed vocabulary) |
| `Shared/Sources/BurrowCards/Samples.swift` | ✅ disk / CPU / cleanup sample cards |
| `BurrowCardsExtension/` (MSMessagesAppViewController) | ✅ decode `?p=` → render; compose gallery inserts samples; deep-links |
| `BurrowCardsHost/` (host app + debug harness) | ✅ picker + live-JSON paste, renders with no send |
| `project.yml` (xcodegen) | ✅ host app embeds the extension |

The Swift schema matches the TS round-trip test byte-for-byte — change one side, change both.

## Test it three ways

1. **Harness (fastest, no sending):** run the **BurrowCardsHost** scheme on a Simulator or your iPhone. Flip the sample picker, or paste live JSON and hit Render. This exercises the renderer directly.
2. **Extension in Messages:** run the **BurrowCards** scheme (or open Messages after installing the host). Tap `+` → Burrow Cards → the compose gallery inserts a sample card; tap the bubble to expand, tap an action to fire the `burrow://` deep link.
3. **Real send from the sidecar:** set the `card` block (below) and `cd tools/burrow-alerts && bun run check:card`. The sidecar emits a `BurrowLayout` in the `?p=` URL; your extension renders it.

## Reality check

- **iOS-only.** iMessage app extensions don't run in macOS Messages. You receive cards on your iPhone.
- **Requires the extension installed on the *receiving* device.** Without it, the message falls back to a standard bubble (thumbnail + caption). The sidecar already sends a text fallback for everyone else — cards are a per-device upgrade, not a hard dependency.
- iOS 26+ / Xcode 26+.

## Build & sideload (your own iPhone)

```bash
brew install xcodegen
cd imessage
xcodegen generate # once project.yml lands
open BurrowCards.xcodeproj
```

Set your Apple **Team ID** in `project.yml` (`DEVELOPMENT_TEAM`) or Xcode → Signing & Capabilities (both the host app and the extension). A free Apple account works for sideloading to your own device. Build/run the host app to your iPhone; the extension installs with it. Then in Messages → `+` → Burrow Cards.

## Wiring the sidecar

Point `tools/burrow-alerts` at this extension:

```jsonc
// config.local.json
"card": {
"appName": "Burrow",
"extensionBundleId": "dev.caezium.Burrow.imessage",
"teamId": "<your Apple Team ID>",
"url": "https://burrow.henryzh.dev/card" // base; sidecar appends ?p=<layout>
}
```

The sidecar builds a `BurrowLayout` (`src/burrowlayout.ts`), base64url-encodes it into the `url` as `?p=`, and sends via `customizedMiniApp`. `bun run check:card` fires a sample.

## Schema

`BurrowLayout` = `{ version, title, subtitle?, accentColorHex?, root, actions? }`. `root` is a recursive `BurrowNode`:

`vstack` · `hstack` · `section` · `text` · `statusBadge` · `progressBar` · `gauge` · `keyValueRow`

Actions are `{ id, label, systemImage?, deepLinkURL }` — `burrow://action?id=…` buttons that insert a reply into the thread.

## Acknowledgments

Node vocabulary and the "declarative JSON → signed native renderer" approach are derived from [**HermesShare**](https://github.com/time-attack/HermesShare) (MIT). Burrow Cards trims the schema to system-health alerts and re-brands the transport/deep-link scheme.
Loading