Skip to content

Latest commit

 

History

History
560 lines (459 loc) · 18.4 KB

tutorial-device.md

File metadata and controls

560 lines (459 loc) · 18.4 KB

Part 2 — Your first device plugin

In Part 1 the plugin ran entirely on the Mac; this one rides in the app. On iOS, a device plugin is one Swift package that carries both the implementation and its web panel: the app links the package and registers the plugin, and the panel appears in Necto while the app is connected. Nothing is installed on the Mac side — plugin-manifest.md explains the two kinds.

The steps below write the plugin in Swift. For an Android app, build the same contract and panel, then answer them in Kotlin with The same plugin on Android.

We build Uptime: a panel that shows how long the app has been running. Two operations, one of each kind —

  • uptime.get answers once with when the app started and the seconds since.
  • uptime.observe streams the seconds, once per second, while the panel watches.

You need a running Necto and the example app, plus Node 22.12 or later for the panel build.

The package

Create the package next to your toss-necto checkout:

mkdir -p necto-uptime-plugin/Sources/NectoUptimePlugin
mkdir -p necto-uptime-plugin/panel/public necto-uptime-plugin/panel/src
cd necto-uptime-plugin

The package layout:

necto-uptime-plugin/
  Package.swift
  Sources/NectoUptimePlugin/
    UptimePlugin.swift        the implementation
    Panel/                    the built panel — vite writes it here
  panel/                      the panel source
    index.html
    package.json
    vite.config.js
    public/manifest.json
    src/main.js
    src/style.css

Package.swift:

// swift-tools-version: 6.0

import PackageDescription

let package = Package(
    name: "necto-uptime-plugin",
    platforms: [
        .iOS(.v16),
        .macOS(.v14),
    ],
    products: [
        .library(name: "NectoUptimePlugin", targets: ["NectoUptimePlugin"]),
    ],
    dependencies: [
        // The label after `package:` below is this folder's name — adjust both
        // if your checkout is called something else.
        .package(path: "../toss-necto"),
    ],
    targets: [
        .target(
            name: "NectoUptimePlugin",
            dependencies: [
                .product(name: "NectoSDK", package: "toss-necto"),
            ],
            resources: [
                .copy("Panel"),
            ]
        ),
    ]
)

resources: [.copy("Panel")] includes the built panel in the module's resources. Rebuild and commit the panel assets when web sources change, then test them with the native implementation distributed in the same package.

The contract

The manifest is the contract. It declares the operations the panel may call, and the runtime connects nothing that is not named here — no Swift type is shared with the Mac. What travels is JSON, validated against these schemas at the runtime boundary on the way in and the way out. See plugin-manifest.md for each field's definition and whether it is required.

panel/public/manifest.json:

{
  "schemaVersion": 1,
  "id": "uptime",
  "name": "Uptime",
  "description": "How long the app has been running",
  "version": "1.0.0",
  "author": "You",
  "icon": { "systemName": "clock" },
  "assets": ["index.html", "assets"],
  "allowedOrigins": ["self"],
  "operations": [
    {
      "id": "uptime.get",
      "title": "Read uptime",
      "description": "When the app started, and the seconds since",
      "kind": "once",
      "binding": {
        "name": "necto.device.uptime.get",
        "version": 1
      },
      "inputSchema": { "type": "object", "additionalProperties": false },
      "outputSchema": {
        "type": "object",
        "properties": {
          "startedAt": { "type": "string" },
          "uptimeSeconds": { "type": "number" }
        },
        "required": ["startedAt", "uptimeSeconds"],
        "additionalProperties": true
      },
      "timeoutMs": 3000
    },
    {
      "id": "uptime.observe",
      "title": "Observe uptime",
      "description": "The seconds since launch, once per second",
      "kind": "stream",
      "binding": {
        "name": "necto.device.uptime.observe",
        "version": 1
      },
      "inputSchema": { "type": "object", "additionalProperties": false },
      "outputSchema": {
        "type": "object",
        "properties": {
          "uptimeSeconds": { "type": "number" }
        },
        "required": ["uptimeSeconds"],
        "additionalProperties": true
      },
      "timeoutMs": 0
    }
  ]
}

