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/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/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/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.
|
||||
@@ -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-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 --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.
|
||||
- `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.
|
||||
|
||||
@@ -8,3 +8,5 @@ This directory contains generated companion starter shells for:
|
||||
- Android shell
|
||||
|
||||
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:
|
||||
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
||||
- `CompanionBootstrap.kt`: platform-native starter model/wrapper snippet
|
||||
- `CompanionBootstrap.kt`
|
||||
- `AndroidCompanionRuntime.kt`
|
||||
|
||||
Notes:
|
||||
- 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,
|
||||
"generatedAt": "2026-02-27T04:50:38.373Z",
|
||||
"generatedAt": "2026-02-27T04:56:19.684Z",
|
||||
"gateway": {
|
||||
"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:
|
||||
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
||||
- `CompanionBootstrap.swift`: platform-native starter model/wrapper snippet
|
||||
- `CompanionBootstrap.swift`
|
||||
- `IOSCompanionRuntime.swift`
|
||||
|
||||
Notes:
|
||||
- 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,
|
||||
"generatedAt": "2026-02-27T04:50:38.373Z",
|
||||
"generatedAt": "2026-02-27T04:56:19.684Z",
|
||||
"gateway": {
|
||||
"url": "ws://127.0.0.1:18800"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-02-27T04:50:38.373Z",
|
||||
"generatedAt": "2026-02-27T04:56:19.684Z",
|
||||
"gateway": {
|
||||
"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:
|
||||
- `companion.bootstrap.json`: resolved Flynn companion bootstrap contract
|
||||
- `MenuBarCompanion.swift`: platform-native starter model/wrapper snippet
|
||||
- `MenuBarCompanion.swift`
|
||||
|
||||
Notes:
|
||||
- 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,
|
||||
"generatedAt": "2026-02-27T04:50:38.373Z",
|
||||
"generatedAt": "2026-02-27T04:56:19.684Z",
|
||||
"gateway": {
|
||||
"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 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 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 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)
|
||||
|
||||
@@ -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 ... --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 --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: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.
|
||||
|
||||
@@ -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 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 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.
|
||||
- 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.
|
||||
|
||||
@@ -75,7 +75,9 @@ flynn companion \
|
||||
Generated files:
|
||||
|
||||
- `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`
|
||||
|
||||
## 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)
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
|
||||
+12
-4
@@ -7018,13 +7018,21 @@
|
||||
"status": "completed",
|
||||
"date": "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": [
|
||||
"src/companion/shellTemplate.ts",
|
||||
"src/companion/shellTemplate.test.ts",
|
||||
"src/companion/index.ts",
|
||||
"src/cli/companion.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",
|
||||
"docs/api/PROTOCOL.md",
|
||||
"docs/architecture/AGENT_DIAGRAM.md",
|
||||
@@ -7033,7 +7041,7 @@
|
||||
"docs/plans/2026-02-26-personal-assistant-productization-plan.md",
|
||||
"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": {
|
||||
"status": "completed",
|
||||
@@ -7213,7 +7221,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -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_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)",
|
||||
"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."
|
||||
},
|
||||
"soul_md_and_cron_create": {
|
||||
|
||||
@@ -41,6 +41,8 @@ This directory contains generated companion starter shells for:
|
||||
- Android shell
|
||||
|
||||
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 runtimeRaw = await readFile(`${outDir}/IOSCompanionRuntime.swift`, 'utf8');
|
||||
const manifestRaw = await readFile(`${outDir}/companion.bootstrap.json`, 'utf8');
|
||||
const readmeRaw = await readFile(`${outDir}/README.md`, 'utf8');
|
||||
|
||||
expect(result.files).toEqual([
|
||||
`${outDir}/companion.bootstrap.json`,
|
||||
`${outDir}/CompanionBootstrap.swift`,
|
||||
`${outDir}/IOSCompanionRuntime.swift`,
|
||||
`${outDir}/README.md`,
|
||||
]);
|
||||
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(readmeRaw).toContain('Shell Template');
|
||||
expect(readmeRaw).toContain('IOSCompanionRuntime.swift');
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -65,8 +70,11 @@ describe('writeCompanionShellTemplate', () => {
|
||||
});
|
||||
|
||||
const androidTemplate = await readFile(`${androidDir}/CompanionBootstrap.kt`, 'utf8');
|
||||
const androidRuntime = await readFile(`${androidDir}/AndroidCompanionRuntime.kt`, 'utf8');
|
||||
const macosTemplate = await readFile(`${macosDir}/MenuBarCompanion.swift`, 'utf8');
|
||||
expect(androidTemplate).toContain('data class CompanionBootstrap');
|
||||
expect(androidRuntime).toContain('class AndroidCompanionRuntime');
|
||||
expect(androidRuntime).toContain("\"node.register\"");
|
||||
expect(macosTemplate).toContain('launchFlynnCompanion');
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
+350
-25
@@ -15,7 +15,12 @@ export interface WriteCompanionShellTemplateResult {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
function swiftTemplate(manifest: CompanionBootstrapManifest): string {
|
||||
interface TemplateArtifact {
|
||||
filename: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function macosTemplate(manifest: CompanionBootstrapManifest): string {
|
||||
return `import Foundation
|
||||
|
||||
struct CompanionBootstrap: Codable {
|
||||
@@ -57,7 +62,7 @@ func launchFlynnCompanion() throws {
|
||||
`;
|
||||
}
|
||||
|
||||
function iosTemplate(manifest: CompanionBootstrapManifest): string {
|
||||
function iosBootstrapTemplate(manifest: CompanionBootstrapManifest): string {
|
||||
return `import Foundation
|
||||
|
||||
// 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
|
||||
|
||||
// Reference Android bootstrap model for integrating with Flynn gateway runtime.
|
||||
@@ -129,41 +303,182 @@ data class Runtime(
|
||||
`;
|
||||
}
|
||||
|
||||
function templateFilename(platform: CompanionShellTemplatePlatform): string {
|
||||
if (platform === 'macos') {
|
||||
return 'MenuBarCompanion.swift';
|
||||
}
|
||||
if (platform === 'ios') {
|
||||
return 'CompanionBootstrap.swift';
|
||||
}
|
||||
return 'CompanionBootstrap.kt';
|
||||
function androidRuntimeTemplate(manifest: CompanionBootstrapManifest): string {
|
||||
return `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)
|
||||
}
|
||||
|
||||
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,
|
||||
manifest: CompanionBootstrapManifest,
|
||||
): string {
|
||||
): TemplateArtifact[] {
|
||||
if (platform === 'macos') {
|
||||
return swiftTemplate(manifest);
|
||||
return [{ filename: 'MenuBarCompanion.swift', body: macosTemplate(manifest) }];
|
||||
}
|
||||
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 {
|
||||
return `# Flynn Companion ${platform} Shell Template
|
||||
function platformArticle(platform: CompanionShellTemplatePlatform): 'a' | 'an' {
|
||||
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:
|
||||
- \`companion.bootstrap.json\`: resolved Flynn companion bootstrap contract
|
||||
- \`${templateFilename(platform)}\`: platform-native starter model/wrapper snippet
|
||||
${templateList}
|
||||
|
||||
Notes:
|
||||
- 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> {
|
||||
await mkdir(input.outputDir, { recursive: true });
|
||||
const manifestPath = `${input.outputDir}/companion.bootstrap.json`;
|
||||
const templatePath = `${input.outputDir}/${templateFilename(input.platform)}`;
|
||||
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(templatePath, templateBody(input.platform, input.manifest), 'utf8');
|
||||
await writeFile(readmePath, readmeBody(input.platform), 'utf8');
|
||||
for (let i = 0; i < artifacts.length; i += 1) {
|
||||
await writeFile(artifactPaths[i], artifacts[i].body, 'utf8');
|
||||
}
|
||||
await writeFile(
|
||||
readmePath,
|
||||
readmeBody(
|
||||
input.platform,
|
||||
artifacts.map((artifact) => artifact.filename),
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return {
|
||||
outputDir: input.outputDir,
|
||||
platform: input.platform,
|
||||
files: [manifestPath, templatePath, readmePath],
|
||||
files: [manifestPath, ...artifactPaths, readmePath],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user