purposeos

Quickstart: a hello app

One action, a Go service that answers it, a small window, and a phrase that triggers it. Total: three files and one icon.

Tested here: the manifest passes the real validator (exec-manifest) and generates the executor manifest shown below. The Go service builds as a static arm64 binary and answers the real wire protocol. Packing and installing on a booted image follow the installer docs; I have not run those two steps for this exact app.

1. Lay out the folder

hello/
  app.json
  bin/hello-svc          built in step 2
  ui/index.html
  assets/hello.svg       any square SVG

2. Write the service

A service is any program that accepts serve --socket PATH and answers newline-delimited JSON. First it answers describe with its name and actions. Then one reply per request. This one uses only Go's standard library.

main.go
package main

import (
	"bufio"
	"encoding/json"
	"flag"
	"net"
	"os"
)

type req struct {
	Op     string          `json:"op"`
	Action string          `json:"action"`
	Input  json.RawMessage `json:"input"`
}

var actions = []string{"hello.greet"}

// handle must be idempotent: the executor may retry a call.
func handle(action string, in json.RawMessage) (any, string, string) {
	if action == "hello.greet" {
		p := struct{ Name string `json:"name"` }{Name: "world"}
		_ = json.Unmarshal(in, &p)
		return map[string]string{"text": "hello " + p.Name}, "", ""
	}
	return nil, "permanent", "unknown action"
}

func main() {
	if len(os.Args) < 2 || os.Args[1] != "serve" {
		os.Exit(2)
	}
	fs := flag.NewFlagSet("serve", flag.ExitOnError)
	sock := fs.String("socket", "", "unix socket path")
	fs.Parse(os.Args[2:])
	os.Remove(*sock)
	l, err := net.Listen("unix", *sock)
	if err != nil {
		panic(err)
	}
	os.Chmod(*sock, 0o600)
	for {
		c, err := l.Accept()
		if err != nil {
			return
		}
		go func(c net.Conn) {
			defer c.Close()
			sc := bufio.NewScanner(c)
			sc.Buffer(make([]byte, 1<<20), 1<<20) // requests are capped at 1 MiB
			enc := json.NewEncoder(c)
			for sc.Scan() {
				var r req
				if json.Unmarshal(sc.Bytes(), &r) != nil {
					enc.Encode(map[string]any{"error": map[string]string{"kind": "permanent", "message": "bad json"}})
					continue
				}
				if r.Op == "describe" {
					enc.Encode(map[string]any{"ok": map[string]any{"protocol": 1, "name": "hello-svc", "actions": actions}})
					continue
				}
				v, kind, msg := handle(r.Action, r.Input)
				if kind != "" {
					enc.Encode(map[string]any{"error": map[string]string{"kind": kind, "message": msg}})
				} else {
					enc.Encode(map[string]any{"ok": v})
				}
			}
		}(c)
	}
}

Build it static for the image's CPU and put it in bin/:

CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o hello/bin/hello-svc .

The name in describe must equal <id>-<service> from the manifest. Here that is hello-svc. The executor refuses a service that does not serve every action the manifest declares.

3. Write the manifest

hello/app.json
{
  "schema": 1,
  "id": "hello",
  "name": "Hello",
  "version": "0.1.0",
  "requires": { "protocol": 1 },
  "ui": { "kind": "web", "entry": "ui/index.html" },
  "launcher": { "icon": "assets/hello.svg", "categories": ["Utility"], "show": true },
  "services": [
    {
      "name": "svc",
      "runtime": "native",
      "exec": "bin/hello-svc",
      "start": "session",
      "timeout_ms": 10000,
      "actions": ["hello.greet"]
    }
  ],
  "intents": [
    { "intent": "greet", "action": "hello.greet", "phrases": ["say hello", "greet me"] }
  ],
  "names": ["hello"],
  "permissions": { "call": [], "data": "own", "files": [], "network": false, "mic": false }
}

The action starts with hello., so the app owns it. The intent lets you say “say hello” or “greet me”. permissions.call is empty because an app may always call its own services.

4. Write the window

hello/ui/index.html
<!doctype html>
<meta charset="utf-8" />
<title>Hello</title>
<body style="font-family: Roboto, sans-serif; padding: 24px">
  <input id="name" placeholder="Your name" />
  <button id="go">Greet</button>
  <p id="out"></p>
  <script>
    document.getElementById("go").onclick = async () => {
      const out = document.getElementById("out");
      try {
        const name = document.getElementById("name").value || undefined;
        out.textContent = (await purpose.call("hello.greet", { name })).text;
      } catch (e) {
        out.textContent = e.kind + ": " + e.message; // PurposeError { kind, message }
      }
    };
  </script>
</body>

The page runs inside purpose-webhost, which injects purpose. It can reach your service and nothing else.

5. Pack and install

purpose-pkg pack hello -o hello-0.1.0-aarch64.papp --arch aarch64
purpose-pkg install hello-0.1.0-aarch64.papp
purpose-pkg verify hello

Install validates everything in a staging folder first: checksums, archive safety, CPU architecture, the manifest rules and that every referenced file exists. If any step fails it rolls back. The executor binds the app's socket within about two seconds. The service starts with your next session. To start it sooner, run the generated hook by hand: ~/.local/share/purpose/session.d/60-hello-svc.

6. Use it

purpose-exec call hello.greet '{"name":"kd"}'
# {"text":"hello kd"}

Open Hello from the app grid, or say “say hello”. To remove it: purpose-pkg uninstall hello --purge-data.

Next