The manifest id must match the Swift plugin's ID to associate the panel with its provider. Each binding names a necto.device. bridge answered by the app. These bindings are refused during desktop installation.

The output schemas leave additionalProperties open on purpose, so adding a field later is not a breaking change — plugin-manifest.md covers when binding.version moves.

Answering on the device

NectoPlugin requires you to implement id and register(_:). Its panel property defaults to nil; plugins with a panel override it to point to the Panel directory in the module's resources.

Sources/NectoUptimePlugin/UptimePlugin.swift:

import Foundation
import NectoModel
import NectoSDK

/// How long the app has been running, asked once or watched live.
public struct UptimePlugin: NectoPlugin {
    public let id = "uptime"

    private let startedAt = Date()

    public var panel: NectoPluginPanel? { NectoPluginPanel(bundle: .module) }

    public init() {}

    public func register(_ necto: NectoRegistrar) {
        necto.handle("uptime.get") { _ in
            [
                "startedAt": .string(ISO8601DateFormatter().string(from: startedAt)),
                "uptimeSeconds": .number(Date().timeIntervalSince(startedAt)),
            ]
        }

        necto.handle("uptime.observe") { _, out in
            while !Task.isCancelled {
                await out.send(["uptimeSeconds": .number(Date().timeIntervalSince(startedAt))])
                try await Task.sleep(for: .seconds(1))
            }
        }
    }
}

Handler names are relative to necto.device. — handle("uptime.get") here is the necto.device.uptime.get the manifest binds, and necto.device.send("uptime.get") in the panel. The closure shape picks the kind: return a value and the operation answers once; take an out and it streams until the closure returns or the caller stops listening, which cancels the closure's task and ends the loop. See bridges.md for handler registration details.

Payloads use NectoJSONValue and are validated against the manifest schema. Sharing a payload struct would couple the app and host versions.

The panel

The panel is a web page that calls operations by their manifest ids. This example bundles @necto/bridge with vite so it can run without downloading external files.

panel/package.json:

{
  "name": "uptime-panel",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "@necto/bridge": "https://github.com/toss/necto/releases/download/0.1.0/necto-bridge-0.1.0.tgz"
  },
  "devDependencies": {
    "vite": "^6.0.0"
  }
}

panel/vite.config.js — output goes straight into the Swift package's resources, and public/manifest.json is copied alongside it:

import { defineConfig } from "vite";

export default defineConfig({
  base: "./",
  build: {
    outDir: "../Sources/NectoUptimePlugin/Panel",
    emptyOutDir: true,
    rollupOptions: {
      output: {
        entryFileNames: "assets/[name].js",
        chunkFileNames: "assets/[name].js",
        assetFileNames: "assets/[name].[ext]",
      },
    },
  },
});

panel/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="color-scheme" content="light dark" />
    <title>Uptime</title>
    <link rel="stylesheet" href="./src/style.css" />
  </head>
  <body>
    <div class="necto-app">
      <div class="necto-body">
        <h2 class="necto-section-title">Uptime</h2>
        <div class="necto-toolbar-group">
          <button type="button" class="necto-button" id="refresh">Refresh</button>
        </div>
        <dl class="necto-pairs">
          <div><dt>Started at</dt><dd id="started-at">—</dd></div>
          <div><dt>Uptime</dt><dd id="uptime">Loading…</dd></div>
        </dl>
      </div>
    </div>
    <script type="module" src="./src/main.js"></script>
  </body>
</html>

panel/src/style.css — the two shared stylesheets, and nothing hardcoded. Tokens are how the panel follows the window into dark mode with no theme code of its own; the rules and the full list are in design.md:

@import "@necto/bridge/theme.css";
@import "@necto/bridge/components.css";

.necto-body {
  padding: var(--necto-space-3);
}

panel/src/main.js — necto.device.send for the query, necto.device.subscribe for the stream. The side is named at the call site because only device can fail from nothing being connected:

