e9a4c4a601
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 1m1s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
ci / bench (push) Successful in 7m12s
apple / screenshots (push) Successful in 6m21s
deb / build-publish (push) Successful in 11m27s
deb / build-publish-host (push) Successful in 12m0s
arch / build-publish (push) Successful in 13m2s
android / android (push) Successful in 15m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m38s
ci / rust (push) Successful in 20m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m2s
docker / deploy-docs (push) Successful in 27s
windows-host / package (push) Successful in 15m19s
The LocalService runner can't traverse the interactive user's profile the way the old SYSTEM runner could — so a plugin can no longer read a file an app running as *you* produced (the acute case: the Playnite exporter's library JSON under %APPDATA%). Confirmed on-glass: as LocalService the glob finds nothing and the profile is un-traversable. Add the inverse of plugin-state: <config_dir>\ingest, granted BUILTIN\Users Modify by plugins enable (disable reverts to inherited Users:RX). An interactive-user app drops ingest\<plugin>\… and the de-privileged runner reads it there — the one Users-writable carve-out in the otherwise Users-read-only tree. SDK exports pluginIngestDir(name) to resolve it; on Linux the systemd --user runner owns the config dir so same-user producers write there with no grant. Accepted tradeoff: the inbox is writable by any local user (trusted-single- user model; it feeds only a LocalService runner). Consumers must treat ingest data as lower trust than their own state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
202 lines
6.4 KiB
TypeScript
202 lines
6.4 KiB
TypeScript
// `@punktfunk/host` — the Promise facade, the package's front door (RFC §7): `connect()`,
|
|
// `await`, `.on()` — a thin veneer over the same core the Effect surface uses (config
|
|
// resolution, one HTTP implementation, one reconnecting SSE source). Effect is the substrate
|
|
// and the power surface (`@punktfunk/host/effect`), never a prerequisite:
|
|
//
|
|
// import { connect } from "@punktfunk/host";
|
|
//
|
|
// const pf = await connect();
|
|
// pf.events.on("pairing.pending", async (e) => {
|
|
// await notifyPhone(`Pairing request from ${e.device.name}`);
|
|
// });
|
|
import { type Effect, Result } from "effect";
|
|
import { type HostApi, makeHostApi } from "./api.js";
|
|
import type { PunktfunkHost } from "./client.js";
|
|
import {
|
|
type ConnectOptions,
|
|
type ResolvedConfig,
|
|
resolveConfig,
|
|
} from "./config.js";
|
|
import { HttpStatusError, httpRequest } from "./core.js";
|
|
import { type SseFrame, sseFrames } from "./sse.js";
|
|
import {
|
|
decodeHostEvent,
|
|
type EventOf,
|
|
type HostEvent,
|
|
type HostEventKind,
|
|
kindMatches,
|
|
} from "./wire.js";
|
|
|
|
export type { HostApi } from "./api.js";
|
|
export { HttpStatusError } from "./core.js";
|
|
export type { ConnectOptions } from "./config.js";
|
|
// A plugin persists its state here — the one dir the de-privileged Windows runner may write.
|
|
export { pluginStateDir } from "./config.js";
|
|
// A plugin reads cross-account data (dropped by an interactive-user app) from here.
|
|
export { pluginIngestDir } from "./config.js";
|
|
export {
|
|
type PluginUiHandle,
|
|
type PluginUiOptions,
|
|
servePluginUi,
|
|
} from "./ui.js";
|
|
export type {
|
|
ClientRef,
|
|
DeviceRef,
|
|
DisconnectReason,
|
|
EventOf,
|
|
HostEvent,
|
|
HostEventKind,
|
|
Plane,
|
|
SessionRef,
|
|
StreamRef,
|
|
} from "./wire.js";
|
|
|
|
/** `on()` also accepts these beyond the typed kinds. */
|
|
type SpecialPattern = "*" | "dropped" | "unknown" | (string & {});
|
|
|
|
interface Listener {
|
|
pattern: string;
|
|
cb: (ev: never) => unknown;
|
|
}
|
|
|
|
export interface PunktfunkEvents {
|
|
/**
|
|
* Subscribe to lifecycle events. `pattern` is an exact kind (`"stream.started"` — the
|
|
* callback is typed to it), a `domain.*` prefix, `"*"` (every known event), `"dropped"`
|
|
* (the fell-off-the-ring marker), or `"unknown"` (kinds this SDK doesn't know — a newer
|
|
* host). Returns the unsubscribe function. Callback errors are caught and warned, never
|
|
* fatal to the stream.
|
|
*/
|
|
on<K extends HostEventKind>(
|
|
pattern: K,
|
|
cb: (ev: EventOf<K>) => unknown,
|
|
): () => void;
|
|
on(pattern: SpecialPattern, cb: (ev: HostEvent) => unknown): () => void;
|
|
}
|
|
|
|
export interface Punktfunk {
|
|
/** Resolved connection (URL/token/CA). */
|
|
readonly config: ResolvedConfig;
|
|
/**
|
|
* The **typed** management API — every REST endpoint as an autocompletable method with
|
|
* checked request/response types (`await pf.api.listPairedClients()`). This is the front
|
|
* door; reach for [`request`] only for something the generated client doesn't cover.
|
|
*/
|
|
readonly api: HostApi;
|
|
/** Untyped escape hatch: one raw `/api/v1` request (`request("GET", "/status")`). */
|
|
request(method: string, path: string, body?: unknown): Promise<unknown>;
|
|
/** The lifecycle-event subscription surface. */
|
|
readonly events: PunktfunkEvents;
|
|
/** Stop the event stream and release the connection. */
|
|
close(): void;
|
|
}
|
|
|
|
/**
|
|
* Connect to the host's management API: resolves the URL, bearer token, and the host's
|
|
* self-signed identity cert from the environment/host files (zero config on the host box),
|
|
* verifies the credentials with a `/health`-adjacent probe, and returns the client.
|
|
*/
|
|
export const connect = async (options?: ConnectOptions): Promise<Punktfunk> => {
|
|
const cfg = await resolveConfig(options);
|
|
// Fail fast on bad credentials/URL: one cheap authenticated probe.
|
|
await httpRequest(cfg, "GET", "/host");
|
|
const api = await makeHostApi(cfg);
|
|
|
|
const listeners = new Set<Listener>();
|
|
let pump: AsyncGenerator<SseFrame> | undefined;
|
|
let closed = false;
|
|
|
|
const warn = (m: string) => console.warn(`[punktfunk] ${m}`);
|
|
const dispatch = (pattern: string, ev: unknown) => {
|
|
for (const l of listeners) {
|
|
if (l.pattern === pattern || (pattern !== "dropped" && pattern !== "unknown" && (l.pattern === "*" || kindMatches(l.pattern, pattern)))) {
|
|
try {
|
|
(l.cb as (e: unknown) => unknown)(ev);
|
|
} catch (e) {
|
|
warn(`listener for "${l.pattern}" threw: ${e}`);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const startPump = () => {
|
|
if (pump || closed) return;
|
|
pump = sseFrames(cfg, { onWarning: warn });
|
|
void (async () => {
|
|
try {
|
|
for await (const frame of pump) {
|
|
if (closed) break;
|
|
if (frame.event === "dropped") {
|
|
dispatch("dropped", frame);
|
|
continue;
|
|
}
|
|
let json: unknown;
|
|
try {
|
|
json = JSON.parse(frame.data);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const decoded = decodeHostEvent(json);
|
|
if (Result.isFailure(decoded)) {
|
|
dispatch("unknown", json);
|
|
continue;
|
|
}
|
|
dispatch(decoded.success.kind, decoded.success);
|
|
}
|
|
} catch (e) {
|
|
if (!closed) warn(`event stream stopped: ${e}`);
|
|
}
|
|
})();
|
|
};
|
|
|
|
return {
|
|
config: cfg,
|
|
api,
|
|
request: (method, path, body) => httpRequest(cfg, method, path, body),
|
|
events: {
|
|
on(pattern: string, cb: (ev: never) => unknown) {
|
|
const l: Listener = { pattern, cb };
|
|
listeners.add(l);
|
|
startPump();
|
|
return () => listeners.delete(l);
|
|
},
|
|
} as PunktfunkEvents,
|
|
close() {
|
|
closed = true;
|
|
pump?.return(undefined).catch(() => {});
|
|
pump = undefined;
|
|
},
|
|
};
|
|
};
|
|
|
|
// ------------------------------------------------------------------- plugins (RFC §8)
|
|
|
|
/**
|
|
* A plugin's `main`: either a plain async function receiving the connected client, or an
|
|
* Effect requiring the `PunktfunkHost` service (`@punktfunk/host/effect`) — the shape that
|
|
* makes it well-behaved under the managed runner's supervision (structured interruption,
|
|
* scoped finalizers).
|
|
*/
|
|
export type PluginMain =
|
|
| ((pf: Punktfunk) => Promise<unknown> | unknown)
|
|
| Effect.Effect<unknown, unknown, PunktfunkHost>;
|
|
|
|
export interface PluginDef {
|
|
/** Package-convention name (`punktfunk-plugin-*` drops the prefix): kebab-case. */
|
|
name: string;
|
|
main: PluginMain;
|
|
}
|
|
|
|
/**
|
|
* Declare a plugin (the `punktfunk-plugin-*` convention, RFC §8). In v1 a plugin is a script
|
|
* the operator runs; the managed runner (a later, optional package) discovers this default
|
|
* export and supervises `main`.
|
|
*/
|
|
export const definePlugin = (def: PluginDef): PluginDef => {
|
|
if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
|
|
throw new Error(
|
|
`plugin name "${def.name}" must be kebab-case ([a-z][a-z0-9-]*)`,
|
|
);
|
|
}
|
|
return def;
|
|
};
|