feat: Phase 3 recipes file, spotify_play, and locked-screen refusal
Phase 3 of AGENTS.md section 11.
Recipes file (FR-5)
- recipes/ holds one hand-editable file per task; recipes/spotify_play.recipe
is the first one.
- internal/recipe parses directives, guards, parameters and steps, and runs
them with a per-step and a whole-run timeout (FR-11).
- Steps name text ("tap the element whose text is Search"), never coordinates.
- guards name screens Artemis must never tap on, so a signed-out or
first-run Spotify fails with its own reason (FR-8).
- Every run appends task, params, steps, result and duration to
logs/runs.jsonl (FR-10).
spotify_play
- launch, search, play, then wait for the transport button to say "Pause".
- internal/ui/rows.go adds tap_row: a result row is a clickable container with
a title and a subtitle, so a one-line search suggestion is not a result, and
the tap lands on the row's centre rather than on its "More options" button.
This fixes a real wrong tap: a plain text tap hit a 126x12 pixel
"More options for song Honky Tonk Women - Live" button.
Locked screen (AGENTS.md section 13 Q2, decided)
- Artemis fails loudly on a secure keyguard and never types a PIN. No secret is
stored. A non-secure keyguard is still dismissed.
- internal/ui/keyguard.go reads the KeyguardServiceDelegate block plus
cmd lock_settings is-user-secure.
- Every acting command wakes the screen before it checks the keyguard: a dozing
phone reports showing=false, because the lock screen is not drawn yet.
- AGENTS.md records the decision and that storing a PIN for auto-unlock is
rejected for now.
Verified live: the phone locked itself mid-session, and keyguard, tap and
run spotify_play all refused with "phone is locked; unlock it and retry".
The live end-to-end playback run still needs an unlocked phone.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// Command artemis drives an Android phone over ADB wireless.
|
||||
//
|
||||
// This is the Phase 2 surface: act primitives on the command line. The Hub's
|
||||
// HTTP action API arrives in Phase 6 and reuses these packages.
|
||||
// This is the command line surface: connection, act primitives, and the named
|
||||
// recipes in recipes/. The Hub's HTTP action API arrives in Phase 6 and reuses
|
||||
// these packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -22,6 +23,7 @@ connection
|
||||
connect [host] connect to the phone (scans ports if host given)
|
||||
devices list adb devices
|
||||
wake wake the screen
|
||||
keyguard report the lock-screen state
|
||||
|
||||
reading
|
||||
dump print visible text nodes with bounds
|
||||
@@ -37,6 +39,10 @@ acting
|
||||
back press back
|
||||
home press home
|
||||
|
||||
recipes
|
||||
recipes list the recipes in the recipes directory
|
||||
run <task> [k=v ...] run a named recipe, e.g. run spotify_play query="..."
|
||||
|
||||
proof
|
||||
demo Phase 2 DoD: launch an app, tap a named element
|
||||
|
||||
@@ -44,6 +50,8 @@ environment
|
||||
ARTEMIS_PHONE phone tailscale IP (default 100.101.49.17)
|
||||
ARTEMIS_ADB_SERIAL pin a specific adb serial
|
||||
ARTEMIS_ADB path to the adb binary (default: PATH)
|
||||
ARTEMIS_RECIPES recipes directory (default: recipes)
|
||||
ARTEMIS_LOG run log file (default: logs/runs.jsonl)
|
||||
`
|
||||
|
||||
func main() {
|
||||
@@ -64,6 +72,8 @@ func main() {
|
||||
err = cmdDevices(r)
|
||||
case "wake":
|
||||
err = cmdWake(r)
|
||||
case "keyguard":
|
||||
err = cmdKeyguard(r)
|
||||
case "dump":
|
||||
err = cmdDump(r)
|
||||
case "find":
|
||||
@@ -81,9 +91,23 @@ func main() {
|
||||
case "scroll":
|
||||
err = cmdScroll(r, args)
|
||||
case "back":
|
||||
err = ensurePhone(r, func() error { return ui.Back(r) })
|
||||
err = ensurePhone(r, func() error {
|
||||
if err := wakeAndGuard(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return ui.Back(r)
|
||||
})
|
||||
case "home":
|
||||
err = ensurePhone(r, func() error { return ui.Home(r) })
|
||||
err = ensurePhone(r, func() error {
|
||||
if err := wakeAndGuard(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return ui.Home(r)
|
||||
})
|
||||
case "recipes":
|
||||
err = cmdRecipes(args)
|
||||
case "run":
|
||||
err = cmdRun(r, args)
|
||||
case "demo":
|
||||
err = cmdDemo(r)
|
||||
case "-h", "--help", "help":
|
||||
@@ -119,6 +143,19 @@ func ensurePhone(r *adb.Runner, fn func() error) error {
|
||||
return fn()
|
||||
}
|
||||
|
||||
// wakeAndGuard wakes the screen and refuses to act while a secure keyguard is
|
||||
// showing. Artemis has no PIN and never types one (AGENTS.md section 13 Q2).
|
||||
//
|
||||
// Waking first matters. A dozing phone reports `showing=false` because the lock
|
||||
// screen is not drawn yet, so the keyguard state is only trustworthy once the
|
||||
// screen is on.
|
||||
func wakeAndGuard(r *adb.Runner) error {
|
||||
if err := ui.Wake(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return ui.RequireUnlocked(r)
|
||||
}
|
||||
|
||||
func cmdConnect(r *adb.Runner, args []string) error {
|
||||
if len(args) > 0 {
|
||||
msg, err := r.Connect(args[0])
|
||||
@@ -156,6 +193,30 @@ func cmdWake(r *adb.Runner) error {
|
||||
return err
|
||||
}
|
||||
fmt.Println("screen awake")
|
||||
showing, secure, err := ui.KeyguardState(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if showing && secure {
|
||||
fmt.Println("phone is locked (secure keyguard); Artemis will not act until it is unlocked")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("phone is unlocked")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// cmdKeyguard reports the lock-screen state without touching it.
|
||||
func cmdKeyguard(r *adb.Runner) error {
|
||||
return ensurePhone(r, func() error {
|
||||
showing, secure, err := ui.KeyguardState(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("keyguard showing=%t secure=%t\n", showing, secure)
|
||||
if showing && secure {
|
||||
return ui.ErrLocked
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -219,7 +280,7 @@ func cmdLaunch(r *adb.Runner, args []string) error {
|
||||
return fmt.Errorf("usage: artemis launch <package>")
|
||||
}
|
||||
return ensurePhone(r, func() error {
|
||||
if err := ui.Wake(r); err != nil {
|
||||
if err := wakeAndGuard(r); err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
@@ -236,7 +297,7 @@ func cmdTap(r *adb.Runner, args []string) error {
|
||||
return fmt.Errorf("usage: artemis tap <text>")
|
||||
}
|
||||
return ensurePhone(r, func() error {
|
||||
if err := ui.Wake(r); err != nil {
|
||||
if err := wakeAndGuard(r); err != nil {
|
||||
return err
|
||||
}
|
||||
node, err := ui.TapText(r, args[0], false)
|
||||
@@ -253,7 +314,12 @@ func cmdType(r *adb.Runner, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("usage: artemis type <text>")
|
||||
}
|
||||
return ensurePhone(r, func() error { return ui.Type(r, args[0]) })
|
||||
return ensurePhone(r, func() error {
|
||||
if err := wakeAndGuard(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return ui.Type(r, args[0])
|
||||
})
|
||||
}
|
||||
|
||||
func cmdSwipe(r *adb.Runner, args []string) error {
|
||||
@@ -276,21 +342,31 @@ func cmdSwipe(r *adb.Runner, args []string) error {
|
||||
}
|
||||
ms = v
|
||||
}
|
||||
return ensurePhone(r, func() error { return ui.Swipe(r, nums[0], nums[1], nums[2], nums[3], ms) })
|
||||
return ensurePhone(r, func() error {
|
||||
if err := wakeAndGuard(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return ui.Swipe(r, nums[0], nums[1], nums[2], nums[3], ms)
|
||||
})
|
||||
}
|
||||
|
||||
func cmdScroll(r *adb.Runner, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("usage: artemis scroll <up|down>")
|
||||
}
|
||||
return ensurePhone(r, func() error { return ui.Scroll(r, args[0]) })
|
||||
return ensurePhone(r, func() error {
|
||||
if err := wakeAndGuard(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return ui.Scroll(r, args[0])
|
||||
})
|
||||
}
|
||||
|
||||
// cmdDemo is the Phase 2 Definition of Done, runnable on demand:
|
||||
// Artemis opens a named app and taps a named element on it.
|
||||
func cmdDemo(r *adb.Runner) error {
|
||||
return ensurePhone(r, func() error {
|
||||
if err := ui.Wake(r); err != nil {
|
||||
if err := wakeAndGuard(r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
122
cmd/artemis/run.go
Normal file
122
cmd/artemis/run.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
|
||||
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/recipe"
|
||||
)
|
||||
|
||||
// recipesDir is where hand-editable recipes live.
|
||||
func recipesDir() string {
|
||||
if d := os.Getenv("ARTEMIS_RECIPES"); d != "" {
|
||||
return d
|
||||
}
|
||||
return "recipes"
|
||||
}
|
||||
|
||||
// runLogPath is the JSON-lines action log (FR-10).
|
||||
func runLogPath() string {
|
||||
if p := os.Getenv("ARTEMIS_LOG"); p != "" {
|
||||
return p
|
||||
}
|
||||
return filepath.Join("logs", "runs.jsonl")
|
||||
}
|
||||
|
||||
func cmdRecipes(args []string) error {
|
||||
recipes, err := recipe.List(recipesDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range recipes {
|
||||
fmt.Printf("%-16s %s\n", r.Task, r.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdRun runs a named recipe: artemis run spotify_play query="Rolling Stones".
|
||||
func cmdRun(r *adb.Runner, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("usage: artemis run <task> [name=value ...]")
|
||||
}
|
||||
task := args[0]
|
||||
|
||||
params := map[string]string{}
|
||||
for _, kv := range args[1:] {
|
||||
name, value, ok := strings.Cut(kv, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("parameter %q must be name=value", kv)
|
||||
}
|
||||
params[strings.TrimSpace(name)] = value
|
||||
}
|
||||
|
||||
rec, err := recipe.Find(recipesDir(), task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if missing := rec.MissingParams(params); len(missing) > 0 {
|
||||
return fmt.Errorf("recipe %s needs %s", task, strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
var res *recipe.RunResult
|
||||
err = ensurePhone(r, func() error {
|
||||
res = recipe.Run(r, rec, recipe.Options{Params: params, Log: os.Stdout})
|
||||
if logErr := appendRunLog(runLogPath(), res); logErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "artemis: could not write the run log: %v\n", logErr)
|
||||
}
|
||||
if !res.OK {
|
||||
return fmt.Errorf("%s", res.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("\n%s\n", summaryLine(task, res))
|
||||
return err
|
||||
}
|
||||
|
||||
func summaryLine(task string, res *recipe.RunResult) string {
|
||||
if res == nil {
|
||||
return "task " + task + " did not run"
|
||||
}
|
||||
steps := len(res.Steps)
|
||||
if res.OK {
|
||||
return fmt.Sprintf("ok: %s ran %d steps in %s", task, steps, res.Duration.Round(time.Millisecond))
|
||||
}
|
||||
return fmt.Sprintf("failed: %s stopped at step %d in %s: %s",
|
||||
task, steps, res.Duration.Round(time.Millisecond), res.Error)
|
||||
}
|
||||
|
||||
// appendRunLog adds one JSON line per run: task, params, steps, result and
|
||||
// duration (FR-10).
|
||||
func appendRunLog(path string, res *recipe.RunResult) error {
|
||||
if res == nil {
|
||||
return nil
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
entry := struct {
|
||||
Time string `json:"time"`
|
||||
*recipe.RunResult
|
||||
}{Time: time.Now().Format(time.RFC3339), RunResult: res}
|
||||
|
||||
raw, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(append(raw, '\n'))
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user