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:
2026-10-16 13:24:22 +11:00
parent 7767651c11
commit 2604fea483
15 changed files with 2192 additions and 23 deletions

View File

@@ -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
}