import { necto, isNectoBridgeError } from "@necto/bridge";

function show(id, text) {
  document.getElementById(id).textContent = text;
}

function describe(error) {
  return isNectoBridgeError(error) ? `${error.code}: ${error.message}` : String(error);
}

async function refresh() {
  try {
    const snapshot = await necto.device.send("uptime.get");
    show("started-at", snapshot.startedAt);
    show("uptime", `${Math.round(snapshot.uptimeSeconds)} s`);
  } catch (error) {
    show("uptime", describe(error));
  }
}

async function main() {
  if (!necto.isAvailable()) {
    show("uptime", "This plugin only works inside Necto");
    return;
  }

  document.getElementById("refresh").addEventListener("click", () => void refresh());

  await refresh();

  try {
    await necto.device.subscribe(
      "uptime.observe",
      {},
      (event) => show("uptime", `${Math.round(event.uptimeSeconds)} s`),
      (error) => show("uptime", describe(error)),
    );
  } catch (error) {
    show("uptime", describe(error));
  }

  // Signal only after every handler is registered; the host buffers events until then.
  await necto.ready();
}

void main();

Build it:

cd panel
npm install
npm run build
cd ..

Sources/NectoUptimePlugin/Panel/ now holds manifest.json, index.html and assets/. The panel always builds before the app — the SDK reads the Panel folder at registration, so an app built first carries stale assets, or none.

Carry it in the app

Adding the package to the app grants access to the device plugin. There is no separate installation dialog on the Mac.

  1. Open Necto.xcodeproj in the toss-necto checkout.
  2. File ▸ Add Package Dependencies… ▸ Add Local…, and pick necto-uptime-plugin.
  3. In the ExampleApp target, under General ▸ Frameworks, Libraries, and Embedded Content, add NectoUptimePlugin.

Then register it in ExampleApp/ExampleApp.swift, anywhere before NectoSDK.start():

import NectoUptimePlugin
NectoSDK.register(UptimePlugin())

Run it, on the simulator loop from verification.md:

xcrun simctl list devices available          # pick a device id
xcrun simctl boot <device-id>
xcodebuild -project Necto.xcodeproj -scheme ExampleApp -destination "id=<device-id>" SYMROOT="$PWD/Build/Products" build
xcrun simctl install <device-id> Build/Products/Debug-iphonesimulator/ExampleApp.app
xcrun simctl launch <device-id> im.toss.necto.example

With Necto running (open Build/Products/Debug/Necto.app), the app appears in the sidebar within a couple of seconds, and Uptime with it. Open the panel: the seconds tick up once per second, and Refresh re-asks the query. Necto fetched the panel from the app over the wire — nothing was installed on the Mac, and the panel stays for as long as the app offers it, going gray when the app disconnects.

Verify the plugin

The checks from verification.md that apply here:

  • Order matters. Panel first (npm run build), then the app — plugin output rides inside the package, so an app built before the panel carries stale assets. And reinstall the app on the simulator being inspected: building against another booted simulator updates that product, not the app already serving the panel.
  • Check it in the app, not only in a browser. Two things only appear in the real host: the bridge, and the theme the window imposes. npm run dev gives the layout loop — the page renders its fallback line, since the bridge is not there.
  • Toggle System Settings between light and dark with the panel open. Because the panel draws from the tokens, it follows without a reload.
  • Break the contract on purpose. Return .string where the schema says number and the call fails with INVALID_OUTPUT before it reaches the panel.
  • The USB path cannot be verified in a simulator. The simulator uses loopback. Before trusting the plugin on a device, run it there over USB, and say so when you have not.

When contributing a plugin or changing a bridge, follow the implementation rules and verification steps in harness.md.

The same plugin on Android

An Android app carries the plugin as Kotlin code and Java resources instead of a Swift package. The contract and the panel stay as they are: the Kotlin plugin uses the same id and answers the same two operations, so one built panel serves both platforms.

Add necto-android to the debug build, with JitPack configured as in setup.md. It brings in necto-core, which holds the plugin API, so a plugin of your own needs no other module.

