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
@@ -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.
`;
}
+9 -1
View File
@@ -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
View File
@@ -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],
};
}