feat(companion): add mobile runtime skeleton shell templates
This commit is contained in:
@@ -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