feat(companion): add mobile runtime skeleton shell templates

This commit is contained in:
William Valentin
2026-02-26 20:56:43 -08:00
parent d303869866
commit 078c3799ce
20 changed files with 690 additions and 54 deletions
+2
View File
@@ -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)
+5 -4
View File
@@ -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)
+5 -4
View File
@@ -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 -1
View File
@@ -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,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"
},
+4 -4
View File
@@ -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"
},