Skip to content

Commit 337f5b5

Browse files
committed
added tasks, implemented deeplinks
1 parent a27d6d0 commit 337f5b5

File tree

6 files changed

+316
-3
lines changed

6 files changed

+316
-3
lines changed

client/main.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ type TaskConfig struct {
3232
}
3333

3434
type GoalConfig struct {
35-
Prompt string `toml:"prompt"`
36-
App string `toml:"app"` // package name to launch first
35+
Prompt string `toml:"prompt"`
36+
App string `toml:"app"` // package name to launch first
37+
Deeplink string `toml:"deeplink"` // deep link URI to open (e.g. instagram://mainfeed)
3738
}
3839

3940
type ModelConfig struct {
@@ -51,6 +52,7 @@ type Options struct {
5152
type TaskRequest struct {
5253
Goal string `json:"goal"`
5354
App string `json:"app,omitempty"`
55+
Deeplink string `json:"deeplink,omitempty"`
5456
Provider string `json:"provider,omitempty"`
5557
Model string `json:"model,omitempty"`
5658
Reasoning bool `json:"reasoning"`
@@ -91,6 +93,8 @@ func main() {
9193
apiKey := flag.String("key", "", "API key (or set env var based on provider)")
9294
taskFile := flag.String("task", "", "Task file (TOML)")
9395
appPkg := flag.String("app", "", "App package to launch first (e.g. com.whatsapp)")
96+
deeplink := flag.String("deeplink", "", "Deep link URI to open (e.g. instagram://mainfeed)")
97+
deeplinksApp := flag.String("deeplinks", "", "Discover deep links for an app package (e.g. com.instagram.android)")
9498
clearTasks := flag.Bool("clear", false, "Clear all tasks from server queue")
9599
quiet := flag.Bool("quiet", false, "Quiet mode - minimal output for scripting")
96100
showVersion := flag.Bool("version", false, "Show version and exit")
@@ -132,7 +136,50 @@ func main() {
132136
os.Exit(0)
133137
}
134138

135-
var goal, prov, mod, app string
139+
// Handle -deeplinks flag: discover deep links for an app
140+
if *deeplinksApp != "" {
141+
dlReq, _ := http.NewRequest("GET", *server+"/deeplinks?app="+*deeplinksApp, nil)
142+
if srvKey != "" {
143+
dlReq.Header.Set("X-Server-Key", srvKey)
144+
}
145+
dlResp, err := http.DefaultClient.Do(dlReq)
146+
if err != nil {
147+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
148+
os.Exit(1)
149+
}
150+
defer func() { _ = dlResp.Body.Close() }()
151+
152+
if dlResp.StatusCode != http.StatusOK {
153+
var errResp ErrorResponse
154+
bodyBytes, _ := io.ReadAll(dlResp.Body)
155+
if json.Unmarshal(bodyBytes, &errResp) == nil && errResp.Error != "" {
156+
fmt.Fprintf(os.Stderr, "Error: %s\n", errResp.Error)
157+
} else {
158+
fmt.Fprintf(os.Stderr, "Error: %s\n", string(bodyBytes))
159+
}
160+
os.Exit(1)
161+
}
162+
163+
var dlResult struct {
164+
App string `json:"app"`
165+
Deeplinks []string `json:"deeplinks"`
166+
}
167+
if err := json.NewDecoder(dlResp.Body).Decode(&dlResult); err != nil {
168+
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
169+
os.Exit(1)
170+
}
171+
172+
fmt.Printf("Deep links for %s:\n", dlResult.App)
173+
if len(dlResult.Deeplinks) == 0 {
174+
fmt.Println(" (none found)")
175+
}
176+
for _, dl := range dlResult.Deeplinks {
177+
fmt.Printf(" %s\n", dl)
178+
}
179+
os.Exit(0)
180+
}
181+
182+
var goal, prov, mod, app, dl string
136183
var reason, vis bool
137184
var steps int
138185

@@ -146,6 +193,7 @@ func main() {
146193

147194
goal = tf.Task.Goal.Prompt
148195
app = tf.Task.Goal.App
196+
dl = tf.Task.Goal.Deeplink
149197
prov = tf.Task.Model.Provider
150198
mod = tf.Task.Model.Model
151199
reason = tf.Task.Options.Reasoning
@@ -191,6 +239,9 @@ func main() {
191239
if *appPkg != "" {
192240
app = *appPkg
193241
}
242+
if *deeplink != "" {
243+
dl = *deeplink
244+
}
194245

195246
// Get API key from flag or env
196247
key := *apiKey
@@ -220,13 +271,17 @@ func main() {
220271
if app != "" {
221272
fmt.Printf("App: %s\n", app)
222273
}
274+
if dl != "" {
275+
fmt.Printf("Link: %s\n", dl)
276+
}
223277
fmt.Printf("Goal: %s\n\n", truncate(goal, 60))
224278
}
225279

226280
// Submit task (without API key in body)
227281
req := TaskRequest{
228282
Goal: goal,
229283
App: app,
284+
Deeplink: dl,
230285
Provider: prov,
231286
Model: mod,
232287
Reasoning: reason,

server/main.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"bufio"
45
"context"
56
"crypto/rand"
67
"encoding/hex"
@@ -9,8 +10,10 @@ import (
910
"log"
1011
"net/http"
1112
"os"
13+
"os/exec"
1214
"os/signal"
1315
"regexp"
16+
"sort"
1417
"strings"
1518
"syscall"
1619
"time"
@@ -104,6 +107,7 @@ func NewAPI(q *Queue) *API {
104107
a.mux.HandleFunc("/run", a.handleRun)
105108
a.mux.HandleFunc("/task/", a.handleTask)
106109
a.mux.HandleFunc("/queue", a.handleQueue)
110+
a.mux.HandleFunc("/deeplinks", a.handleDeeplinks)
107111
a.mux.HandleFunc("/health", a.handleHealth)
108112
return a
109113
}
@@ -250,6 +254,13 @@ func validateRequest(req *TaskRequest, apiKey string) error {
250254
}
251255
}
252256

257+
// Deeplink validation (if provided): must be a non-empty URI with a scheme
258+
if req.Deeplink != "" {
259+
if !strings.Contains(req.Deeplink, "://") {
260+
return fmt.Errorf("invalid deeplink (must contain ://): %s", req.Deeplink)
261+
}
262+
}
263+
253264
return nil
254265
}
255266

@@ -314,6 +325,129 @@ func (a *API) handleQueue(w http.ResponseWriter, r *http.Request) {
314325
}
315326
}
316327

328+
func (a *API) handleDeeplinks(w http.ResponseWriter, r *http.Request) {
329+
if r.Method != "GET" {
330+
writeError(w, "GET only", http.StatusMethodNotAllowed)
331+
return
332+
}
333+
334+
app := r.URL.Query().Get("app")
335+
if app == "" {
336+
writeError(w, "app query parameter is required", http.StatusBadRequest)
337+
return
338+
}
339+
340+
// Validate package name
341+
matched, _ := regexp.MatchString(`^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$`, app)
342+
if !matched {
343+
writeError(w, "invalid app package name: "+app, http.StatusBadRequest)
344+
return
345+
}
346+
347+
// Run adb shell dumpsys package
348+
cmd := exec.Command("adb", "shell", "dumpsys", "package", app)
349+
out, err := cmd.Output()
350+
if err != nil {
351+
writeError(w, "adb error: "+err.Error(), http.StatusInternalServerError)
352+
return
353+
}
354+
355+
deeplinks := parseDeeplinks(string(out))
356+
357+
w.Header().Set("Content-Type", "application/json")
358+
if err := json.NewEncoder(w).Encode(map[string]any{
359+
"app": app,
360+
"deeplinks": deeplinks,
361+
}); err != nil {
362+
log.Printf("Failed to encode deeplinks response: %v", err)
363+
}
364+
}
365+
366+
// parseDeeplinks extracts non-http/https deep link URIs from `dumpsys package` output.
367+
// It scans for intent-filter blocks, collects schemes and authorities per block,
368+
// then combines them into scheme://authority URIs.
369+
func parseDeeplinks(output string) []string {
370+
seen := make(map[string]bool)
371+
var schemes []string
372+
var authorities []string
373+
374+
scanner := bufio.NewScanner(strings.NewReader(output))
375+
inFilter := false
376+
377+
for scanner.Scan() {
378+
line := strings.TrimSpace(scanner.Text())
379+
380+
// Detect intent-filter block boundaries
381+
if strings.Contains(line, "filter") {
382+
// Emit combinations from previous block
383+
for _, s := range schemes {
384+
if len(authorities) == 0 {
385+
uri := s + "://"
386+
if !seen[uri] {
387+
seen[uri] = true
388+
}
389+
}
390+
for _, a := range authorities {
391+
uri := s + "://" + a
392+
if !seen[uri] {
393+
seen[uri] = true
394+
}
395+
}
396+
}
397+
schemes = nil
398+
authorities = nil
399+
inFilter = true
400+
continue
401+
}
402+
403+
if !inFilter {
404+
continue
405+
}
406+
407+
// Collect schemes (skip http and https)
408+
if strings.HasPrefix(line, "Scheme: \"") {
409+
scheme := strings.TrimPrefix(line, "Scheme: \"")
410+
scheme = strings.TrimSuffix(scheme, "\"")
411+
if scheme != "http" && scheme != "https" {
412+
schemes = append(schemes, scheme)
413+
}
414+
}
415+
416+
// Collect authorities: format is Authority: "host": -1 (or similar port)
417+
if strings.HasPrefix(line, "Authority: \"") {
418+
rest := strings.TrimPrefix(line, "Authority: \"")
419+
if idx := strings.Index(rest, "\""); idx >= 0 {
420+
authority := rest[:idx]
421+
authorities = append(authorities, authority)
422+
}
423+
}
424+
}
425+
426+
// Emit final block
427+
for _, s := range schemes {
428+
if len(authorities) == 0 {
429+
uri := s + "://"
430+
if !seen[uri] {
431+
seen[uri] = true
432+
}
433+
}
434+
for _, a := range authorities {
435+
uri := s + "://" + a
436+
if !seen[uri] {
437+
seen[uri] = true
438+
}
439+
}
440+
}
441+
442+
// Collect and sort
443+
var result []string
444+
for uri := range seen {
445+
result = append(result, uri)
446+
}
447+
sort.Strings(result)
448+
return result
449+
}
450+
317451
func generateRequestID() string {
318452
b := make([]byte, 8)
319453
if _, err := rand.Read(b); err != nil {

server/queue.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
type TaskRequest struct {
1717
Goal string `json:"goal"`
1818
App string `json:"app,omitempty"`
19+
Deeplink string `json:"deeplink,omitempty"`
1920
Provider string `json:"provider"`
2021
Model string `json:"model"`
2122
Reasoning bool `json:"reasoning"`
@@ -29,6 +30,7 @@ type TaskRequest struct {
2930
type TaskRequestSafe struct {
3031
Goal string `json:"goal"`
3132
App string `json:"app,omitempty"`
33+
Deeplink string `json:"deeplink,omitempty"`
3234
Provider string `json:"provider"`
3335
Model string `json:"model"`
3436
Reasoning bool `json:"reasoning"`
@@ -89,6 +91,7 @@ func (q *Queue) Submit(req TaskRequest, apiKey string) *Task {
8991
Request: TaskRequestSafe{
9092
Goal: req.Goal,
9193
App: req.App,
94+
Deeplink: req.Deeplink,
9295
Provider: req.Provider,
9396
Model: req.Model,
9497
Reasoning: req.Reasoning,
@@ -230,6 +233,7 @@ func (q *Queue) process(id string) {
230233
input, _ := json.Marshal(map[string]any{
231234
"goal": task.Request.Goal,
232235
"app": task.Request.App,
236+
"deeplink": task.Request.Deeplink,
233237
"provider": task.Request.Provider,
234238
"model": task.Request.Model,
235239
"reasoning": task.Request.Reasoning,

tasks/get-notifications.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Get all unread notifications task
2+
3+
[task]
4+
name = "get-notifications"
5+
description = "Report all unread notifications without clearing them"
6+
7+
[task.goal]
8+
app = ""
9+
prompt = """
10+
1. Pull down the notification shade from the top of the screen (swipe down from the status bar)
11+
2. Read ALL notifications currently visible in the notification shade
12+
3. For each notification, capture:
13+
- App name (the source app)
14+
- Title/header text
15+
- Body/content text
16+
- Time if visible
17+
4. If there are grouped notifications, expand them to see all individual notifications
18+
5. DO NOT clear, dismiss, or interact with any notifications - just read them
19+
6. DO NOT tap on any notification
20+
7. Close the notification shade (swipe up or tap outside)
21+
8. Return to home screen
22+
9. Report a complete list of all notifications found with their details
23+
"""
24+
25+
[task.model]
26+
provider = "Google"
27+
model = "gemini-flash-latest"
28+
29+
[task.options]
30+
reasoning = true
31+
vision = true
32+
max_steps = 15

tasks/instagram-reply.toml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Instagram notification reply task
2+
3+
[task]
4+
name = "instagram-reply"
5+
description = "Check unread Instagram notifications for comments or likes and reply to them"
6+
7+
[task.goal]
8+
app = ""
9+
prompt = """
10+
1. Pull down the notification shade by swiping down from the very top of the screen
11+
2. Look through the phone notifications for any from Instagram about:
12+
- Comments on your posts
13+
- Likes on your posts
14+
3. For each Instagram COMMENT notification:
15+
- Tap on the notification to open it directly in Instagram
16+
- Read the comment to understand context
17+
- Reply to the comment with a friendly, relevant response
18+
- Pull down the notification shade again to continue with the next notification
19+
4. For each Instagram LIKE notification:
20+
- Note who liked your post from the notification text
21+
- Do NOT tap on it, just skip to the next notification
22+
5. Skip any notifications that are NOT from Instagram, and skip Instagram notifications
23+
that are about follows, suggestions, or ads
24+
6. After processing all Instagram notifications, close the notification shade
25+
7. Report a summary of:
26+
- How many comment notifications were found
27+
- How many like notifications were found
28+
- What replies you sent and to which comments
29+
"""
30+
31+
[task.model]
32+
provider = "Google"
33+
model = "gemini-flash-latest"
34+
35+
[task.options]
36+
reasoning = true
37+
vision = true
38+
max_steps = 30

0 commit comments

Comments
 (0)