// app/build.gradle.kts
val nectoVersion = "COMMIT_OR_TAG"
dependencies {
    debugImplementation("com.github.toss.necto:necto-android:$nectoVersion")
}

NectoPlugin asks for the same things as in Swift: an id and register(necto), plus a panel that defaults to null. Keep the class in the debug source set, next to the dependency.

app/src/debug/kotlin/com/example/UptimePlugin.kt:

package com.example

import im.toss.necto.model.jsonObject
import im.toss.necto.model.jsonOf
import im.toss.necto.sdk.NectoPlugin
import im.toss.necto.sdk.NectoPluginPanel
import im.toss.necto.sdk.NectoRegistrar
import kotlinx.coroutines.delay
import java.time.Duration
import java.time.Instant

/** How long the app has been running, asked once or watched live. */
class UptimePlugin : NectoPlugin {
    override val id = "uptime"

    private val startedAt = Instant.now()

    override val panel: NectoPluginPanel =
        NectoPluginPanel.resources("panels/uptime", UptimePlugin::class.java.classLoader!!)

    override fun register(necto: NectoRegistrar) {
        necto.handle("uptime.get") {
            jsonObject(
                "startedAt" to jsonOf(startedAt.toString()),
                "uptimeSeconds" to jsonOf(uptimeSeconds()),
            )
        }

        necto.stream("uptime.observe") { _, out ->
            while (true) {
                out.send(jsonObject("uptimeSeconds" to jsonOf(uptimeSeconds())))
                delay(1000)
            }
        }
    }

    private fun uptimeSeconds(): Double =
        Duration.between(startedAt, Instant.now()).toMillis() / 1000.0
}

Here the method picks the kind: handle answers once, and stream answers until its body returns or the caller stops listening, which cancels the coroutine running the body and ends the loop. Payloads are NectoJsonValue, built with jsonObject and jsonOf.

Carry the panel

NectoPluginPanel.resources("panels/uptime", …) reads the panel from the app's Java resources. Resource folders cannot be listed at run time, so it needs an index: panels/uptime/files.txt, naming each panel file by its path relative to that folder, one per line.

The modules Necto ships generate both with NectoPanelResources, a Gradle task in the repository's own buildSrc that is not published. In your own app, copy the built panel into the debug resources and write the index yourself, from the app project's root:

panel=app/src/debug/resources/panels/uptime
mkdir -p "$panel"
cp -R <path-to>/necto-uptime-plugin/Sources/NectoUptimePlugin/Panel/. "$panel"
(cd "$panel" && find . -type f ! -name '.*' ! -name files.txt | sed 's|^\./||' | sort > files.txt)

files.txt now lists index.html, manifest.json and the files under assets/. Run this again after every panel build: a file missing from the index is not served, and a listed file that does not exist fails the whole panel. The sample app shows the Gradle task doing the same for an app's own panel.

Register and run

Pass the plugin to NectoAndroid.start in the debug NectoSetup from setup.md. It registers the listed plugins and starts listening; in a build that is not debuggable it only logs a warning.

// app/src/debug/kotlin/com/example/NectoSetup.kt
NectoAndroid.start(app, listOf(UptimePlugin()))

Install the debug build on an emulator or a USB device, then launch the app:

./gradlew :app:installDebug

With Necto running, the app appears in the sidebar, and Uptime with it. If it does not, install.md lists what Necto needs to find an Android app. The checks in Verify the plugin apply with two differences. The order is panel build, copy and index, then reinstall the app. And an emulator's ADB transport is not USB, so run the plugin on a device before calling the USB path verified.

Where next

  • plugin-manifest.md — the full manifest reference: version axes, error codes, and what each plugin kind can do.
  • bridges.md — everything a panel can ask Necto to do, including the desktop bridges.
  • harness.md — implementation rules and mock-host verification.
  • setup.md — wiring plugins into your own app and excluding SDK calls from Release builds.
  • CONTRIBUTING.md — how to contribute your plugin.