feat(companion): add mobile runtime skeleton shell templates
This commit is contained in:
@@ -1736,7 +1736,7 @@ Companion runtime helper:
|
|||||||
- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load (with safe normalization for invalid random samples), `tickNow()` for manual sends, success/error hooks, loop observability (`successCount`, `lastSuccessAt`, `failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures.
|
- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load (with safe normalization for invalid random samples), `tickNow()` for manual sends, success/error hooks, loop observability (`successCount`, `lastSuccessAt`, `failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures.
|
||||||
- `src/companion/bootstrapManifest.ts` provides `createCompanionBootstrapManifest()` for generating a typed gateway/node/runtime bootstrap contract used by packaging flows, including optional initial status/location/push payloads.
|
- `src/companion/bootstrapManifest.ts` provides `createCompanionBootstrapManifest()` for generating a typed gateway/node/runtime bootstrap contract used by packaging flows, including optional initial status/location/push payloads.
|
||||||
- `src/companion/releaseBundle.ts` provides `writeCompanionReleaseBundle()` for writing a distributable companion bundle directory (bootstrap JSON + launcher script + README + SHA-256 checksums + release manifest).
|
- `src/companion/releaseBundle.ts` provides `writeCompanionReleaseBundle()` for writing a distributable companion bundle directory (bootstrap JSON + launcher script + README + SHA-256 checksums + release manifest).
|
||||||
- `src/companion/shellTemplate.ts` provides `writeCompanionShellTemplate()` for emitting platform starter shells (macOS/iOS/Android native scaffold snippets + bootstrap JSON).
|
- `src/companion/shellTemplate.ts` provides `writeCompanionShellTemplate()` for emitting platform starter shells (macOS launcher wrapper snippet, iOS/Android bootstrap + runtime skeleton files, and bootstrap JSON).
|
||||||
- `src/companion/releaseVerify.ts` provides `verifyCompanionReleaseBundle()` for validating bundle checksums and optional signature metadata.
|
- `src/companion/releaseVerify.ts` provides `verifyCompanionReleaseBundle()` for validating bundle checksums and optional signature metadata.
|
||||||
- `src/companion/releasePipeline.ts` provides `buildAndVerifyCompanionReleaseBundle()` for build-and-verify automation (including signed bundle flows).
|
- `src/companion/releasePipeline.ts` provides `buildAndVerifyCompanionReleaseBundle()` for build-and-verify automation (including signed bundle flows).
|
||||||
- `src/companion/macosMenuBarApp.ts` provides `generateMacOSMenuBarApp()` for emitting a runnable Swift Package macOS menu-bar companion scaffold.
|
- `src/companion/macosMenuBarApp.ts` provides `generateMacOSMenuBarApp()` for emitting a runnable Swift Package macOS menu-bar companion scaffold.
|
||||||
@@ -1749,7 +1749,7 @@ Minimal companion CLI:
|
|||||||
- `flynn companion --export-bootstrap ./companion.bootstrap.json` writes a resolved bootstrap manifest for desktop/mobile companion app packaging (use `-` for stdout).
|
- `flynn companion --export-bootstrap ./companion.bootstrap.json` writes a resolved bootstrap manifest for desktop/mobile companion app packaging (use `-` for stdout).
|
||||||
- `flynn companion --export-release-bundle ./dist/companion-macos` writes a release bundle directory (`companion.bootstrap.json`, `run-companion.sh`, `README.md`, `CHECKSUMS.sha256`, `RELEASE_MANIFEST.json`) and exits.
|
- `flynn companion --export-release-bundle ./dist/companion-macos` writes a release bundle directory (`companion.bootstrap.json`, `run-companion.sh`, `README.md`, `CHECKSUMS.sha256`, `RELEASE_MANIFEST.json`) and exits.
|
||||||
- `flynn companion --export-release-bundle ./dist/companion-macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` also writes `CHECKSUMS.sha256.sig` for signed verification workflows.
|
- `flynn companion --export-release-bundle ./dist/companion-macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` also writes `CHECKSUMS.sha256.sig` for signed verification workflows.
|
||||||
- `flynn companion --platform ios --export-shell-template ./dist/companion-ios-template` writes a platform-native starter template directory (`companion.bootstrap.json`, native starter file, `README.md`) and exits.
|
- `flynn companion --platform ios --export-shell-template ./dist/companion-ios-template` writes a platform-native starter template directory (`companion.bootstrap.json`, bootstrap/runtime starter files, `README.md`) and exits.
|
||||||
- `flynn companion --verify-release-bundle ./dist/companion-macos --verify-signing-key ./keys/release-public.pem --verify-signing-key-id team-k1 --require-signature` verifies checksums and signature metadata before install.
|
- `flynn companion --verify-release-bundle ./dist/companion-macos --verify-signing-key ./keys/release-public.pem --verify-signing-key-id team-k1 --require-signature` verifies checksums and signature metadata before install.
|
||||||
- `pnpm companion:bundle -- --output ./dist/companion-macos --platform macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` builds and verifies a release bundle in one step.
|
- `pnpm companion:bundle -- --output ./dist/companion-macos --platform macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` builds and verifies a release bundle in one step.
|
||||||
- `pnpm companion:reference-apps -- --output ./apps/companion` regenerates macOS/iOS/Android reference app starter shells plus `apps/companion/macos-app` runnable scaffold.
|
- `pnpm companion:reference-apps -- --output ./apps/companion` regenerates macOS/iOS/Android reference app starter shells plus `apps/companion/macos-app` runnable scaffold.
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ This directory contains generated companion starter shells for:
|
|||||||
- Android shell
|
- Android shell
|
||||||
|
|
||||||
These are reference starters, not production binaries. Use them as a baseline for app packaging and distribution workflows.
|
These are reference starters, not production binaries. Use them as a baseline for app packaging and distribution workflows.
|
||||||
|
|
||||||
|
iOS/Android starter directories include bootstrap plus runtime-skeleton files for register/status/location/push/handoff flows.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package flynn.companion
|
||||||
|
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledFuture
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
interface CompanionTransport {
|
||||||
|
fun connect(url: String, token: String?)
|
||||||
|
fun disconnect()
|
||||||
|
fun send(rawMessage: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CompanionStatusPayload(
|
||||||
|
val platform: String,
|
||||||
|
val appVersion: String? = null,
|
||||||
|
val statusText: String? = null,
|
||||||
|
val batteryPct: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CompanionLocationPayload(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val source: String = "device"
|
||||||
|
)
|
||||||
|
|
||||||
|
class AndroidCompanionRuntime(
|
||||||
|
private val bootstrap: CompanionBootstrap,
|
||||||
|
private val transport: CompanionTransport
|
||||||
|
) {
|
||||||
|
private var nextRequestId: Int = 1
|
||||||
|
private val scheduler = Executors.newSingleThreadScheduledExecutor()
|
||||||
|
private var heartbeatFuture: ScheduledFuture<*>? = null
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
transport.connect(bootstrap.gateway.url, bootstrap.gateway.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
heartbeatFuture?.cancel(true)
|
||||||
|
heartbeatFuture = null
|
||||||
|
transport.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerNode() {
|
||||||
|
val params = JSONObject()
|
||||||
|
.put("nodeId", bootstrap.node.nodeId)
|
||||||
|
.put("role", bootstrap.node.role)
|
||||||
|
.put("platform", bootstrap.node.platform)
|
||||||
|
.put("capabilities", JSONArray(bootstrap.node.capabilities))
|
||||||
|
sendRpc("node.register", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishStatus(status: CompanionStatusPayload) {
|
||||||
|
val statusJson = JSONObject()
|
||||||
|
.put("platform", status.platform)
|
||||||
|
status.appVersion?.let { statusJson.put("appVersion", it) }
|
||||||
|
status.statusText?.let { statusJson.put("statusText", it) }
|
||||||
|
status.batteryPct?.let { statusJson.put("batteryPct", it) }
|
||||||
|
sendRpc(
|
||||||
|
"node.status.set",
|
||||||
|
JSONObject()
|
||||||
|
.put("nodeId", bootstrap.node.nodeId)
|
||||||
|
.put("status", statusJson)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishLocation(location: CompanionLocationPayload) {
|
||||||
|
sendRpc(
|
||||||
|
"node.location.set",
|
||||||
|
JSONObject()
|
||||||
|
.put("nodeId", bootstrap.node.nodeId)
|
||||||
|
.put(
|
||||||
|
"location",
|
||||||
|
JSONObject()
|
||||||
|
.put("latitude", location.latitude)
|
||||||
|
.put("longitude", location.longitude)
|
||||||
|
.put("source", location.source)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerPushToken(token: String, topic: String? = null) {
|
||||||
|
val params = JSONObject()
|
||||||
|
.put("nodeId", bootstrap.node.nodeId)
|
||||||
|
.put("provider", "fcm")
|
||||||
|
.put("token", token)
|
||||||
|
topic?.let { params.put("topic", it) }
|
||||||
|
sendRpc("node.push_token.set", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendHandoff(message: String, sessionId: String? = null) {
|
||||||
|
val params = JSONObject()
|
||||||
|
.put("message", message)
|
||||||
|
.put("awaitResponse", true)
|
||||||
|
sessionId?.let { params.put("sessionId", it) }
|
||||||
|
sendRpc("agent.send", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startHeartbeatLoop(statusFactory: () -> CompanionStatusPayload) {
|
||||||
|
heartbeatFuture?.cancel(true)
|
||||||
|
val intervalSeconds = bootstrap.runtime.heartbeatSeconds.coerceAtLeast(1).toLong()
|
||||||
|
heartbeatFuture = scheduler.scheduleAtFixedRate(
|
||||||
|
{ publishStatus(statusFactory()) },
|
||||||
|
0L,
|
||||||
|
intervalSeconds,
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendRpc(method: String, params: JSONObject) {
|
||||||
|
val request = JSONObject()
|
||||||
|
.put("jsonrpc", "2.0")
|
||||||
|
.put("id", nextRequestId++)
|
||||||
|
.put("method", method)
|
||||||
|
.put("params", params)
|
||||||
|
transport.send(request.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated for node: android-reference-shell (android)
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
# Flynn Companion android Shell Template
|
# Flynn Companion Android Shell Template
|
||||||
|
|
||||||
This directory contains a generated starter template for a android companion shell.
|
This directory contains a generated starter template for an Android companion shell.
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
||||||
- `CompanionBootstrap.kt`: platform-native starter model/wrapper snippet
|
- `CompanionBootstrap.kt`
|
||||||
|
- `AndroidCompanionRuntime.kt`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- These templates are intentionally minimal and should be integrated into your app project.
|
- These templates are intentionally minimal and should be integrated into your app project.
|
||||||
- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`).
|
- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`, `agent.send`).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": "2026-02-27T04:50:38.373Z",
|
"generatedAt": "2026-02-27T04:56:19.684Z",
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"url": "ws://127.0.0.1:18800"
|
"url": "ws://127.0.0.1:18800"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CompanionRuntimeError: Error {
|
||||||
|
case invalidGatewayURL
|
||||||
|
case notConnected
|
||||||
|
case encodingFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct JsonRpcRequest<Params: Encodable>: Encodable {
|
||||||
|
let jsonrpc = "2.0"
|
||||||
|
let id: Int
|
||||||
|
let method: String
|
||||||
|
let params: Params
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompanionStatusPayload: Encodable {
|
||||||
|
let platform: String
|
||||||
|
let appVersion: String?
|
||||||
|
let statusText: String?
|
||||||
|
let batteryPct: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompanionLocationPayload: Encodable {
|
||||||
|
let latitude: Double
|
||||||
|
let longitude: Double
|
||||||
|
let source: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeRegisterParams: Encodable {
|
||||||
|
let nodeId: String
|
||||||
|
let role: String
|
||||||
|
let platform: String
|
||||||
|
let capabilities: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeStatusSetParams: Encodable {
|
||||||
|
let nodeId: String
|
||||||
|
let status: CompanionStatusPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeLocationSetParams: Encodable {
|
||||||
|
let nodeId: String
|
||||||
|
let location: CompanionLocationPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodePushTokenSetParams: Encodable {
|
||||||
|
let nodeId: String
|
||||||
|
let provider: String
|
||||||
|
let token: String
|
||||||
|
let topic: String?
|
||||||
|
let environment: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AgentSendParams: Encodable {
|
||||||
|
let message: String
|
||||||
|
let sessionId: String?
|
||||||
|
let awaitResponse: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
final class IOSCompanionRuntime {
|
||||||
|
private let bootstrap: CompanionBootstrap
|
||||||
|
private let session: URLSession
|
||||||
|
private var socket: URLSessionWebSocketTask?
|
||||||
|
private var nextRequestId = 1
|
||||||
|
private var heartbeatLoop: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(bootstrap: CompanionBootstrap, session: URLSession = .shared) {
|
||||||
|
self.bootstrap = bootstrap
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect() throws {
|
||||||
|
guard socket == nil else { return }
|
||||||
|
guard let url = URL(string: bootstrap.gateway.url) else {
|
||||||
|
throw CompanionRuntimeError.invalidGatewayURL
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
if let token = bootstrap.gateway.token, !token.isEmpty {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
let task = session.webSocketTask(with: request)
|
||||||
|
task.resume()
|
||||||
|
socket = task
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
heartbeatLoop?.cancel()
|
||||||
|
heartbeatLoop = nil
|
||||||
|
socket?.cancel(with: .goingAway, reason: nil)
|
||||||
|
socket = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerNode() async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "node.register",
|
||||||
|
params: NodeRegisterParams(
|
||||||
|
nodeId: bootstrap.node.nodeId,
|
||||||
|
role: bootstrap.node.role,
|
||||||
|
platform: bootstrap.node.platform,
|
||||||
|
capabilities: bootstrap.node.capabilities
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishStatus(_ status: CompanionStatusPayload) async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "node.status.set",
|
||||||
|
params: NodeStatusSetParams(nodeId: bootstrap.node.nodeId, status: status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishLocation(_ location: CompanionLocationPayload) async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "node.location.set",
|
||||||
|
params: NodeLocationSetParams(nodeId: bootstrap.node.nodeId, location: location)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerPushToken(token: String, topic: String? = nil, environment: String? = nil) async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "node.push_token.set",
|
||||||
|
params: NodePushTokenSetParams(
|
||||||
|
nodeId: bootstrap.node.nodeId,
|
||||||
|
provider: "apns",
|
||||||
|
token: token,
|
||||||
|
topic: topic,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHandoff(message: String, sessionId: String? = nil) async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "agent.send",
|
||||||
|
params: AgentSendParams(message: message, sessionId: sessionId, awaitResponse: true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHeartbeatLoop(statusFactory: @escaping () -> CompanionStatusPayload) {
|
||||||
|
heartbeatLoop?.cancel()
|
||||||
|
let interval = UInt64(max(bootstrap.runtime.heartbeatSeconds, 1)) * 1_000_000_000
|
||||||
|
heartbeatLoop = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
guard let self else { return }
|
||||||
|
try? await self.publishStatus(statusFactory())
|
||||||
|
try? await Task.sleep(nanoseconds: interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRpc<Params: Encodable>(method: String, params: Params) async throws {
|
||||||
|
guard let socket else {
|
||||||
|
throw CompanionRuntimeError.notConnected
|
||||||
|
}
|
||||||
|
let request = JsonRpcRequest(id: nextRequestId, method: method, params: params)
|
||||||
|
nextRequestId += 1
|
||||||
|
let data = try JSONEncoder().encode(request)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
|
throw CompanionRuntimeError.encodingFailure
|
||||||
|
}
|
||||||
|
try await socket.send(.string(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated for node: ios-reference-shell (ios)
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
# Flynn Companion ios Shell Template
|
# Flynn Companion iOS Shell Template
|
||||||
|
|
||||||
This directory contains a generated starter template for a ios companion shell.
|
This directory contains a generated starter template for an iOS companion shell.
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
||||||
- `CompanionBootstrap.swift`: platform-native starter model/wrapper snippet
|
- `CompanionBootstrap.swift`
|
||||||
|
- `IOSCompanionRuntime.swift`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- These templates are intentionally minimal and should be integrated into your app project.
|
- These templates are intentionally minimal and should be integrated into your app project.
|
||||||
- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`).
|
- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`, `agent.send`).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": "2026-02-27T04:50:38.373Z",
|
"generatedAt": "2026-02-27T04:56:19.684Z",
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"url": "ws://127.0.0.1:18800"
|
"url": "ws://127.0.0.1:18800"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": "2026-02-27T04:50:38.373Z",
|
"generatedAt": "2026-02-27T04:56:19.684Z",
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"url": "ws://127.0.0.1:18800"
|
"url": "ws://127.0.0.1:18800"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Flynn Companion macos Shell Template
|
# Flynn Companion macOS Shell Template
|
||||||
|
|
||||||
This directory contains a generated starter template for a macos companion shell.
|
This directory contains a generated starter template for a macOS companion shell.
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
||||||
- `MenuBarCompanion.swift`: platform-native starter model/wrapper snippet
|
- `MenuBarCompanion.swift`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- These templates are intentionally minimal and should be integrated into your app project.
|
- These templates are intentionally minimal and should be integrated into your app project.
|
||||||
- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`).
|
- Runtime transport should use Flynn gateway JSON-RPC node methods (`node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`, `agent.send`).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"generatedAt": "2026-02-27T04:50:38.373Z",
|
"generatedAt": "2026-02-27T04:56:19.684Z",
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"url": "ws://127.0.0.1:18800"
|
"url": "ws://127.0.0.1:18800"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1866,7 +1866,7 @@ For more implementation details, see:
|
|||||||
- Companion release bundle helper: `src/companion/releaseBundle.ts` (writes bootstrap JSON + launcher script + README + `CHECKSUMS.sha256` + `RELEASE_MANIFEST.json`; optional `CHECKSUMS.sha256.sig` when a signing key is provided. Launcher performs checksum verification before exec.)
|
- Companion release bundle helper: `src/companion/releaseBundle.ts` (writes bootstrap JSON + launcher script + README + `CHECKSUMS.sha256` + `RELEASE_MANIFEST.json`; optional `CHECKSUMS.sha256.sig` when a signing key is provided. Launcher performs checksum verification before exec.)
|
||||||
- Companion release bundle verifier: `src/companion/releaseVerify.ts` (validates `CHECKSUMS.sha256` and optional signature metadata against a provided public key)
|
- Companion release bundle verifier: `src/companion/releaseVerify.ts` (validates `CHECKSUMS.sha256` and optional signature metadata against a provided public key)
|
||||||
- Companion release automation pipeline: `src/companion/releasePipeline.ts` + `scripts/build-companion-release-bundle.ts` (build-and-verify workflow for deterministic companion artifact generation)
|
- Companion release automation pipeline: `src/companion/releasePipeline.ts` + `scripts/build-companion-release-bundle.ts` (build-and-verify workflow for deterministic companion artifact generation)
|
||||||
- Companion shell template helper: `src/companion/shellTemplate.ts` (writes platform-native starter template files for `macos`, `ios`, and `android` shell scaffolding)
|
- Companion shell template helper: `src/companion/shellTemplate.ts` (writes platform-native starter template files for `macos`, `ios`, and `android` shell scaffolding, including iOS/Android runtime skeletons for `node.register` + status/location/push + `agent.send`)
|
||||||
- Companion macOS menu-bar app scaffold helper: `src/companion/macosMenuBarApp.ts` (writes runnable Swift Package menu-bar app starter surface)
|
- Companion macOS menu-bar app scaffold helper: `src/companion/macosMenuBarApp.ts` (writes runnable Swift Package menu-bar app starter surface)
|
||||||
- Companion reference app exporter: `src/companion/referenceApps.ts` + `scripts/export-companion-reference-apps.ts` (regenerates in-repo platform starter app directories, including `apps/companion/macos-app`)
|
- Companion reference app exporter: `src/companion/referenceApps.ts` + `scripts/export-companion-reference-apps.ts` (regenerates in-repo platform starter app directories, including `apps/companion/macos-app`)
|
||||||
- CI artifact workflow: `.github/workflows/companion-release-bundle.yml` (manual dispatch bundle build/verify/upload pipeline)
|
- CI artifact workflow: `.github/workflows/companion-release-bundle.yml` (manual dispatch bundle build/verify/upload pipeline)
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ Gateway streaming UX signals:
|
|||||||
- `flynn companion --export-release-bundle <dir>` can emit a distributable shell bundle (bootstrap JSON + launcher + README + SHA-256 checksums) for desktop/mobile packaging pipelines.
|
- `flynn companion --export-release-bundle <dir>` can emit a distributable shell bundle (bootstrap JSON + launcher + README + SHA-256 checksums) for desktop/mobile packaging pipelines.
|
||||||
- `flynn companion --export-release-bundle ... --signing-key <pem>` can additionally emit `CHECKSUMS.sha256.sig` for signed artifact verification pipelines.
|
- `flynn companion --export-release-bundle ... --signing-key <pem>` can additionally emit `CHECKSUMS.sha256.sig` for signed artifact verification pipelines.
|
||||||
- `flynn companion --verify-release-bundle <dir>` can validate bundle checksums and optional signatures before installation or rollout.
|
- `flynn companion --verify-release-bundle <dir>` can validate bundle checksums and optional signatures before installation or rollout.
|
||||||
- `flynn companion --export-shell-template <dir>` can emit platform starter shell templates (macOS/iOS/Android native scaffold files + bootstrap JSON) for reference app bootstrapping.
|
- `flynn companion --export-shell-template <dir>` can emit platform starter shell templates (macOS/iOS/Android native scaffold files + bootstrap JSON), including iOS/Android runtime skeletons for registration, heartbeat/status, location/push updates, and handoff.
|
||||||
- `pnpm companion:bundle -- --output <dir> ...` runs a build-and-verify release pipeline for repeatable companion artifact generation.
|
- `pnpm companion:bundle -- --output <dir> ...` runs a build-and-verify release pipeline for repeatable companion artifact generation.
|
||||||
- `pnpm companion:reference-apps -- --output apps/companion` regenerates in-repo macOS/iOS/Android reference app starter surfaces plus a runnable `macos-app` Swift Package menu-bar scaffold.
|
- `pnpm companion:reference-apps -- --output apps/companion` regenerates in-repo macOS/iOS/Android reference app starter surfaces plus a runnable `macos-app` Swift Package menu-bar scaffold.
|
||||||
- `.github/workflows/companion-release-bundle.yml` provides CI artifact generation for companion release bundles using the same build-and-verify pipeline.
|
- `.github/workflows/companion-release-bundle.yml` provides CI artifact generation for companion release bundles using the same build-and-verify pipeline.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`.
|
|||||||
- Companion release-bundle exports can optionally be signed (`--signing-key`, `--signing-key-id`) to emit `CHECKSUMS.sha256.sig` for distribution trust verification.
|
- Companion release-bundle exports can optionally be signed (`--signing-key`, `--signing-key-id`) to emit `CHECKSUMS.sha256.sig` for distribution trust verification.
|
||||||
- Companion release bundles can be verified before install via `flynn companion --verify-release-bundle <dir>` with optional signature-key checks.
|
- Companion release bundles can be verified before install via `flynn companion --verify-release-bundle <dir>` with optional signature-key checks.
|
||||||
- Companion packaging automation is available via `pnpm companion:bundle -- --output <dir> ...`, which builds and verifies the release bundle in one pass.
|
- Companion packaging automation is available via `pnpm companion:bundle -- --output <dir> ...`, which builds and verifies the release bundle in one pass.
|
||||||
- Companion platform starter scaffolds can be generated via `flynn companion --export-shell-template <dir>` for macOS/iOS/Android reference app bootstrapping.
|
- Companion platform starter scaffolds can be generated via `flynn companion --export-shell-template <dir>` for macOS/iOS/Android reference app bootstrapping, including iOS/Android runtime skeletons that issue `node.register`, `node.status.set`, `node.location.set`, `node.push_token.set`, and `agent.send`.
|
||||||
- Companion reference app directories can be regenerated via `pnpm companion:reference-apps -- --output apps/companion` for repo-shipped starter surfaces, including a runnable `macos-app` Swift Package menu-bar scaffold.
|
- Companion reference app directories can be regenerated via `pnpm companion:reference-apps -- --output apps/companion` for repo-shipped starter surfaces, including a runnable `macos-app` Swift Package menu-bar scaffold.
|
||||||
- CI workflow `.github/workflows/companion-release-bundle.yml` mirrors this pipeline for manual artifact generation/upload.
|
- CI workflow `.github/workflows/companion-release-bundle.yml` mirrors this pipeline for manual artifact generation/upload.
|
||||||
- Companion CLI supports one-shot shell bootstrap metadata for live sessions (`--app-version`/`--status-text`, `--latitude`/`--longitude`, `--push-token`) so desktop/mobile wrappers can initialize node status/location/push in a single launch flow.
|
- Companion CLI supports one-shot shell bootstrap metadata for live sessions (`--app-version`/`--status-text`, `--latitude`/`--longitude`, `--push-token`) so desktop/mobile wrappers can initialize node status/location/push in a single launch flow.
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ flynn companion \
|
|||||||
Generated files:
|
Generated files:
|
||||||
|
|
||||||
- `companion.bootstrap.json`
|
- `companion.bootstrap.json`
|
||||||
- platform starter file (`CompanionBootstrap.swift`, `CompanionBootstrap.kt`, or `MenuBarCompanion.swift`)
|
- `MenuBarCompanion.swift` (macOS)
|
||||||
|
- `CompanionBootstrap.swift` + `IOSCompanionRuntime.swift` (iOS)
|
||||||
|
- `CompanionBootstrap.kt` + `AndroidCompanionRuntime.kt` (Android)
|
||||||
- `README.md`
|
- `README.md`
|
||||||
|
|
||||||
## Verify Bundle Integrity
|
## Verify Bundle Integrity
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Scope: ship the remaining product-layer capabilities that make Flynn feel like a
|
|||||||
|
|
||||||
## Rebaseline (What Is Already Done)
|
## Rebaseline (What Is Already Done)
|
||||||
|
|
||||||
Completion update (2026-02-27): all roadmap phases are now implemented, including companion app-surface packaging outputs (bootstrap export, signed/verified release bundles, reference app starter surfaces including a runnable macOS menu-bar Swift Package scaffold, and CI artifact workflow), voice reliability, browser workflow reliability, and onboarding first-success improvements.
|
Completion update (2026-02-27): all roadmap phases are now implemented, including companion app-surface packaging outputs (bootstrap export, signed/verified release bundles, reference app starter surfaces including a runnable macOS menu-bar Swift Package scaffold plus iOS/Android runtime shell skeletons, and CI artifact workflow), voice reliability, browser workflow reliability, and onboarding first-success improvements.
|
||||||
|
|
||||||
The following were previously treated as gaps but are already implemented in Flynn:
|
The following were previously treated as gaps but are already implemented in Flynn:
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ Within 8-10 weeks, ship a stable "Personal Assistant Mode" that supports:
|
|||||||
2. Ship a minimal mobile companion shell (iOS + Android) for registration, status, push token, and message handoff.
|
2. Ship a minimal mobile companion shell (iOS + Android) for registration, status, push token, and message handoff.
|
||||||
3. Add signed release artifacts and installation docs.
|
3. Add signed release artifacts and installation docs.
|
||||||
|
|
||||||
Status update (2026-02-27): companion bootstrap-manifest export is now available via `flynn companion --export-bootstrap <path|->` as a packaging contract for desktop/mobile shells, `flynn companion --export-release-bundle <dir>` now emits bundle artifacts (bootstrap JSON + launcher + README + `CHECKSUMS.sha256` + `RELEASE_MANIFEST.json`, optional `CHECKSUMS.sha256.sig` with `--signing-key`), `flynn companion --verify-release-bundle <dir>` now validates checksum/signature artifacts before install, `pnpm companion:bundle -- --output <dir> ...` now provides one-pass build-and-verify automation, `.github/workflows/companion-release-bundle.yml` provides CI artifact build/verify/upload, `flynn companion --export-shell-template <dir>` now emits macOS/iOS/Android starter shell templates, `pnpm companion:reference-apps` now regenerates in-repo macOS/iOS/Android reference app starter directories plus `apps/companion/macos-app` runnable menu-bar scaffold, and `flynn companion` supports one-shot status/location/push bootstrap flags (`--app-version`, `--latitude/--longitude`, `--push-token`) so thin shells can initialize companion metadata in a single run.
|
Status update (2026-02-27): companion bootstrap-manifest export is now available via `flynn companion --export-bootstrap <path|->` as a packaging contract for desktop/mobile shells, `flynn companion --export-release-bundle <dir>` now emits bundle artifacts (bootstrap JSON + launcher + README + `CHECKSUMS.sha256` + `RELEASE_MANIFEST.json`, optional `CHECKSUMS.sha256.sig` with `--signing-key`), `flynn companion --verify-release-bundle <dir>` now validates checksum/signature artifacts before install, `pnpm companion:bundle -- --output <dir> ...` now provides one-pass build-and-verify automation, `.github/workflows/companion-release-bundle.yml` provides CI artifact build/verify/upload, `flynn companion --export-shell-template <dir>` now emits macOS/iOS/Android starter shell templates including iOS/Android runtime skeletons for register/status/location/push/handoff flows, `pnpm companion:reference-apps` now regenerates in-repo macOS/iOS/Android reference app starter directories plus `apps/companion/macos-app` runnable menu-bar scaffold, and `flynn companion` supports one-shot status/location/push bootstrap flags (`--app-version`, `--latitude/--longitude`, `--push-token`) so thin shells can initialize companion metadata in a single run.
|
||||||
|
|
||||||
### Implementation Anchors
|
### Implementation Anchors
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -7018,13 +7018,21 @@
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-27",
|
"date": "2026-02-27",
|
||||||
"updated": "2026-02-27",
|
"updated": "2026-02-27",
|
||||||
"summary": "Added platform shell-template export for reference companion apps. `flynn companion --export-shell-template <dir>` now writes platform starter scaffolds for macOS/iOS/Android (`MenuBarCompanion.swift`, `CompanionBootstrap.swift`, `CompanionBootstrap.kt`) plus bootstrap JSON and template README.",
|
"summary": "Expanded platform shell-template export for reference companion apps. `flynn companion --export-shell-template <dir>` now writes macOS/iOS/Android starter scaffolds with iOS/Android runtime skeleton files (`IOSCompanionRuntime.swift`, `AndroidCompanionRuntime.kt`) covering register/status/location/push/handoff JSON-RPC calls, plus bootstrap JSON and template README.",
|
||||||
"files_modified": [
|
"files_modified": [
|
||||||
"src/companion/shellTemplate.ts",
|
"src/companion/shellTemplate.ts",
|
||||||
"src/companion/shellTemplate.test.ts",
|
"src/companion/shellTemplate.test.ts",
|
||||||
"src/companion/index.ts",
|
"src/companion/index.ts",
|
||||||
"src/cli/companion.ts",
|
"src/cli/companion.ts",
|
||||||
"src/cli/companion.test.ts",
|
"src/cli/companion.test.ts",
|
||||||
|
"apps/companion/ios/CompanionBootstrap.swift",
|
||||||
|
"apps/companion/ios/IOSCompanionRuntime.swift",
|
||||||
|
"apps/companion/ios/README.md",
|
||||||
|
"apps/companion/ios/companion.bootstrap.json",
|
||||||
|
"apps/companion/android/CompanionBootstrap.kt",
|
||||||
|
"apps/companion/android/AndroidCompanionRuntime.kt",
|
||||||
|
"apps/companion/android/README.md",
|
||||||
|
"apps/companion/android/companion.bootstrap.json",
|
||||||
"README.md",
|
"README.md",
|
||||||
"docs/api/PROTOCOL.md",
|
"docs/api/PROTOCOL.md",
|
||||||
"docs/architecture/AGENT_DIAGRAM.md",
|
"docs/architecture/AGENT_DIAGRAM.md",
|
||||||
@@ -7033,7 +7041,7 @@
|
|||||||
"docs/plans/2026-02-26-personal-assistant-productization-plan.md",
|
"docs/plans/2026-02-26-personal-assistant-productization-plan.md",
|
||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/cli/companion.test.ts src/companion/shellTemplate.test.ts + pnpm typecheck passing"
|
"test_status": "pnpm test:run src/companion/shellTemplate.test.ts src/companion/referenceApps.test.ts + pnpm typecheck passing"
|
||||||
},
|
},
|
||||||
"personal-assistant-productization-phase1-companion-signed-release-artifacts": {
|
"personal-assistant-productization-phase1-companion-signed-release-artifacts": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -7213,7 +7221,7 @@
|
|||||||
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
||||||
"tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings",
|
"tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings",
|
||||||
"tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes",
|
"tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes",
|
||||||
"feature_gap_scorecard": "rebaselined 2026-02-26 and finalized 2026-02-27 — channel breadth, setup wizard, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging/distribution primitives (bootstrap export + release-bundle artifacts + checksum manifests + release manifest artifact + optional signatures + verification mode + checksum-gated launcher + one-pass build/verify automation + CI workflow), repo-shipped macOS/iOS/Android reference app starter surfaces plus runnable macOS menu-bar Swift Package scaffold (`apps/companion/macos-app`), one-shot status/location/push shell bootstrap controls, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented.",
|
"feature_gap_scorecard": "rebaselined 2026-02-26 and finalized 2026-02-27 — channel breadth, setup wizard, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging/distribution primitives (bootstrap export + release-bundle artifacts + checksum manifests + release manifest artifact + optional signatures + verification mode + checksum-gated launcher + one-pass build/verify automation + CI workflow), repo-shipped macOS/iOS/Android reference app starter surfaces plus runnable macOS menu-bar Swift Package scaffold (`apps/companion/macos-app`) and iOS/Android runtime shell skeletons (`IOSCompanionRuntime.swift`, `AndroidCompanionRuntime.kt`), one-shot status/location/push shell bootstrap controls, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented.",
|
||||||
"operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done",
|
"operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done",
|
||||||
"dashboard_observability": "completed — service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling",
|
"dashboard_observability": "completed — service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling",
|
||||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||||
@@ -7246,7 +7254,7 @@
|
|||||||
"deeper_surfaces_phase3_companion_canvas_voice": "completed — companion reconnect resilience (auto-reconnect with backoff, pending-wait cancellation on disconnect), canvas artifact persistence (SQLite-backed store, daemon-restart durability), voice TTS fallback coverage (text-only reply on TTS failure, no dropped responses)",
|
"deeper_surfaces_phase3_companion_canvas_voice": "completed — companion reconnect resilience (auto-reconnect with backoff, pending-wait cancellation on disconnect), canvas artifact persistence (SQLite-backed store, daemon-restart durability), voice TTS fallback coverage (text-only reply on TTS failure, no dropped responses)",
|
||||||
"deeper_surfaces_phase4_rollout": "completed — phase 4 rollout and operator readiness plan documented: canary rollout plan by feature flag/surface, explicit rollback playbook, operator docs and architecture/protocol docs synchronized",
|
"deeper_surfaces_phase4_rollout": "completed — phase 4 rollout and operator readiness plan documented: canary rollout plan by feature flag/surface, explicit rollback playbook, operator docs and architecture/protocol docs synchronized",
|
||||||
"post_phase_test_fixes": "completed — fixed 4 test failures introduced by phases 1-3: iOS/Android push listNodes (missing publishHeartbeat before platform-filtered query), server.test agent.send (run_state events now precede done; added sendAndWaitForDone helper), httpBody 413 (req.destroy() closed socket before response could be sent; replaced with Connection: close header on 413 responses)",
|
"post_phase_test_fixes": "completed — fixed 4 test failures introduced by phases 1-3: iOS/Android push listNodes (missing publishHeartbeat before platform-filtered query), server.test agent.send (run_state events now precede done; added sendAndWaitForDone helper), httpBody 413 (req.destroy() closed socket before response could be sent; replaced with Connection: close header on 413 responses)",
|
||||||
"personal_assistant_productization_plan": "completed — roadmap phases delivered: companion app-surface packaging/distribution outputs (including reference starters + CI), voice reliability, browser workflow reliability, and onboarding first-success funnel.",
|
"personal_assistant_productization_plan": "completed — roadmap phases delivered: companion app-surface packaging/distribution outputs (including runnable/reference starters + iOS/Android runtime shell skeletons + CI), voice reliability, browser workflow reliability, and onboarding first-success funnel.",
|
||||||
"subagents_support": "completed — subagent phases 1-3 shipped with `subagent.spawn/send/list/cancel/delete/summary`, per-child queue mode (`followup|interrupt`), budgets (`max_turns`, `max_total_tokens`, `turn_timeout_ms`), tool-profile overrides, trace-linked audit events, `/subagents` inspection commands, and focused regression tests."
|
"subagents_support": "completed — subagent phases 1-3 shipped with `subagent.spawn/send/list/cancel/delete/summary`, per-child queue mode (`followup|interrupt`), budgets (`max_turns`, `max_total_tokens`, `turn_timeout_ms`), tool-profile overrides, trace-linked audit events, `/subagents` inspection commands, and focused regression tests."
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ This directory contains generated companion starter shells for:
|
|||||||
- Android shell
|
- Android shell
|
||||||
|
|
||||||
These are reference starters, not production binaries. Use them as a baseline for app packaging and distribution workflows.
|
These are reference starters, not production binaries. Use them as a baseline for app packaging and distribution workflows.
|
||||||
|
|
||||||
|
iOS/Android starter directories include bootstrap plus runtime-skeleton files for register/status/location/push/handoff flows.
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,18 +32,23 @@ describe('writeCompanionShellTemplate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const templateRaw = await readFile(`${outDir}/CompanionBootstrap.swift`, 'utf8');
|
const templateRaw = await readFile(`${outDir}/CompanionBootstrap.swift`, 'utf8');
|
||||||
|
const runtimeRaw = await readFile(`${outDir}/IOSCompanionRuntime.swift`, 'utf8');
|
||||||
const manifestRaw = await readFile(`${outDir}/companion.bootstrap.json`, 'utf8');
|
const manifestRaw = await readFile(`${outDir}/companion.bootstrap.json`, 'utf8');
|
||||||
const readmeRaw = await readFile(`${outDir}/README.md`, 'utf8');
|
const readmeRaw = await readFile(`${outDir}/README.md`, 'utf8');
|
||||||
|
|
||||||
expect(result.files).toEqual([
|
expect(result.files).toEqual([
|
||||||
`${outDir}/companion.bootstrap.json`,
|
`${outDir}/companion.bootstrap.json`,
|
||||||
`${outDir}/CompanionBootstrap.swift`,
|
`${outDir}/CompanionBootstrap.swift`,
|
||||||
|
`${outDir}/IOSCompanionRuntime.swift`,
|
||||||
`${outDir}/README.md`,
|
`${outDir}/README.md`,
|
||||||
]);
|
]);
|
||||||
expect(templateRaw).toContain('struct CompanionBootstrap: Codable');
|
expect(templateRaw).toContain('struct CompanionBootstrap: Codable');
|
||||||
expect(templateRaw).toContain('node.push_token.set');
|
expect(runtimeRaw).toContain('final class IOSCompanionRuntime');
|
||||||
|
expect(runtimeRaw).toContain('"node.push_token.set"');
|
||||||
|
expect(runtimeRaw).toContain('"agent.send"');
|
||||||
expect(JSON.parse(manifestRaw)).toMatchObject({ node: { nodeId: 'test-node' } });
|
expect(JSON.parse(manifestRaw)).toMatchObject({ node: { nodeId: 'test-node' } });
|
||||||
expect(readmeRaw).toContain('Shell Template');
|
expect(readmeRaw).toContain('Shell Template');
|
||||||
|
expect(readmeRaw).toContain('IOSCompanionRuntime.swift');
|
||||||
|
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@@ -65,8 +70,11 @@ describe('writeCompanionShellTemplate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const androidTemplate = await readFile(`${androidDir}/CompanionBootstrap.kt`, 'utf8');
|
const androidTemplate = await readFile(`${androidDir}/CompanionBootstrap.kt`, 'utf8');
|
||||||
|
const androidRuntime = await readFile(`${androidDir}/AndroidCompanionRuntime.kt`, 'utf8');
|
||||||
const macosTemplate = await readFile(`${macosDir}/MenuBarCompanion.swift`, 'utf8');
|
const macosTemplate = await readFile(`${macosDir}/MenuBarCompanion.swift`, 'utf8');
|
||||||
expect(androidTemplate).toContain('data class CompanionBootstrap');
|
expect(androidTemplate).toContain('data class CompanionBootstrap');
|
||||||
|
expect(androidRuntime).toContain('class AndroidCompanionRuntime');
|
||||||
|
expect(androidRuntime).toContain("\"node.register\"");
|
||||||
expect(macosTemplate).toContain('launchFlynnCompanion');
|
expect(macosTemplate).toContain('launchFlynnCompanion');
|
||||||
|
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
|||||||
+350
-25
@@ -15,7 +15,12 @@ export interface WriteCompanionShellTemplateResult {
|
|||||||
files: string[];
|
files: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function swiftTemplate(manifest: CompanionBootstrapManifest): string {
|
interface TemplateArtifact {
|
||||||
|
filename: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function macosTemplate(manifest: CompanionBootstrapManifest): string {
|
||||||
return `import Foundation
|
return `import Foundation
|
||||||
|
|
||||||
struct CompanionBootstrap: Codable {
|
struct CompanionBootstrap: Codable {
|
||||||
@@ -57,7 +62,7 @@ func launchFlynnCompanion() throws {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function iosTemplate(manifest: CompanionBootstrapManifest): string {
|
function iosBootstrapTemplate(manifest: CompanionBootstrapManifest): string {
|
||||||
return `import Foundation
|
return `import Foundation
|
||||||
|
|
||||||
// Reference iOS bootstrap model for integrating with Flynn gateway runtime.
|
// Reference iOS bootstrap model for integrating with Flynn gateway runtime.
|
||||||
@@ -93,7 +98,176 @@ struct Runtime: Codable {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function androidTemplate(manifest: CompanionBootstrapManifest): string {
|
function iosRuntimeTemplate(manifest: CompanionBootstrapManifest): string {
|
||||||
|
return `import Foundation
|
||||||
|
|
||||||
|
enum CompanionRuntimeError: Error {
|
||||||
|
case invalidGatewayURL
|
||||||
|
case notConnected
|
||||||
|
case encodingFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct JsonRpcRequest<Params: Encodable>: Encodable {
|
||||||
|
let jsonrpc = "2.0"
|
||||||
|
let id: Int
|
||||||
|
let method: String
|
||||||
|
let params: Params
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompanionStatusPayload: Encodable {
|
||||||
|
let platform: String
|
||||||
|
let appVersion: String?
|
||||||
|
let statusText: String?
|
||||||
|
let batteryPct: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompanionLocationPayload: Encodable {
|
||||||
|
let latitude: Double
|
||||||
|
let longitude: Double
|
||||||
|
let source: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeRegisterParams: Encodable {
|
||||||
|
let nodeId: String
|
||||||
|
let role: String
|
||||||
|
let platform: String
|
||||||
|
let capabilities: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeStatusSetParams: Encodable {
|
||||||
|
let nodeId: String
|
||||||
|
let status: CompanionStatusPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeLocationSetParams: Encodable {
|
||||||
|
let nodeId: String
|
||||||
|
let location: CompanionLocationPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodePushTokenSetParams: Encodable {
|
||||||
|
let nodeId: String
|
||||||
|
let provider: String
|
||||||
|
let token: String
|
||||||
|
let topic: String?
|
||||||
|
let environment: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AgentSendParams: Encodable {
|
||||||
|
let message: String
|
||||||
|
let sessionId: String?
|
||||||
|
let awaitResponse: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
final class IOSCompanionRuntime {
|
||||||
|
private let bootstrap: CompanionBootstrap
|
||||||
|
private let session: URLSession
|
||||||
|
private var socket: URLSessionWebSocketTask?
|
||||||
|
private var nextRequestId = 1
|
||||||
|
private var heartbeatLoop: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(bootstrap: CompanionBootstrap, session: URLSession = .shared) {
|
||||||
|
self.bootstrap = bootstrap
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect() throws {
|
||||||
|
guard socket == nil else { return }
|
||||||
|
guard let url = URL(string: bootstrap.gateway.url) else {
|
||||||
|
throw CompanionRuntimeError.invalidGatewayURL
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
if let token = bootstrap.gateway.token, !token.isEmpty {
|
||||||
|
request.setValue("Bearer \\(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
let task = session.webSocketTask(with: request)
|
||||||
|
task.resume()
|
||||||
|
socket = task
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
heartbeatLoop?.cancel()
|
||||||
|
heartbeatLoop = nil
|
||||||
|
socket?.cancel(with: .goingAway, reason: nil)
|
||||||
|
socket = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerNode() async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "node.register",
|
||||||
|
params: NodeRegisterParams(
|
||||||
|
nodeId: bootstrap.node.nodeId,
|
||||||
|
role: bootstrap.node.role,
|
||||||
|
platform: bootstrap.node.platform,
|
||||||
|
capabilities: bootstrap.node.capabilities
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishStatus(_ status: CompanionStatusPayload) async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "node.status.set",
|
||||||
|
params: NodeStatusSetParams(nodeId: bootstrap.node.nodeId, status: status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishLocation(_ location: CompanionLocationPayload) async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "node.location.set",
|
||||||
|
params: NodeLocationSetParams(nodeId: bootstrap.node.nodeId, location: location)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerPushToken(token: String, topic: String? = nil, environment: String? = nil) async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "node.push_token.set",
|
||||||
|
params: NodePushTokenSetParams(
|
||||||
|
nodeId: bootstrap.node.nodeId,
|
||||||
|
provider: "apns",
|
||||||
|
token: token,
|
||||||
|
topic: topic,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHandoff(message: String, sessionId: String? = nil) async throws {
|
||||||
|
try await sendRpc(
|
||||||
|
method: "agent.send",
|
||||||
|
params: AgentSendParams(message: message, sessionId: sessionId, awaitResponse: true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHeartbeatLoop(statusFactory: @escaping () -> CompanionStatusPayload) {
|
||||||
|
heartbeatLoop?.cancel()
|
||||||
|
let interval = UInt64(max(bootstrap.runtime.heartbeatSeconds, 1)) * 1_000_000_000
|
||||||
|
heartbeatLoop = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
guard let self else { return }
|
||||||
|
try? await self.publishStatus(statusFactory())
|
||||||
|
try? await Task.sleep(nanoseconds: interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRpc<Params: Encodable>(method: String, params: Params) async throws {
|
||||||
|
guard let socket else {
|
||||||
|
throw CompanionRuntimeError.notConnected
|
||||||
|
}
|
||||||
|
let request = JsonRpcRequest(id: nextRequestId, method: method, params: params)
|
||||||
|
nextRequestId += 1
|
||||||
|
let data = try JSONEncoder().encode(request)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
|
throw CompanionRuntimeError.encodingFailure
|
||||||
|
}
|
||||||
|
try await socket.send(.string(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated for node: ${manifest.node.nodeId} (${manifest.node.platform})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function androidBootstrapTemplate(manifest: CompanionBootstrapManifest): string {
|
||||||
return `package flynn.companion
|
return `package flynn.companion
|
||||||
|
|
||||||
// Reference Android bootstrap model for integrating with Flynn gateway runtime.
|
// Reference Android bootstrap model for integrating with Flynn gateway runtime.
|
||||||
@@ -129,41 +303,182 @@ data class Runtime(
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateFilename(platform: CompanionShellTemplatePlatform): string {
|
function androidRuntimeTemplate(manifest: CompanionBootstrapManifest): string {
|
||||||
if (platform === 'macos') {
|
return `package flynn.companion
|
||||||
return 'MenuBarCompanion.swift';
|
|
||||||
}
|
import java.util.concurrent.Executors
|
||||||
if (platform === 'ios') {
|
import java.util.concurrent.ScheduledFuture
|
||||||
return 'CompanionBootstrap.swift';
|
import java.util.concurrent.TimeUnit
|
||||||
}
|
import org.json.JSONArray
|
||||||
return 'CompanionBootstrap.kt';
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
interface CompanionTransport {
|
||||||
|
fun connect(url: String, token: String?)
|
||||||
|
fun disconnect()
|
||||||
|
fun send(rawMessage: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateBody(
|
data class CompanionStatusPayload(
|
||||||
|
val platform: String,
|
||||||
|
val appVersion: String? = null,
|
||||||
|
val statusText: String? = null,
|
||||||
|
val batteryPct: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CompanionLocationPayload(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val source: String = "device"
|
||||||
|
)
|
||||||
|
|
||||||
|
class AndroidCompanionRuntime(
|
||||||
|
private val bootstrap: CompanionBootstrap,
|
||||||
|
private val transport: CompanionTransport
|
||||||
|
) {
|
||||||
|
private var nextRequestId: Int = 1
|
||||||
|
private val scheduler = Executors.newSingleThreadScheduledExecutor()
|
||||||
|
private var heartbeatFuture: ScheduledFuture<*>? = null
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
transport.connect(bootstrap.gateway.url, bootstrap.gateway.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
heartbeatFuture?.cancel(true)
|
||||||
|
heartbeatFuture = null
|
||||||
|
transport.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerNode() {
|
||||||
|
val params = JSONObject()
|
||||||
|
.put("nodeId", bootstrap.node.nodeId)
|
||||||
|
.put("role", bootstrap.node.role)
|
||||||
|
.put("platform", bootstrap.node.platform)
|
||||||
|
.put("capabilities", JSONArray(bootstrap.node.capabilities))
|
||||||
|
sendRpc("node.register", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishStatus(status: CompanionStatusPayload) {
|
||||||
|
val statusJson = JSONObject()
|
||||||
|
.put("platform", status.platform)
|
||||||
|
status.appVersion?.let { statusJson.put("appVersion", it) }
|
||||||
|
status.statusText?.let { statusJson.put("statusText", it) }
|
||||||
|
status.batteryPct?.let { statusJson.put("batteryPct", it) }
|
||||||
|
sendRpc(
|
||||||
|
"node.status.set",
|
||||||
|
JSONObject()
|
||||||
|
.put("nodeId", bootstrap.node.nodeId)
|
||||||
|
.put("status", statusJson)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishLocation(location: CompanionLocationPayload) {
|
||||||
|
sendRpc(
|
||||||
|
"node.location.set",
|
||||||
|
JSONObject()
|
||||||
|
.put("nodeId", bootstrap.node.nodeId)
|
||||||
|
.put(
|
||||||
|
"location",
|
||||||
|
JSONObject()
|
||||||
|
.put("latitude", location.latitude)
|
||||||
|
.put("longitude", location.longitude)
|
||||||
|
.put("source", location.source)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerPushToken(token: String, topic: String? = null) {
|
||||||
|
val params = JSONObject()
|
||||||
|
.put("nodeId", bootstrap.node.nodeId)
|
||||||
|
.put("provider", "fcm")
|
||||||
|
.put("token", token)
|
||||||
|
topic?.let { params.put("topic", it) }
|
||||||
|
sendRpc("node.push_token.set", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendHandoff(message: String, sessionId: String? = null) {
|
||||||
|
val params = JSONObject()
|
||||||
|
.put("message", message)
|
||||||
|
.put("awaitResponse", true)
|
||||||
|
sessionId?.let { params.put("sessionId", it) }
|
||||||
|
sendRpc("agent.send", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startHeartbeatLoop(statusFactory: () -> CompanionStatusPayload) {
|
||||||
|
heartbeatFuture?.cancel(true)
|
||||||
|
val intervalSeconds = bootstrap.runtime.heartbeatSeconds.coerceAtLeast(1).toLong()
|
||||||
|
heartbeatFuture = scheduler.scheduleAtFixedRate(
|
||||||
|
{ publishStatus(statusFactory()) },
|
||||||
|
0L,
|
||||||
|
intervalSeconds,
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendRpc(method: String, params: JSONObject) {
|
||||||
|
val request = JSONObject()
|
||||||
|
.put("jsonrpc", "2.0")
|
||||||
|
.put("id", nextRequestId++)
|
||||||
|
.put("method", method)
|
||||||
|
.put("params", params)
|
||||||
|
transport.send(request.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated for node: ${manifest.node.nodeId} (${manifest.node.platform})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateArtifacts(
|
||||||
platform: CompanionShellTemplatePlatform,
|
platform: CompanionShellTemplatePlatform,
|
||||||
manifest: CompanionBootstrapManifest,
|
manifest: CompanionBootstrapManifest,
|
||||||
): string {
|
): TemplateArtifact[] {
|
||||||
if (platform === 'macos') {
|
if (platform === 'macos') {
|
||||||
return swiftTemplate(manifest);
|
return [{ filename: 'MenuBarCompanion.swift', body: macosTemplate(manifest) }];
|
||||||
}
|
}
|
||||||
if (platform === 'ios') {
|
if (platform === 'ios') {
|
||||||
return iosTemplate(manifest);
|
return [
|
||||||
|
{ filename: 'CompanionBootstrap.swift', body: iosBootstrapTemplate(manifest) },
|
||||||
|
{ filename: 'IOSCompanionRuntime.swift', body: iosRuntimeTemplate(manifest) },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return androidTemplate(manifest);
|
return [
|
||||||
|
{ filename: 'CompanionBootstrap.kt', body: androidBootstrapTemplate(manifest) },
|
||||||
|
{ filename: 'AndroidCompanionRuntime.kt', body: androidRuntimeTemplate(manifest) },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function readmeBody(platform: CompanionShellTemplatePlatform): string {
|
function platformArticle(platform: CompanionShellTemplatePlatform): 'a' | 'an' {
|
||||||
return `# Flynn Companion ${platform} Shell Template
|
if (platform === 'ios' || platform === 'android') {
|
||||||
|
return 'an';
|
||||||
|
}
|
||||||
|
return 'a';
|
||||||
|
}
|
||||||
|
|
||||||
This directory contains a generated starter template for a ${platform} companion shell.
|
function displayPlatformName(platform: CompanionShellTemplatePlatform): string {
|
||||||
|
if (platform === 'macos') {
|
||||||
|
return 'macOS';
|
||||||
|
}
|
||||||
|
if (platform === 'ios') {
|
||||||
|
return 'iOS';
|
||||||
|
}
|
||||||
|
return 'Android';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readmeBody(platform: CompanionShellTemplatePlatform, templateFiles: string[]): string {
|
||||||
|
const platformName = displayPlatformName(platform);
|
||||||
|
const templateList = templateFiles.map((name) => `- \`${name}\``).join('\n');
|
||||||
|
return `# Flynn Companion ${platformName} Shell Template
|
||||||
|
|
||||||
|
This directory contains a generated starter template for ${platformArticle(platform)} ${platformName} companion shell.
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
- \`companion.bootstrap.json\`: resolved Flynn companion bootstrap contract
|
- \`companion.bootstrap.json\`: resolved Flynn companion bootstrap contract
|
||||||
- \`${templateFilename(platform)}\`: platform-native starter model/wrapper snippet
|
${templateList}
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- These templates are intentionally minimal and should be integrated into your app project.
|
- These templates are intentionally minimal and should be integrated into your app project.
|
||||||
- Runtime transport should use Flynn gateway JSON-RPC node methods (\`node.register\`, \`node.status.set\`, \`node.location.set\`, \`node.push_token.set\`).
|
- Runtime transport should use Flynn gateway JSON-RPC node methods (\`node.register\`, \`node.status.set\`, \`node.location.set\`, \`node.push_token.set\`, \`agent.send\`).
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,16 +487,26 @@ export async function writeCompanionShellTemplate(
|
|||||||
): Promise<WriteCompanionShellTemplateResult> {
|
): Promise<WriteCompanionShellTemplateResult> {
|
||||||
await mkdir(input.outputDir, { recursive: true });
|
await mkdir(input.outputDir, { recursive: true });
|
||||||
const manifestPath = `${input.outputDir}/companion.bootstrap.json`;
|
const manifestPath = `${input.outputDir}/companion.bootstrap.json`;
|
||||||
const templatePath = `${input.outputDir}/${templateFilename(input.platform)}`;
|
|
||||||
const readmePath = `${input.outputDir}/README.md`;
|
const readmePath = `${input.outputDir}/README.md`;
|
||||||
|
const artifacts = templateArtifacts(input.platform, input.manifest);
|
||||||
|
const artifactPaths = artifacts.map((artifact) => `${input.outputDir}/${artifact.filename}`);
|
||||||
|
|
||||||
await writeFile(manifestPath, `${JSON.stringify(input.manifest, null, 2)}\n`, 'utf8');
|
await writeFile(manifestPath, `${JSON.stringify(input.manifest, null, 2)}\n`, 'utf8');
|
||||||
await writeFile(templatePath, templateBody(input.platform, input.manifest), 'utf8');
|
for (let i = 0; i < artifacts.length; i += 1) {
|
||||||
await writeFile(readmePath, readmeBody(input.platform), 'utf8');
|
await writeFile(artifactPaths[i], artifacts[i].body, 'utf8');
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
readmePath,
|
||||||
|
readmeBody(
|
||||||
|
input.platform,
|
||||||
|
artifacts.map((artifact) => artifact.filename),
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
outputDir: input.outputDir,
|
outputDir: input.outputDir,
|
||||||
platform: input.platform,
|
platform: input.platform,
|
||||||
files: [manifestPath, templatePath, readmePath],
|
files: [manifestPath, ...artifactPaths, readmePath],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user