Initial commit: Null DRM Official

Capture, decrypt, and restream toolkit with compiled-in app modules
(RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www.
BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
404errordeveloper 2026-10-06 00:25:35 +02:00
commit 2fa8f2435f
121 changed files with 17802 additions and 0 deletions

37
apps/pkg/README.md Normal file
View file

@ -0,0 +1,37 @@
# pkg — shared Go libraries
Imported by `apps/capture`, `apps/agent`, `apps/streamd`, `apps/proxy`, `apps/cli`
and `apps/modules` as module `drmdecryption` via:
```go
replace drmdecryption => ../pkg
```
## Packages
| Package | Role |
|---------|------|
| `adb` | ADB client (shell, push/pull, UI dump, tap) |
| `app` | App plugin interface + registry (+ optional Catalog / StreamDefaults) |
| `uiflow` | Phone-UI primitives app modules drive playback with |
| `modcfg` | Load an app module's local `module.yaml` + env overrides |
| `proxy` | Push/start/stop appproxy, CA install/reinject, pull captures |
| `phonecap` | One phone MITM job (proxy → launch → autoplay → wvkey → session) |
| `wvkey` | Shells out to `apps/wvkey/wvkey.py` |
| `brightcove` | Brightcove Playback API + HLS Widevine extraction (platform, not provider) |
| `capture` | Parse on-device capture JSON + score candidate manifest URLs |
| `session` | Write `outputs/<app>/<stamp>/` |
| `mpd` | Manifest rewriter interface, registry, synthetic live MPD builder, local server |
| `repo` | Find repo root (`apps/pkg/go.mod`) |
Nothing here names a streaming provider. Provider logic lives in `apps/modules/<name>`;
provider values live in that module's gitignored `module.yaml`.
## Build / test
```bash
# Go must be on PATH (or set GOROOT)
go -C apps/pkg test ./...
```
No binary of its own — everything ships in `bin/drm`, built from `apps/cli`.

374
apps/pkg/adb/adb.go Normal file
View file

@ -0,0 +1,374 @@
package adb
import (
"bytes"
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// Client wraps the adb CLI. When Serial is set, every invocation uses -s SERIAL.
type Client struct {
Bin string
Serial string
}
func New() *Client {
bin := "adb"
if p, err := exec.LookPath("adb"); err == nil {
bin = p
}
return &Client{Bin: bin}
}
// WithSerial returns a copy that targets one device.
func (c *Client) WithSerial(serial string) *Client {
out := *c
out.Serial = strings.TrimSpace(serial)
return &out
}
func (c *Client) prefix(args []string) []string {
if c.Serial == "" {
return args
}
return append([]string{"-s", c.Serial}, args...)
}
func (c *Client) Run(args ...string) (string, string, error) {
cmd := exec.Command(c.Bin, c.prefix(args)...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return stdout.String(), stderr.String(), err
}
func (c *Client) Out(args ...string) string {
out, _, _ := c.Run(args...)
return strings.TrimSpace(out)
}
func (c *Client) EnsureDevice() error {
state := c.Out("get-state")
if state != "device" {
if c.Serial != "" {
return fmt.Errorf("adb device %s not ready (state=%q)", c.Serial, state)
}
return fmt.Errorf("adb device not ready (state=%q)", state)
}
return nil
}
// Device is one row from `adb devices -l`.
type Device struct {
Serial string
State string
Model string
Product string
USB string
}
// ListDevices returns every adb device row (any state). Uses a serial-less client.
func ListDevices(bin string) ([]Device, error) {
if bin == "" {
bin = New().Bin
}
cmd := exec.Command(bin, "devices", "-l")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("adb devices: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
var out []Device
for _, line := range strings.Split(stdout.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "List of devices") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
d := Device{Serial: fields[0], State: fields[1]}
for _, f := range fields[2:] {
if strings.HasPrefix(f, "model:") {
d.Model = strings.TrimPrefix(f, "model:")
}
if strings.HasPrefix(f, "product:") {
d.Product = strings.TrimPrefix(f, "product:")
}
if strings.HasPrefix(f, "usb:") {
d.USB = strings.TrimPrefix(f, "usb:")
}
}
out = append(out, d)
}
return out, nil
}
// PackageInstalled is true when `pm path <pkg>` returns a path. Never installs.
func (c *Client) PackageInstalled(pkg string) bool {
pkg = strings.TrimSpace(pkg)
if pkg == "" {
return false
}
out := c.Out("shell", "pm", "path", pkg)
return strings.Contains(out, "package:")
}
// ForceStop stops the package so the phone is free for the next job.
func (c *Client) ForceStop(pkg string) {
if pkg == "" {
return
}
_, _ = c.Shell("am", "force-stop", pkg)
}
// EnsureAwake wakes the screen. Call this BEFORE launching the target app.
// It must not press HOME or swipe after the app is open — dumpsys always
// contains "StatusBar", and a HOME key looks like "the app just exits".
func (c *Client) EnsureAwake() {
_, _ = c.Shell("input", "keyevent", "KEYCODE_WAKEUP")
time.Sleep(250 * time.Millisecond)
_, _ = c.Shell("wm", "dismiss-keyguard")
if currentFocusIsLockOrShade(c.Out("shell", "dumpsys", "window")) {
_, _ = c.Shell("input", "swipe", "540", "2000", "540", "600", "300")
time.Sleep(400 * time.Millisecond)
c.DismissShadeIfFocused()
}
}
func currentFocusIsLockOrShade(dumpsys string) bool {
for _, line := range strings.Split(dumpsys, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "mCurrentFocus=") {
continue
}
low := strings.ToLower(line)
return strings.Contains(low, "notificationshade") ||
strings.Contains(low, "keyguard") ||
strings.Contains(low, "lockscreen")
}
return false
}
// DismissShadeIfFocused presses BACK only when the notification shade has focus.
func (c *Client) DismissShadeIfFocused() {
if currentFocusIsLockOrShade(c.Out("shell", "dumpsys", "window")) {
_, _ = c.Shell("input", "keyevent", "KEYCODE_BACK")
time.Sleep(200 * time.Millisecond)
}
}
func (c *Client) Shell(args ...string) (string, error) {
all := append([]string{"shell"}, args...)
out, errOut, err := c.Run(all...)
if err != nil {
if strings.TrimSpace(errOut) != "" {
return out, fmt.Errorf("%w: %s", err, strings.TrimSpace(errOut))
}
return out, err
}
return out, nil
}
func (c *Client) Push(local, remote string) error {
_, errOut, err := c.Run("push", local, remote)
if err != nil {
return fmt.Errorf("adb push: %w (%s)", err, strings.TrimSpace(errOut))
}
return nil
}
func (c *Client) Pull(remote, local string) error {
_, errOut, err := c.Run("pull", remote, local)
if err != nil {
return fmt.Errorf("adb pull: %w (%s)", err, strings.TrimSpace(errOut))
}
return nil
}
func (c *Client) Tap(x, y int) error {
_, err := c.Shell("input", "tap", strconv.Itoa(x), strconv.Itoa(y))
return err
}
// Swipe performs an input swipe over durationMs milliseconds.
func (c *Client) Swipe(x1, y1, x2, y2, durationMs int) error {
if durationMs <= 0 {
durationMs = 300
}
_, err := c.Shell("input", "swipe",
strconv.Itoa(x1), strconv.Itoa(y1),
strconv.Itoa(x2), strconv.Itoa(y2),
strconv.Itoa(durationMs))
return err
}
// Node is one uiautomator element.
type Node struct {
Text string
Desc string
Class string
ResourceID string
Clickable bool
X1, Y1 int
X2, Y2 int
}
func (n Node) CX() int { return (n.X1 + n.X2) / 2 }
func (n Node) CY() int { return (n.Y1 + n.Y2) / 2 }
func (n Node) Area() int {
return (n.X2 - n.X1) * (n.Y2 - n.Y1)
}
var (
reNode = regexp.MustCompile(`<node [^>]+>`)
reAttr = func(k string) *regexp.Regexp { return regexp.MustCompile(k + `="([^"]*)"`) }
reBounds = regexp.MustCompile(`\[(\d+),(\d+)\]\[(\d+),(\d+)\]`)
)
func parseNodes(xml string) []Node {
var out []Node
for _, m := range reNode.FindAllString(xml, -1) {
attr := func(k string) string {
mm := reAttr(k).FindStringSubmatch(m)
if len(mm) < 2 {
return ""
}
return mm[1]
}
b := attr("bounds")
bm := reBounds.FindStringSubmatch(b)
if len(bm) != 5 {
continue
}
x1, _ := strconv.Atoi(bm[1])
y1, _ := strconv.Atoi(bm[2])
x2, _ := strconv.Atoi(bm[3])
y2, _ := strconv.Atoi(bm[4])
out = append(out, Node{
Text: attr("text"),
Desc: attr("content-desc"),
Class: attr("class"),
ResourceID: attr("resource-id"),
Clickable: attr("clickable") == "true",
X1: x1, Y1: y1, X2: x2, Y2: y2,
})
}
return out
}
// DumpUI pulls a uiautomator hierarchy.
func (c *Client) DumpUI(cacheDir string) ([]Node, error) {
remote := "/sdcard/streamd_uidump.xml"
if _, err := c.Shell("uiautomator", "dump", remote); err != nil {
// dump often returns exit 0 with message; ignore soft failures
}
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return nil, err
}
local := cacheDir + string(os.PathSeparator) + "uidump.xml"
if err := c.Pull(remote, local); err != nil {
return nil, err
}
raw, err := os.ReadFile(local)
if err != nil {
return nil, err
}
return parseNodes(string(raw)), nil
}
// FindLargest returns the largest node matching any text/desc regex.
func FindLargest(nodes []Node, textPats, descPats []*regexp.Regexp) *Node {
var hits []Node
for _, n := range nodes {
ok := false
if n.Text != "" {
for _, p := range textPats {
if p.MatchString(n.Text) {
ok = true
break
}
}
}
if n.Desc != "" {
for _, p := range descPats {
if p.MatchString(n.Desc) {
ok = true
break
}
}
}
if ok {
hits = append(hits, n)
}
}
if len(hits) == 0 {
return nil
}
best := hits[0]
for _, h := range hits[1:] {
if h.Area() > best.Area() {
best = h
}
}
return &best
}
// FindSmallest prefers tiny chips over hero buttons.
func FindSmallest(nodes []Node, descPats []*regexp.Regexp) *Node {
var hits []Node
for _, n := range nodes {
if n.Desc == "" {
continue
}
for _, p := range descPats {
if p.MatchString(n.Desc) {
hits = append(hits, n)
break
}
}
}
if len(hits) == 0 {
return nil
}
best := hits[0]
for _, h := range hits[1:] {
if h.Area() < best.Area() {
best = h
}
}
return &best
}
// WaitFor polls DumpUI until a match appears or timeout.
func (c *Client) WaitFor(cacheDir string, textPats, descPats []*regexp.Regexp, timeout time.Duration) (*Node, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
nodes, err := c.DumpUI(cacheDir)
if err == nil {
if n := FindLargest(nodes, textPats, descPats); n != nil {
return n, nil
}
}
time.Sleep(800 * time.Millisecond)
}
return nil, fmt.Errorf("ui node not found within %s", timeout)
}
// PlaybackActive is true when the package media session is PLAYING.
func (c *Client) PlaybackActive(pkg string) bool {
out := c.Out("shell", "dumpsys", "media_session")
if !strings.Contains(out, pkg) {
return false
}
return strings.Contains(out, "state=PLAYING") || strings.Contains(out, "PLAYING(")
}

154
apps/pkg/app/app.go Normal file
View file

@ -0,0 +1,154 @@
package app
import (
"flag"
"fmt"
"sort"
"sync"
"drmdecryption/adb"
"drmdecryption/capture"
"drmdecryption/mpd"
"drmdecryption/session"
)
// Channel is a named live target inside an app (e.g. rteone).
type Channel struct {
ID string
Label string
}
// App is a per-streaming-app plugin, compiled in from apps/modules/<name>. The
// capture core stays generic: an app supplies its package name, UI automation,
// capture hints, key mode and optional MPD rewrite.
type App interface {
Name() string
Package() string
LicenseURL() string
Launch(c *adb.Client) error
AutoPlay(c *adb.Client, channel string) error
CaptureHints() capture.Hints
MPDRewriter() mpd.Rewriter
Channels() []Channel
HasChannel(id string) bool
// ProxyBin is the on-device MITM binary to push.
ProxyBin() string
// CAHash is the MITM CA subject hash to reinject.
CAHash() string
// KeyMode selects the wvkey mode: "modulardrm" or "raw".
KeyMode() string
}
// Catalog is implemented by apps whose channels can be resolved without a phone
// (a public playback catalog). Drives the `catalog` subcommand.
type Catalog interface {
// Resolve returns streamd-ready credentials for a channel id.
Resolve(channel string) (session.Stream, error)
// EnrichWithKeys fills PSSH + KID:KEY using the local CDM.
EnrichWithKeys(info *session.Stream, python, wvd string) error
}
// CatalogRow is one channel in a catalog listing.
type CatalogRow struct {
ID string
CatalogID string
Label string
}
// CatalogLister is the optional richer listing a catalog app may provide: each
// channel's resolved catalog id plus whatever feature flags the provider
// publishes alongside it.
type CatalogLister interface {
ListChannels() (rows []CatalogRow, flags map[string]bool, err error)
}
// StreamDefaults is implemented by apps that need particular downloader settings.
// It replaces name-sniffing in the media supervisor.
type StreamDefaults interface {
// VideoSelect / AudioSelect are N_m3u8DL-RE selector expressions.
VideoSelect() string
AudioSelect() string
// RewriterName is the registered mpd rewriter to run, or "none".
RewriterName() string
// LiveWaitSeconds is the downloader's live refresh wait.
LiveWaitSeconds() int
// TSReadyBytes is how much muxed output to buffer before probing.
TSReadyBytes() int64
}
// TransparentMITM is implemented by apps that must use iptables REDIRECT capture
// (no Wi‑Fi http_proxy) so a phone VPN (e.g. NordVPN UK for BBC) can stay on.
type TransparentMITM interface {
UseTransparentMITM() bool
}
var (
mu sync.RWMutex
registry = map[string]func() (App, error){}
)
// Register adds a constructor for --app <name>. Called from each module's init().
func Register(name string, ctor func() (App, error)) {
mu.Lock()
defer mu.Unlock()
registry[name] = ctor
}
// Get constructs a registered app by name.
func Get(name string) (App, error) {
mu.RLock()
ctor, ok := registry[name]
mu.RUnlock()
if !ok {
return nil, fmt.Errorf("unknown app %q — compiled-in apps: %v", name, Names())
}
return ctor()
}
// Names lists registered app ids, sorted.
func Names() []string {
mu.RLock()
defer mu.RUnlock()
out := make([]string, 0, len(registry))
for k := range registry {
out = append(out, k)
}
sort.Strings(out)
return out
}
// All constructs every registered app, skipping those that fail to build.
func All() []App {
out := []App{}
for _, n := range Names() {
a, err := Get(n)
if err != nil {
continue
}
out = append(out, a)
}
return out
}
// flagHooks are registered from each module's init() so overrides can be bound
// to the module's config holder BEFORE any app is constructed.
var flagHooks []func(*flag.FlagSet)
// RegisterFlags records a module's flag binder. Called from init().
func RegisterFlags(bind func(*flag.FlagSet)) {
mu.Lock()
defer mu.Unlock()
flagHooks = append(flagHooks, bind)
}
// BindFlags lets every compiled-in module register its value overrides on fs.
// Call this before fs.Parse, and construct apps only afterwards.
func BindFlags(fs *flag.FlagSet) {
mu.RLock()
hooks := append([]func(*flag.FlagSet){}, flagHooks...)
mu.RUnlock()
for _, h := range hooks {
h(fs)
}
}

View file

@ -0,0 +1,161 @@
// Package brightcove talks to the Brightcove Playback API and reads Widevine
// material out of an HLS master. Brightcove is a platform, not a provider: this
// package holds no account, policy key, video id or channel knowledge.
package brightcove
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
// EdgeAPI is the Playback API base. Overridable for tests or a proxy.
var EdgeAPI = "https://edge.api.brightcove.com/playback/v1"
var (
// SESSION-KEY / KEY lines put URI and KEYFORMAT in either order.
reWidevinePSSH = regexp.MustCompile(`(?is)KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed".*?URI="data:text/plain;base64,([A-Za-z0-9+/=]+)"`)
reWidevinePSSH2 = regexp.MustCompile(`(?is)URI="data:text/plain;base64,([A-Za-z0-9+/=]+)".*?KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"`)
reKeyID = regexp.MustCompile(`(?i)keyId=([0-9a-f]{32})`)
)
// WidevineKeySystem is the key_systems map key for Widevine sources.
const WidevineKeySystem = "com.widevine.alpha"
// Video is the subset of a Playback API response we use.
type Video struct {
ID string `json:"id"`
Name string `json:"name"`
Sources []struct {
Type string `json:"type"`
Src string `json:"src"`
KeySystems map[string]struct {
LicenseURL string `json:"license_url"`
} `json:"key_systems"`
} `json:"sources"`
}
// PlaybackURL is the canonical (token-free) Playback API URL for a video.
func PlaybackURL(accountID, videoID string) string {
return fmt.Sprintf("%s/accounts/%s/videos/%s", EdgeAPI, accountID, videoID)
}
// FetchPlayback resolves a video through the Playback API. livePlaybackToken may
// be empty for assets that do not need one.
func FetchPlayback(accountID, videoID, livePlaybackToken, policyKey string) (Video, error) {
var v Video
if policyKey == "" {
return v, fmt.Errorf("policy key required")
}
if accountID == "" {
return v, fmt.Errorf("account id required")
}
u := PlaybackURL(accountID, videoID)
if livePlaybackToken != "" {
u += "?livePlaybackToken=" + livePlaybackToken
}
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return v, err
}
req.Header.Set("Accept", "application/json;pk="+policyKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return v, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return v, fmt.Errorf("brightcove playback HTTP %d: %s", resp.StatusCode, Trim(string(body), 200))
}
if err := json.Unmarshal(body, &v); err != nil {
return v, err
}
return v, nil
}
// PickHLSAndLicense chooses the best HLS source: prefer a DVR playlist that
// carries a Widevine license URL, then any Widevine source, then any DVR
// playlist, then any HLS at all.
func PickHLSAndLicense(v Video) (hls, license string) {
var dvr, dvrWV, anyWV, anyHLS string
var dvrLic, anyLic string
for _, s := range v.Sources {
if !strings.Contains(strings.ToLower(s.Type), "mpegurl") && !strings.Contains(strings.ToLower(s.Src), ".m3u8") {
continue
}
src := s.Src
lic := ""
if ks, ok := s.KeySystems[WidevineKeySystem]; ok {
lic = ks.LicenseURL
}
isDVR := strings.Contains(src, "playlist-hls-dvr.m3u8") || strings.Contains(src, "-dvr")
switch {
case isDVR && lic != "" && dvrWV == "":
dvrWV, dvrLic = src, lic
case isDVR && dvr == "":
dvr = src
case lic != "" && anyWV == "":
anyWV, anyLic = src, lic
case anyHLS == "":
anyHLS = src
}
}
switch {
case dvrWV != "":
return dvrWV, dvrLic
case anyWV != "":
return anyWV, anyLic
case dvr != "":
return dvr, ""
default:
return anyHLS, ""
}
}
// ExtractWidevinePSSH returns the base64 Widevine init data from an HLS master.
func ExtractWidevinePSSH(master string) string {
if m := reWidevinePSSH.FindStringSubmatch(master); len(m) == 2 {
return m[1]
}
if m := reWidevinePSSH2.FindStringSubmatch(master); len(m) == 2 {
return m[1]
}
return ""
}
// PrimaryKID returns the first keyId= seen in an HLS master, lowercased.
func PrimaryKID(master string) string {
if kids := reKeyID.FindAllStringSubmatch(master, -1); len(kids) > 0 {
return strings.ToLower(kids[0][1])
}
return ""
}
// Get fetches a URL as text (used for the HLS master).
func Get(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(b), nil
}
// Trim shortens a string for error messages.
func Trim(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

284
apps/pkg/capture/capture.go Normal file
View file

@ -0,0 +1,284 @@
package capture
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"drmdecryption/adb"
)
// Hints tell the waiter which fields / log tags mean a capture is complete.
type Hints struct {
RemoteCaps []string // paths on device to pull
RemoteLog string
Require []string // json keys: auth, pid, pssh, mpd
// BestEffort: return early when any useful field appears; at timeout return
// whatever was collected (error only if completely empty).
BestEffort bool
MPDLogRE *regexp.Regexp
// Score ranks candidate manifest URLs. Zero value falls back to
// DefaultScoreCfg, which has no provider hosts.
Score ScoreCfg
}
// RemoteCapPaths are the on-device locations the MITM may persist its capture
// JSON to. The rte_cap.json entries are legacy names still written by proxies
// already deployed on phones in the field.
var RemoteCapPaths = []string{
"/data/local/tmp/appproxy_cap.json",
"/data/local/tmp/rte_cap.json",
"/sdcard/Download/rte_cap.json",
"/storage/emulated/0/Download/rte_cap.json",
}
// RemoteLogPath is where the on-device MITM writes its log.
const RemoteLogPath = "/data/local/tmp/appproxy.log"
// DefaultHints is the device-layout baseline every app starts from. Callers set
// Require (and optionally Score) for their own DRM shape.
func DefaultHints() Hints {
return Hints{
RemoteCaps: append([]string{}, RemoteCapPaths...),
RemoteLog: RemoteLogPath,
MPDLogRE: regexp.MustCompile(`\[MPD\] (https://\S+)`),
Score: DefaultScoreCfg(),
}
}
// DefaultPassiveHints is for a bare capture run: dump whatever the MITM sees.
func DefaultPassiveHints() Hints {
h := DefaultHints()
h.BestEffort = true
return h
}
// score applies the hints' URL scoring, defaulting when unset.
func (h Hints) score(u string) int {
if len(h.Score.Deny) == 0 && len(h.Score.Hosts) == 0 {
return DefaultScoreCfg().Score(u)
}
return h.Score.Score(u)
}
// Data is the merged capture payload before key fetch.
type Data map[string]string
func (d Data) Get(k string) string { return d[k] }
// Wait pulls device JSON + local mirrored log until required fields exist.
func Wait(c *adb.Client, hints Hints, localLog string, localCap string, timeout time.Duration) (Data, error) {
deadline := time.Now().Add(timeout)
lastNote := ""
for time.Now().Before(deadline) {
cap := Data{}
for _, remote := range hints.RemoteCaps {
_ = os.Remove(localCap)
if err := c.Pull(remote, localCap); err != nil {
continue
}
raw, err := os.ReadFile(localCap)
if err != nil {
continue
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
continue
}
for k, v := range m {
if s, ok := v.(string); ok && s != "" {
cap[k] = s
}
}
break
}
logText := ""
if b, err := os.ReadFile(localLog); err == nil {
logText = string(b)
}
enrichFromProxyLog(cap, logText, hints)
if hints.BestEffort {
if hasUseful(cap, hints) {
_ = os.WriteFile(localCap, mustJSON(cap), 0o644)
return cap, nil
}
} else {
missing := false
for _, k := range hints.Require {
if strings.TrimSpace(cap[k]) == "" {
missing = true
break
}
}
if !missing {
_ = os.WriteFile(localCap, mustJSON(cap), 0o644)
return cap, nil
}
}
have := []string{}
keys := hints.Require
if hints.BestEffort {
keys = []string{"mpd", "license_url", "pssh", "auth", "pid"}
}
for _, k := range keys {
if cap[k] != "" {
have = append(have, k)
}
}
note := "json=" + strings.Join(have, ",")
if note == "json=" {
note = "json=none"
}
if strings.Contains(logText, "[LIC]") {
note += " log=LIC"
}
if strings.Contains(logText, "[PSSH]") {
note += " log=PSSH"
}
if strings.Contains(logText, "[MPD]") || strings.Contains(logText, "[MAN]") || strings.Contains(logText, "[MEDIA]") || strings.Contains(logText, "[MS]") {
note += " log=MAN"
}
if note != lastNote {
fmt.Println(" …" + note)
lastNote = note
}
time.Sleep(time.Second)
}
if hints.BestEffort {
// Final pull after timeout — return whatever we got.
cap := Data{}
for _, remote := range hints.RemoteCaps {
_ = os.Remove(localCap)
if err := c.Pull(remote, localCap); err != nil {
continue
}
raw, err := os.ReadFile(localCap)
if err != nil {
continue
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
continue
}
for k, v := range m {
if s, ok := v.(string); ok && s != "" {
cap[k] = s
}
}
break
}
logText := ""
if b, err := os.ReadFile(localLog); err == nil {
logText = string(b)
}
enrichFromProxyLog(cap, logText, hints)
if len(cap) > 0 {
_ = os.WriteFile(localCap, mustJSON(cap), 0o644)
return cap, nil
}
return nil, fmt.Errorf("timed out — no DRM/manifest traffic seen (play something on the phone)")
}
return nil, fmt.Errorf("timed out waiting for capture (%v)", hints.Require)
}
func hasUseful(cap Data, hints Hints) bool {
// Real stream signal only — ignore catalog/EPG URLs parked in "mpd".
if mpd := strings.TrimSpace(cap["mpd"]); mpd != "" && hints.score(mpd) > 0 {
return true
}
for _, k := range []string{"license_url", "pssh", "auth", "pid"} {
if strings.TrimSpace(cap[k]) != "" {
return true
}
}
return false
}
var (
reLogLIC = regexp.MustCompile(`\[LIC\]\s+(?:POST|GET)\s+(https://\S+)`)
reLogMPD = regexp.MustCompile(`\[MPD\]\s+(https://\S+)`)
reLogMAN = regexp.MustCompile(`\[MAN\]\s+GET\s+(https://\S+)`)
reLogMEDIA = regexp.MustCompile(`\[MEDIA\]\s+(https://\S+)`)
reLogPSSH = regexp.MustCompile(`\[PSSH\]\s+(\S+)`)
)
func enrichFromProxyLog(cap Data, logText string, hints Hints) {
if logText == "" {
return
}
bestMPD := ""
bestScore := 0
consider := func(u string) {
u = strings.TrimRight(u, ".,)")
if u == "" || !strings.HasPrefix(u, "http") {
return
}
sc := hints.score(u)
// Ignore EPG/schedule/metrics noise (score <= 0).
if sc <= 0 {
return
}
if sc > bestScore {
bestScore = sc
bestMPD = u
}
}
if hints.MPDLogRE != nil {
for _, m := range hints.MPDLogRE.FindAllStringSubmatch(logText, -1) {
consider(m[1])
}
}
for _, m := range reLogMPD.FindAllStringSubmatch(logText, -1) {
consider(m[1])
}
for _, m := range reLogMAN.FindAllStringSubmatch(logText, -1) {
consider(m[1])
}
for _, m := range reLogMEDIA.FindAllStringSubmatch(logText, -1) {
consider(m[1])
}
if bestMPD != "" && (cap["mpd"] == "" || hints.score(bestMPD) > hints.score(cap["mpd"])) {
cap["mpd"] = bestMPD
}
// Drop a previously stored non-manifest "mpd" (e.g. schedules feed).
if cap["mpd"] != "" && hints.score(cap["mpd"]) <= 0 {
delete(cap, "mpd")
}
if cap["license_url"] == "" {
if ms := reLogLIC.FindAllStringSubmatch(logText, -1); len(ms) > 0 {
cap["license_url"] = strings.TrimRight(ms[len(ms)-1][1], ".,)")
}
}
if cap["pssh"] == "" {
if ms := reLogPSSH.FindAllStringSubmatch(logText, -1); len(ms) > 0 {
cap["pssh"] = ms[len(ms)-1][1]
}
}
}
func mustJSON(d Data) []byte {
b, _ := json.MarshalIndent(map[string]string(d), "", " ")
return append(b, '\n')
}
// MirrorLog starts a background `adb exec-out log` equivalent via continuous pull.
// For v1 we periodically pull the remote log file into localLog.
func MirrorLogLoop(c *adb.Client, remote, local string, stop <-chan struct{}) {
_ = os.MkdirAll(filepath.Dir(local), 0o755)
for {
select {
case <-stop:
return
default:
_ = c.Pull(remote, local)
time.Sleep(800 * time.Millisecond)
}
}
}

100
apps/pkg/capture/score.go Normal file
View file

@ -0,0 +1,100 @@
package capture
import "strings"
// ScoreCfg ranks candidate URLs seen by the MITM so a schedule/EPG/analytics URL
// is never mistaken for a stream manifest. Providers supply their own hosts and
// noise needles; this package ships only format-level scoring.
type ScoreCfg struct {
// Deny: any match scores the URL out entirely.
Deny []string `yaml:"deny"`
// Hosts: known-good manifest hosts for this provider.
Hosts []string `yaml:"hosts"`
// HostBonus is added when a Hosts entry matches (default 50).
HostBonus int `yaml:"host_bonus"`
}
// DefaultScoreCfg is the provider-neutral baseline: catalog/analytics shapes that
// are never a manifest for anyone.
func DefaultScoreCfg() ScoreCfg {
return ScoreCfg{
Deny: []string{
"schedules", "bylistingtime", "maxlistings", "bycallsign",
"/feed.", "playback_config", "config.json",
},
HostBonus: 50,
}
}
// Merge overlays a provider's hosts and extra deny needles on the baseline.
func (s ScoreCfg) Merge(other ScoreCfg) ScoreCfg {
out := s
out.Deny = append(append([]string{}, s.Deny...), other.Deny...)
out.Hosts = append(append([]string{}, s.Hosts...), other.Hosts...)
if other.HostBonus != 0 {
out.HostBonus = other.HostBonus
}
if out.HostBonus == 0 {
out.HostBonus = 50
}
return out
}
// Score rates a URL as a live manifest. Zero or negative means "not a manifest".
func (s ScoreCfg) Score(u string) int {
lu := strings.ToLower(u)
path := lu
if i := strings.Index(path, "?"); i >= 0 {
path = path[:i]
}
for _, bad := range s.Deny {
if bad != "" && strings.Contains(lu, strings.ToLower(bad)) {
return -100
}
}
score := 0
bonus := s.HostBonus
if bonus == 0 {
bonus = 50
}
for _, host := range s.Hosts {
if host != "" && strings.Contains(lu, strings.ToLower(host)) {
score += bonus
break
}
}
// Format-level signals — true for any provider.
if strings.Contains(path, "playlist-hls") || strings.Contains(path, "playlist.m3u8") {
score += 40
}
if strings.Contains(path, "chunklist") {
score += 10
}
if strings.HasSuffix(path, ".m3u8") {
score += 5
}
if strings.HasSuffix(path, ".mpd") || strings.Contains(path, "manifest.mpd") {
score += 30
}
if strings.Contains(path, ".isml") || strings.Contains(path, "/manifest") {
score += 20
}
return score
}
// ProxyArgs renders this config as flags for the on-device MITM, which runs on
// the phone and cannot read an app module's values file itself.
func (s ScoreCfg) ProxyArgs() []string {
out := []string{}
for _, h := range s.Hosts {
if h != "" {
out = append(out, "-score-host", h)
}
}
for _, d := range s.Deny {
if d != "" {
out = append(out, "-score-deny", d)
}
}
return out
}

View file

@ -0,0 +1,72 @@
package capture
import "testing"
// provider-shaped config, supplied the way a module would.
func testScoreCfg() ScoreCfg {
return DefaultScoreCfg().Merge(ScoreCfg{
Deny: []string{"feed.entertainment", "metrics.example", "noise.example"},
Hosts: []string{"live.example.com", "fastly.live.example.com"},
})
}
func TestScoreCfgScore(t *testing.T) {
cfg := testScoreCfg()
cases := []struct {
url string
want string // "pos" or "neg"
}{
{"https://feed.entertainment.tv.example.eu/f/1uC-gC/prd-all-schedules?byListingTime=1~2", "neg"},
{"https://metrics.example.com/v2/tracker?foo=.m3u8", "neg"},
{"https://cdn.example.com/smarttv/playback_config.json", "neg"},
{"https://live.example.com/live/foo/manifest.mpd", "pos"},
{"https://fastly.live.example.com/x/playlist-hls.m3u8", "pos"},
{"https://unknown-host.test/video/master.m3u8", "pos"},
{"https://unknown-host.test/live/vc11.isml/Manifest", "pos"},
}
for _, tc := range cases {
sc := cfg.Score(tc.url)
if tc.want == "pos" && sc <= 0 {
t.Errorf("score(%q)=%d, want > 0", tc.url, sc)
}
if tc.want == "neg" && sc > 0 {
t.Errorf("score(%q)=%d, want <= 0", tc.url, sc)
}
}
}
// A known provider host must outrank a bare manifest on an unknown host, so the
// best candidate in a noisy MITM log is the provider's own origin.
func TestScoreCfgPrefersKnownHost(t *testing.T) {
cfg := testScoreCfg()
known := cfg.Score("https://live.example.com/live/foo/manifest.mpd")
unknown := cfg.Score("https://somewhere.test/live/foo/manifest.mpd")
if known <= unknown {
t.Fatalf("known host %d should outrank unknown host %d", known, unknown)
}
}
func TestDefaultScoreCfgNeedsNoProviderHosts(t *testing.T) {
cfg := DefaultScoreCfg()
if cfg.Score("https://anything.test/playlist.m3u8") <= 0 {
t.Fatal("format-level scoring must work without provider hosts")
}
if cfg.Score("https://anything.test/api/schedules?x=1") > 0 {
t.Fatal("schedule feeds must score out with the baseline config")
}
}
func TestHasUsefulIgnoresSchedulesMPD(t *testing.T) {
hints := DefaultPassiveHints()
hints.Score = testScoreCfg()
cap := Data{
"mpd": "https://feed.entertainment.tv.example.eu/f/1uC-gC/prd-all-schedules?byListingTime=1~2",
}
if hasUseful(cap, hints) {
t.Fatal("schedules feed must not count as useful capture")
}
cap["license_url"] = "https://widevine.example/license"
if !hasUseful(cap, hints) {
t.Fatal("license_url should count as useful")
}
}

5
apps/pkg/go.mod Normal file
View file

@ -0,0 +1,5 @@
module drmdecryption
go 1.25.0
require gopkg.in/yaml.v3 v3.0.1

4
apps/pkg/go.sum Normal file
View file

@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

90
apps/pkg/modcfg/modcfg.go Normal file
View file

@ -0,0 +1,90 @@
// Package modcfg loads an app module's values from its local, untracked
// apps/modules/<name>/module.yaml, with environment-variable overrides.
//
// Compiled-in module code is tracked in git and must contain no account ids,
// policy keys, video ids, license URLs or origin hostnames. Those live here, in
// a file git ignores, and can also be supplied by flag or environment so a run
// needs no file at all.
package modcfg
import (
"fmt"
"os"
"path/filepath"
"strings"
"drmdecryption/repo"
"gopkg.in/yaml.v3"
)
// Dir returns the directory holding a module's local values.
func Dir(name string) string {
return filepath.Join(repo.Root(), "apps", "modules", name)
}
// Path returns the module's values file path.
func Path(name string) string {
return filepath.Join(Dir(name), "module.yaml")
}
// Result reports where a module's values came from, for `drm modules`.
type Result struct {
Path string
Loaded bool
}
// Load reads apps/modules/<name>/module.yaml into dst. A missing file is not an
// error — the module runs on its Go defaults plus flags/env.
func Load(name string, dst any) (Result, error) {
path := Path(name)
res := Result{Path: path}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return res, nil
}
return res, err
}
if err := yaml.Unmarshal(b, dst); err != nil {
return res, fmt.Errorf("%s: %w", path, err)
}
res.Loaded = true
return res, nil
}
// EnvKey is the environment variable a field override is read from:
// TG4_POLICY_KEY for module "tg4", field "policy_key".
func EnvKey(module, field string) string {
clean := func(s string) string {
s = strings.ToUpper(s)
s = strings.ReplaceAll(s, "-", "_")
s = strings.ReplaceAll(s, ".", "_")
return s
}
return clean(module) + "_" + clean(field)
}
// Env returns the environment override for a field, or "".
func Env(module, field string) string {
return strings.TrimSpace(os.Getenv(EnvKey(module, field)))
}
// Override picks the first non-empty of: flag value, environment, current value.
// Use it in a module's config resolution so precedence is uniform.
func Override(module, field, flagVal, current string) string {
if strings.TrimSpace(flagVal) != "" {
return flagVal
}
if v := Env(module, field); v != "" {
return v
}
return current
}
// Resolve makes a module-relative path absolute against the repo root.
func Resolve(p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(repo.Root(), p)
}

50
apps/pkg/mpd/kid.go Normal file
View file

@ -0,0 +1,50 @@
package mpd
import (
"encoding/xml"
"fmt"
"regexp"
"strings"
)
var reDefaultKID = regexp.MustCompile(`(?i)default_KID="([^"]+)"`)
// KIDToUUID normalizes any hex-ish KID spelling to dashed UUID form.
func KIDToUUID(kid string) (string, error) {
var b strings.Builder
for _, r := range kid {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
b.WriteRune(r)
}
}
h := strings.ToLower(b.String())
if len(h) != 32 {
return "", fmt.Errorf("invalid KID %q", kid)
}
return fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32]), nil
}
// KIDHex strips dashes and lowercases a KID (the form used as a map key).
func KIDHex(kid string) string {
return strings.ToLower(strings.ReplaceAll(kid, "-", ""))
}
// ExtractDefaultKID pulls cenc:default_KID out of an MPD, as dashed UUID.
func ExtractDefaultKID(xmlText string) string {
m := reDefaultKID.FindStringSubmatch(xmlText)
if len(m) < 2 {
return ""
}
u, err := KIDToUUID(m[1])
if err != nil {
return ""
}
return u
}
// XMLEscape escapes text for inclusion in an XML element body.
func XMLEscape(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}

15
apps/pkg/mpd/mpd.go Normal file
View file

@ -0,0 +1,15 @@
package mpd
// Rewriter transforms an upstream live MPD into something N_m3u8DL-RE can
// refresh safely. Apps that need DAI/colon stripping or synthetic timelines
// implement this; others return Passthrough.
type Rewriter interface {
Name() string
Rewrite(upstreamXML []byte, kidHint string) (out []byte, err error)
}
// Passthrough leaves the MPD unchanged.
type Passthrough struct{}
func (Passthrough) Name() string { return "none" }
func (Passthrough) Rewrite(in []byte, _ string) ([]byte, error) { return in, nil }

59
apps/pkg/mpd/registry.go Normal file
View file

@ -0,0 +1,59 @@
package mpd
import (
"sort"
"strings"
"sync"
)
var (
regMu sync.RWMutex
registry = map[string]func() Rewriter{}
)
func init() {
Register("none", func() Rewriter { return Passthrough{} })
}
// Register makes a rewriter resolvable by name, so consumers (streamd) can pick
// one from stored config without importing the provider that implements it. A
// factory is registered rather than an instance because a rewriter may carry
// per-stream fallback state.
func Register(name string, newRewriter func() Rewriter) {
regMu.Lock()
defer regMu.Unlock()
registry[strings.ToLower(name)] = newRewriter
}
// Lookup builds a fresh rewriter for a name. An empty name, "none" or an unknown
// name yields Passthrough with ok=false, so callers can tell "no rewrite needed"
// from "rewrite with X".
func Lookup(name string) (Rewriter, bool) {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" || key == "none" {
return Passthrough{}, false
}
regMu.RLock()
newRewriter, ok := registry[key]
regMu.RUnlock()
if !ok {
return Passthrough{}, false
}
rw := newRewriter()
if _, isPass := rw.(Passthrough); isPass {
return rw, false
}
return rw, true
}
// Names lists registered rewriter names.
func Names() []string {
regMu.RLock()
defer regMu.RUnlock()
out := make([]string, 0, len(registry))
for k := range registry {
out = append(out, k)
}
sort.Strings(out)
return out
}

104
apps/pkg/mpd/server.go Normal file
View file

@ -0,0 +1,104 @@
package mpd
import (
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// KIDResolver is implemented by rewriters that can recover the live channel from
// a KID alone. The local server uses it so a rewrite still works after the
// upstream (ad-stitched) manifest session has expired and returns 410.
type KIDResolver interface {
ResolveKID(kidHex string) (channel, originBase string, ok bool)
}
// Primer is implemented by rewriters that want to be told the fallback channel
// and origin discovered on earlier requests.
type Primer interface {
Prime(channel, originBase string)
}
// LocalServer serves a rewritten live MPD on 127.0.0.1 for a downloader to
// refresh. Every GET re-fetches upstream and re-runs the rewriter.
type LocalServer struct {
URL string
Upstream string
Key string // KID:KEY
Headers map[string]string
rw Rewriter
kidHint string
ln net.Listener
httpServer *http.Server
}
// StartLocal starts the rewrite proxy. rw must not be nil; pass Passthrough{}
// to serve upstream unchanged.
func StartLocal(upstream, key string, headers map[string]string, rw Rewriter) (*LocalServer, error) {
if rw == nil {
rw = Passthrough{}
}
s := &LocalServer{Upstream: upstream, Key: key, Headers: headers, rw: rw}
if key != "" && strings.Contains(key, ":") {
s.kidHint = strings.SplitN(key, ":", 2)[0]
if r, ok := rw.(KIDResolver); ok {
if ch, base, found := r.ResolveKID(KIDHex(s.kidHint)); found {
if p, ok := rw.(Primer); ok {
p.Prime(ch, base)
}
}
}
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
s.ln = ln
mux := http.NewServeMux()
mux.HandleFunc("/", s.handle)
s.httpServer = &http.Server{Handler: mux}
s.URL = fmt.Sprintf("http://%s/manifest.mpd", ln.Addr().String())
go func() { _ = s.httpServer.Serve(ln) }()
return s, nil
}
func (s *LocalServer) Close() {
if s.httpServer != nil {
_ = s.httpServer.Close()
}
if s.ln != nil {
_ = s.ln.Close()
}
}
func (s *LocalServer) handle(w http.ResponseWriter, r *http.Request) {
body := ""
req, err := http.NewRequest(http.MethodGet, s.Upstream, nil)
if err == nil {
for k, v := range s.Headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 20 * time.Second}
if resp, err := client.Do(req); err == nil {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
_ = resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(b) > 0 {
body = string(b)
}
}
}
// Upstream being gone is not fatal: a rewriter with a KID fallback can still
// publish the encoder timeline so the downloader keeps pulling segments.
payload, err := s.rw.Rewrite([]byte(body), s.kidHint)
if err != nil {
http.Error(w, err.Error(), 502)
return
}
w.Header().Set("Content-Type", "application/dash+xml")
w.WriteHeader(200)
_, _ = w.Write(payload)
}

142
apps/pkg/mpd/smooth.go Normal file
View file

@ -0,0 +1,142 @@
package mpd
import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
)
// DefaultMSSTimescale is the Smooth Streaming tick rate (100ns units).
const DefaultMSSTimescale = 10_000_000
var (
reSegDur = regexp.MustCompile(`\bd="(\d+)"`)
reSegT = regexp.MustCompile(`\bt="(\d+)"`)
reSegR = regexp.MustCompile(`\br="(\d+)"`)
reSegEntry = regexp.MustCompile(`<c\s+([^/]*)/>`)
)
// ScaleMSS converts a Smooth Streaming tick to another timescale without
// overflowing int64 (MSS times are ~1e16; mss*48000 does not fit).
func ScaleMSS(mss, fromScale, toScale int64) int64 {
if fromScale <= 0 {
fromScale = DefaultMSSTimescale
}
return mss/fromScale*toScale + (mss%fromScale)*toScale/fromScale
}
// Grid is the segment duration + phase of one encoder channel, per media type.
type Grid struct {
VDur, ADur int64
VMod, AMod int64
}
// GridSpec describes how to learn a Grid from an origin's Smooth manifest.
type GridSpec struct {
// ManifestPath is appended to the origin base (e.g. "Manifest").
ManifestPath string
// VideoName / AudioName are StreamIndex Name (or Type) attributes.
VideoName, AudioName string
// Timescales the DASH output uses.
VideoTimescale, AudioTimescale int64
MSSTimescale int64
// FallbackSegmentDuration in MSS ticks when the manifest has no d="".
FallbackSegmentDuration int64
}
var (
gridMu sync.Mutex
gridCache = map[string]Grid{}
)
// LearnGrid fetches the origin Smooth manifest once per channel and derives the
// segment duration and phase offset for video and audio.
func LearnGrid(channel, originBase string, spec GridSpec) (Grid, error) {
gridMu.Lock()
if g, ok := gridCache[channel]; ok {
gridMu.Unlock()
return g, nil
}
gridMu.Unlock()
if !strings.HasSuffix(originBase, "/") {
originBase += "/"
}
url := originBase + spec.ManifestPath
resp, err := http.Get(url)
if err != nil {
return Grid{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return Grid{}, err
}
text := string(body)
video := ParseMSSStreamTimes(text, spec.VideoName)
audio := ParseMSSStreamTimes(text, spec.AudioName)
mssDur := spec.FallbackSegmentDuration
if m := reSegDur.FindStringSubmatch(text); len(m) == 2 {
if v, e := strconv.ParseInt(m[1], 10, 64); e == nil {
mssDur = v
}
}
if len(video) == 0 || len(audio) == 0 {
return Grid{}, fmt.Errorf("encoder Manifest missing streams at %s", url)
}
ms := spec.MSSTimescale
vDur := ScaleMSS(mssDur, ms, spec.VideoTimescale)
aDur := ScaleMSS(mssDur, ms, spec.AudioTimescale)
g := Grid{
VDur: vDur,
ADur: aDur,
VMod: ScaleMSS(video[len(video)-1], ms, spec.VideoTimescale) % vDur,
AMod: ScaleMSS(audio[len(audio)-1], ms, spec.AudioTimescale) % aDur,
}
gridMu.Lock()
gridCache[channel] = g
gridMu.Unlock()
return g, nil
}
// ParseMSSStreamTimes expands the <c> timeline of one StreamIndex into
// absolute MSS tick times. streamName matches Name="" or Type="".
func ParseMSSStreamTimes(manifestXML, streamName string) []int64 {
reBlock := regexp.MustCompile(`(?is)<StreamIndex\b[^>]*\bName="` + regexp.QuoteMeta(streamName) + `"[^>]*>(.*?)</StreamIndex>`)
m := reBlock.FindStringSubmatch(manifestXML)
if m == nil {
reBlock = regexp.MustCompile(`(?is)<StreamIndex\b[^>]*\bType="` + regexp.QuoteMeta(streamName) + `"[^>]*>(.*?)</StreamIndex>`)
m = reBlock.FindStringSubmatch(manifestXML)
}
if m == nil {
return nil
}
var times []int64
tCur := int64(-1)
for _, c := range reSegEntry.FindAllStringSubmatch(m[1], -1) {
attrs := c[1]
tm := reSegT.FindStringSubmatch(attrs)
dm := reSegDur.FindStringSubmatch(attrs)
rm := reSegR.FindStringSubmatch(attrs)
if tm != nil {
tCur, _ = strconv.ParseInt(tm[1], 10, 64)
}
if tCur < 0 || dm == nil {
continue
}
d, _ := strconv.ParseInt(dm[1], 10, 64)
r := int64(0)
if rm != nil {
r, _ = strconv.ParseInt(rm[1], 10, 64)
}
for i := int64(0); i <= r; i++ {
times = append(times, tCur)
tCur += d
}
}
return times
}

280
apps/pkg/mpd/synthetic.go Normal file
View file

@ -0,0 +1,280 @@
package mpd
import (
"fmt"
"strings"
"sync"
"time"
)
// SyntheticConfig describes a live encoder's DASH grid well enough to publish a
// synthetic manifest for it. Every value a provider could differ on lives here;
// nothing in this package names a provider.
type SyntheticConfig struct {
// Timescales.
MSSTimescale int64 `yaml:"mss_timescale"`
VideoTimescale int64 `yaml:"video_timescale"`
AudioTimescale int64 `yaml:"audio_timescale"`
// Smooth Streaming stream names used to learn the segment grid.
ManifestPath string `yaml:"manifest_path"`
VideoName string `yaml:"video_name"`
AudioName string `yaml:"audio_name"`
// Segment path appended to the origin base for DASH segments.
SegmentPathSuffix string `yaml:"segment_path_suffix"`
// Representation attributes.
VideoBandwidth int `yaml:"video_bandwidth"`
AudioBandwidth int `yaml:"audio_bandwidth"`
VideoCodecs string `yaml:"video_codecs"`
AudioCodecs string `yaml:"audio_codecs"`
Width int `yaml:"width"`
Height int `yaml:"height"`
AudioSamplingRate int `yaml:"audio_sampling_rate"`
AudioChannels int `yaml:"audio_channels"`
Lang string `yaml:"lang"`
// Segment naming. "{channel}" is substituted; empty means derive as
// <channel>-<stream name>=<bandwidth>[-$Time$].<ext>.
VideoInitTemplate string `yaml:"video_init_template"`
VideoMediaTemplate string `yaml:"video_media_template"`
AudioInitTemplate string `yaml:"audio_init_template"`
AudioMediaTemplate string `yaml:"audio_media_template"`
SegmentExt string `yaml:"segment_ext"`
// Timeline shape.
LiveEdgeOffset float64 `yaml:"live_edge_offset_s"`
WindowSegments int64 `yaml:"window_segments"`
FallbackSegmentDuration int64 `yaml:"fallback_segment_duration"`
// MPD-level durations, as ISO-8601 strings.
MinUpdatePeriod string `yaml:"min_update_period"`
MinBufferTime string `yaml:"min_buffer_time"`
TimeShiftBufferDepth string `yaml:"time_shift_buffer_depth"`
MaxSegmentDuration string `yaml:"max_segment_duration"`
}
// WithDefaults fills anything left zero with the common Smooth-to-DASH values.
// These are container mechanics, not provider identity, so defaulting is safe.
func (c SyntheticConfig) WithDefaults() SyntheticConfig {
if c.MSSTimescale == 0 {
c.MSSTimescale = DefaultMSSTimescale
}
if c.VideoTimescale == 0 {
c.VideoTimescale = 600
}
if c.AudioTimescale == 0 {
c.AudioTimescale = 48_000
}
if c.ManifestPath == "" {
c.ManifestPath = "Manifest"
}
if c.VideoName == "" {
c.VideoName = "video"
}
if c.AudioName == "" {
c.AudioName = "audio_128k"
}
if c.SegmentPathSuffix == "" {
c.SegmentPathSuffix = "dash/"
}
if c.VideoBandwidth == 0 {
c.VideoBandwidth = 6_000_000
}
if c.AudioBandwidth == 0 {
c.AudioBandwidth = 128_000
}
if c.VideoCodecs == "" {
c.VideoCodecs = "avc1.640028"
}
if c.AudioCodecs == "" {
c.AudioCodecs = "mp4a.40.2"
}
if c.Width == 0 {
c.Width = 1920
}
if c.Height == 0 {
c.Height = 1080
}
if c.AudioSamplingRate == 0 {
c.AudioSamplingRate = 48_000
}
if c.AudioChannels == 0 {
c.AudioChannels = 2
}
if c.Lang == "" {
c.Lang = "en"
}
if c.SegmentExt == "" {
c.SegmentExt = "dash"
}
if c.LiveEdgeOffset == 0 {
c.LiveEdgeOffset = 12
}
if c.WindowSegments == 0 {
c.WindowSegments = 6
}
if c.FallbackSegmentDuration == 0 {
c.FallbackSegmentDuration = 38_400_000
}
if c.MinUpdatePeriod == "" {
c.MinUpdatePeriod = "PT2S"
}
if c.MinBufferTime == "" {
c.MinBufferTime = "PT8S"
}
if c.TimeShiftBufferDepth == "" {
c.TimeShiftBufferDepth = "PT1M"
}
if c.MaxSegmentDuration == "" {
c.MaxSegmentDuration = "PT4S"
}
return c
}
// GridSpec is the LearnGrid view of this config, exported so a provider module can
// learn the same encoder grid the builder uses.
func (c SyntheticConfig) GridSpec() GridSpec {
return GridSpec{
ManifestPath: c.ManifestPath,
VideoName: c.VideoName,
AudioName: c.AudioName,
VideoTimescale: c.VideoTimescale,
AudioTimescale: c.AudioTimescale,
MSSTimescale: c.MSSTimescale,
FallbackSegmentDuration: c.FallbackSegmentDuration,
}
}
func (c SyntheticConfig) segName(tmpl, channel, stream string, bandwidth int, withTime bool) string {
if tmpl != "" {
return strings.ReplaceAll(tmpl, "{channel}", channel)
}
if withTime {
return fmt.Sprintf("%s-%s=%d-$Time$.%s", channel, stream, bandwidth, c.SegmentExt)
}
return fmt.Sprintf("%s-%s=%d.%s", channel, stream, bandwidth, c.SegmentExt)
}
type anchor struct {
V, A int64
}
var (
anchorMu sync.Mutex
anchors = map[string]*anchor{}
)
const syntheticTemplate = `<?xml version="1.0" encoding="utf-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:cenc="urn:mpeg:cenc:2013"
profiles="urn:mpeg:dash:profile:isoff-live:2011" type="dynamic"
availabilityStartTime="1970-01-01T00:00:00Z" publishTime="%s"
minimumUpdatePeriod="%s" minBufferTime="%s" timeShiftBufferDepth="%s"
maxSegmentDuration="%s">
<BaseURL>%s</BaseURL>
<Period id="1" start="PT0S">
<AdaptationSet id="1" contentType="video" mimeType="video/mp4" lang="%s">
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="%s"/>
<Representation id="r0" bandwidth="%d" codecs="%s" width="%d" height="%d">
<SegmentTemplate timescale="%d" initialization="%s" media="%s" startNumber="1">
<SegmentTimeline><S t="%d" d="%d" r="%d"/></SegmentTimeline>
</SegmentTemplate>
</Representation>
</AdaptationSet>
<AdaptationSet id="2" contentType="audio" mimeType="audio/mp4" lang="%s">
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="%s"/>
<Representation id="r1" bandwidth="%d" codecs="%s" audioSamplingRate="%d">
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="%d"/>
<SegmentTemplate timescale="%d" initialization="%s" media="%s" startNumber="1">
<SegmentTimeline><S t="%d" d="%d" r="%d"/></SegmentTimeline>
</SegmentTemplate>
</Representation>
</AdaptationSet>
</Period>
</MPD>
`
// BuildSynthetic publishes a dynamic MPD on the encoder's own segment grid, so a
// downloader keeps fetching live segments even once the upstream (ad-stitched)
// manifest session has expired.
func BuildSynthetic(cfg SyntheticConfig, channel, originBase, kid string) ([]byte, error) {
cfg = cfg.WithDefaults()
if !strings.HasSuffix(originBase, "/") {
originBase += "/"
}
segBase := originBase + cfg.SegmentPathSuffix
kidU, err := KIDToUUID(kid)
if err != nil {
return nil, err
}
grid, err := LearnGrid(channel, originBase, cfg.GridSpec())
if err != nil {
return nil, err
}
// Float seconds so sub-second phase stays close to the encoder.
now := float64(time.Now().UTC().UnixNano())/1e9 - cfg.LiveEdgeOffset
align := func(ts, dur, mod int64) int64 {
raw := int64(now * float64(ts))
return raw - (raw-mod)%dur
}
vt := align(cfg.VideoTimescale, grid.VDur, grid.VMod)
at := align(cfg.AudioTimescale, grid.ADur, grid.AMod)
maxCount := cfg.WindowSegments
anchorMu.Lock()
a := anchors[channel]
if a == nil {
a = &anchor{
V: vt - (maxCount-1)*grid.VDur,
A: at - (maxCount-1)*grid.ADur,
}
anchors[channel] = a
}
vStart, aStart := a.V, a.A
if vt > vStart+(maxCount-1)*grid.VDur {
vStart = vt - (maxCount-1)*grid.VDur
aStart = at - (maxCount-1)*grid.ADur
a.V, a.A = vStart, aStart
} else if vt < vStart {
vStart = vt - (maxCount-1)*grid.VDur
aStart = at - (maxCount-1)*grid.ADur
a.V, a.A = vStart, aStart
}
anchorMu.Unlock()
vCount := (vt-vStart)/grid.VDur + 1
aCount := (at-aStart)/grid.ADur + 1
if vCount < 1 {
vCount = 1
}
if aCount < 1 {
aCount = 1
}
if vCount > maxCount {
vStart = vt - (maxCount-1)*grid.VDur
aStart = at - (maxCount-1)*grid.ADur
vCount, aCount = maxCount, maxCount
anchorMu.Lock()
anchors[channel] = &anchor{V: vStart, A: aStart}
anchorMu.Unlock()
}
published := time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
vInit := cfg.segName(cfg.VideoInitTemplate, channel, cfg.VideoName, cfg.VideoBandwidth, false)
vMedia := cfg.segName(cfg.VideoMediaTemplate, channel, cfg.VideoName, cfg.VideoBandwidth, true)
aInit := cfg.segName(cfg.AudioInitTemplate, channel, cfg.AudioName, cfg.AudioBandwidth, false)
aMedia := cfg.segName(cfg.AudioMediaTemplate, channel, cfg.AudioName, cfg.AudioBandwidth, true)
out := fmt.Sprintf(syntheticTemplate,
published, cfg.MinUpdatePeriod, cfg.MinBufferTime, cfg.TimeShiftBufferDepth, cfg.MaxSegmentDuration,
XMLEscape(segBase),
cfg.Lang, kidU,
cfg.VideoBandwidth, cfg.VideoCodecs, cfg.Width, cfg.Height,
cfg.VideoTimescale, vInit, vMedia, vStart, grid.VDur, vCount-1,
cfg.Lang, kidU,
cfg.AudioBandwidth, cfg.AudioCodecs, cfg.AudioSamplingRate, cfg.AudioChannels,
cfg.AudioTimescale, aInit, aMedia, aStart, grid.ADur, aCount-1,
)
return []byte(out), nil
}

View file

@ -0,0 +1,264 @@
package mpd
import (
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
)
// A Smooth Streaming manifest shaped like a live encoder serves: 4s segments at
// the 10MHz MSS tick rate.
const smoothManifest = `<?xml version="1.0" encoding="utf-8"?>
<SmoothStreamingMedia MajorVersion="2" MinorVersion="0" TimeScale="10000000" IsLive="TRUE">
<StreamIndex Type="video" Name="video" Chunks="0" QualityLevels="1" Url="QualityLevels({bitrate})/Fragments(video={start time})">
<QualityLevel Index="0" Bitrate="6000000" FourCC="H264" MaxWidth="1920" MaxHeight="1080"/>
<c t="17000000000000" d="40000000" r="3"/>
</StreamIndex>
<StreamIndex Type="audio" Name="audio_128k" Chunks="0" QualityLevels="1" Url="QualityLevels({bitrate})/Fragments(audio_128k={start time})">
<QualityLevel Index="0" Bitrate="128000" FourCC="AACL" SamplingRate="48000"/>
<c t="17000000000000" d="40000000" r="3"/>
</StreamIndex>
</SmoothStreamingMedia>`
func originServer(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/Manifest") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(smoothManifest))
}))
t.Cleanup(srv.Close)
return srv.URL + "/live/test.isml/"
}
// resetGridCache keeps tests independent — the grid and anchor caches are global
// so a live stream learns its encoder phase only once.
func resetGridCache(channel string) {
gridMu.Lock()
delete(gridCache, channel)
gridMu.Unlock()
anchorMu.Lock()
delete(anchors, channel)
anchorMu.Unlock()
}
func TestBuildSyntheticShape(t *testing.T) {
resetGridCache("vctest")
base := originServer(t)
out, err := BuildSynthetic(SyntheticConfig{}, "vctest", base, "df163382-1ddd-fdd5-bec9-822c1ec0f052")
if err != nil {
t.Fatal(err)
}
got := string(out)
// The default grid is the one the live RTE pipeline was tuned with.
for _, want := range []string{
`type="dynamic"`,
`minimumUpdatePeriod="PT2S"`,
`minBufferTime="PT8S"`,
`timeShiftBufferDepth="PT1M"`,
`maxSegmentDuration="PT4S"`,
`cenc:default_KID="df163382-1ddd-fdd5-bec9-822c1ec0f052"`,
`timescale="600"`,
`timescale="48000"`,
`bandwidth="6000000"`,
`bandwidth="128000"`,
`codecs="avc1.640028"`,
`codecs="mp4a.40.2"`,
`width="1920" height="1080"`,
`audioSamplingRate="48000"`,
`initialization="vctest-video=6000000.dash"`,
`media="vctest-video=6000000-$Time$.dash"`,
`initialization="vctest-audio_128k=128000.dash"`,
`media="vctest-audio_128k=128000-$Time$.dash"`,
base + "dash/",
} {
if !strings.Contains(got, want) {
t.Errorf("synthetic MPD missing %q\n---\n%s", want, got)
}
}
}
// The published window must be WindowSegments long: r = count-1.
func TestBuildSyntheticWindowSize(t *testing.T) {
resetGridCache("vcwin")
base := originServer(t)
out, err := BuildSynthetic(SyntheticConfig{WindowSegments: 6}, "vcwin", base, "df1633821dddfdd5bec9822c1ec0f052")
if err != nil {
t.Fatal(err)
}
re := regexp.MustCompile(`<S t="(\d+)" d="(\d+)" r="(\d+)"/>`)
ms := re.FindAllStringSubmatch(string(out), -1)
if len(ms) != 2 {
t.Fatalf("want 2 SegmentTimelines, got %d", len(ms))
}
for i, m := range ms {
r, _ := strconv.Atoi(m[3])
if r != 5 {
t.Errorf("timeline %d: r=%d, want 5 (6 segments)", i, r)
}
d, _ := strconv.Atoi(m[2])
// 4s segments: 4*600=2400 video ticks, 4*48000=192000 audio ticks.
want := []int{2400, 192000}[i]
if d != want {
t.Errorf("timeline %d: d=%d, want %d", i, d, want)
}
}
}
// Segment times must land on the encoder's grid, i.e. share its phase offset,
// or the downloader requests segments the origin does not have.
func TestBuildSyntheticAlignsToEncoderPhase(t *testing.T) {
resetGridCache("vcphase")
base := originServer(t)
grid, err := LearnGrid("vcphase", base, SyntheticConfig{}.WithDefaults().GridSpec())
if err != nil {
t.Fatal(err)
}
out, err := BuildSynthetic(SyntheticConfig{}, "vcphase", base, "df1633821dddfdd5bec9822c1ec0f052")
if err != nil {
t.Fatal(err)
}
re := regexp.MustCompile(`<S t="(\d+)" d="(\d+)" r="\d+"/>`)
ms := re.FindAllStringSubmatch(string(out), -1)
vStart, _ := strconv.ParseInt(ms[0][1], 10, 64)
if got := ((vStart-grid.VMod)%grid.VDur + grid.VDur) % grid.VDur; got != 0 {
t.Errorf("video start %d is off-grid by %d (dur=%d mod=%d)", vStart, got, grid.VDur, grid.VMod)
}
aStart, _ := strconv.ParseInt(ms[1][1], 10, 64)
if got := ((aStart-grid.AMod)%grid.ADur + grid.ADur) % grid.ADur; got != 0 {
t.Errorf("audio start %d is off-grid by %d (dur=%d mod=%d)", aStart, got, grid.ADur, grid.AMod)
}
}
// Every provider-shaped literal must be overridable from config.
func TestBuildSyntheticHonoursConfig(t *testing.T) {
resetGridCache("ch9")
base := originServer(t)
cfg := SyntheticConfig{
VideoName: "video",
AudioName: "audio_128k",
VideoBandwidth: 3_000_000,
AudioBandwidth: 96_000,
VideoCodecs: "hvc1.1.6.L93.B0",
AudioCodecs: "mp4a.40.5",
Width: 1280,
Height: 720,
Lang: "ga",
SegmentPathSuffix: "cmaf/",
VideoInitTemplate: "{channel}-v-init.m4s",
VideoMediaTemplate: "{channel}-v-$Time$.m4s",
MinUpdatePeriod: "PT4S",
}
out, err := BuildSynthetic(cfg, "ch9", base, "df1633821dddfdd5bec9822c1ec0f052")
if err != nil {
t.Fatal(err)
}
got := string(out)
for _, want := range []string{
`bandwidth="3000000"`, `bandwidth="96000"`,
`codecs="hvc1.1.6.L93.B0"`, `codecs="mp4a.40.5"`,
`width="1280" height="720"`, `lang="ga"`,
`minimumUpdatePeriod="PT4S"`,
base + "cmaf/",
`initialization="ch9-v-init.m4s"`,
`media="ch9-v-$Time$.m4s"`,
// audio templates were left empty, so they derive from name + bandwidth
`initialization="ch9-audio_128k=96000.dash"`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q\n---\n%s", want, got)
}
}
if strings.Contains(got, `width="1920"`) || strings.Contains(got, `codecs="avc1`) {
t.Error("a default leaked past the config override")
}
}
func TestBuildSyntheticRejectsBadKID(t *testing.T) {
resetGridCache("vcbad")
base := originServer(t)
if _, err := BuildSynthetic(SyntheticConfig{}, "vcbad", base, "not-a-kid"); err == nil {
t.Fatal("expected an error for a malformed KID")
}
}
func TestScaleMSSDoesNotOverflow(t *testing.T) {
// A real MSS time (~1.7e16); mss*48000 overflows int64 if done naively.
const mss = int64(17_000_000_000_000_000)
got := ScaleMSS(mss, DefaultMSSTimescale, 48_000)
want := int64(17_000_000_000_000_000 / 10_000_000 * 48_000)
if got != want {
t.Errorf("ScaleMSS = %d, want %d", got, want)
}
if got < 0 {
t.Error("ScaleMSS overflowed to a negative value")
}
}
func TestParseMSSStreamTimesExpandsRepeats(t *testing.T) {
times := ParseMSSStreamTimes(smoothManifest, "video")
if len(times) != 4 {
t.Fatalf("got %d segment times, want 4 (r=3)", len(times))
}
for i, ts := range times {
want := int64(17_000_000_000_000) + int64(i)*40_000_000
if ts != want {
t.Errorf("time[%d] = %d, want %d", i, ts, want)
}
}
if ParseMSSStreamTimes(smoothManifest, "nope") != nil {
t.Error("unknown stream name should yield no times")
}
}
func TestKIDToUUIDAndHex(t *testing.T) {
u, err := KIDToUUID("DF1633821DDDFDD5BEC9822C1EC0F052")
if err != nil {
t.Fatal(err)
}
if u != "df163382-1ddd-fdd5-bec9-822c1ec0f052" {
t.Errorf("KIDToUUID = %q", u)
}
if KIDHex(u) != "df1633821dddfdd5bec9822c1ec0f052" {
t.Errorf("KIDHex = %q", KIDHex(u))
}
if _, err := KIDToUUID("abc"); err == nil {
t.Error("short KID should error")
}
}
func TestExtractDefaultKID(t *testing.T) {
xml := fmt.Sprintf(`<ContentProtection cenc:default_KID="%s"/>`, "DF163382-1DDD-FDD5-BEC9-822C1EC0F052")
if got := ExtractDefaultKID(xml); got != "df163382-1ddd-fdd5-bec9-822c1ec0f052" {
t.Errorf("ExtractDefaultKID = %q", got)
}
if ExtractDefaultKID(`<MPD/>`) != "" {
t.Error("no KID should yield empty string")
}
}
// An unregistered or "none" rewriter must report that no rewrite is needed.
func TestRewriterRegistry(t *testing.T) {
if _, needed := Lookup("none"); needed {
t.Error(`"none" must not need a rewrite`)
}
if _, needed := Lookup(""); needed {
t.Error("empty name must not need a rewrite")
}
if _, needed := Lookup("nosuchprovider"); needed {
t.Error("unknown name must not need a rewrite")
}
Register("testrw", func() Rewriter { return Passthrough{} })
// Registering a Passthrough still means "no rewrite needed".
if _, needed := Lookup("testrw"); needed {
t.Error("a passthrough rewriter must not report needing a rewrite")
}
}

View file

@ -0,0 +1,463 @@
// Package phonecap runs one phone MITM capture: proxy → launch → autoplay →
// wait → wvkey → session → optional force-stop. Used by capture.exe and agent.
package phonecap
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"drmdecryption/adb"
"drmdecryption/app"
"drmdecryption/capture"
"drmdecryption/proxy"
"drmdecryption/repo"
"drmdecryption/session"
"drmdecryption/wvkey"
)
// DefaultCAHash is the subject hash of the bundled MITM CA, used when an app
// module does not state its own.
const DefaultCAHash = "6c3578b4"
// Options for a single capture job on one device.
type Options struct {
App app.App
Channel string
Client *adb.Client // serial-scoped when multi-device
Root string
Python string
WVD string // path to .wvd device file (required for key fetch)
UserAgent string // optional license-request User-Agent
Wait time.Duration
NoAutoPlay bool
// KeyMode: "auto" (default), "modulardrm", or "raw".
KeyMode string
// CloseApp force-stops the package when the job ends (success or failure).
CloseApp bool
// ClearProxy clears global http_proxy after the job.
ClearProxy bool
}
// Result is the captured session plus on-disk path.
type Result struct {
Session session.Session
Path string
Key string
MPD string
}
// RunPassive starts the MITM only — no app launch / auto-play. Waits for
// whatever DRM/manifest traffic the phone generates, prints it, and writes
// outputs/capture/<stamp>/ when anything useful appears.
func RunPassive(opt Options) (res Result, err error) {
if opt.Client == nil {
opt.Client = adb.New()
}
if opt.Root == "" {
opt.Root = repo.Root()
}
if opt.Wait <= 0 {
opt.Wait = 180 * time.Second
}
c := opt.Client
root := opt.Root
if opt.ClearProxy {
defer ClearProxyLater(c)
}
if err := c.EnsureDevice(); err != nil {
return res, err
}
proxyBin, caHash := resolveProxy(nil, root)
if err := proxy.PushAndStart(c, proxyBin); err != nil {
return res, err
}
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
return res, err
}
proxy.ReinjectCA(c, caHash)
fmt.Println("[*] Passive capture — open any app and start playback on the phone")
fmt.Println(" (no auto-play; Ctrl+C will not save — wait for traffic or raise --wait)")
hints := capture.DefaultPassiveHints()
_ = os.MkdirAll(filepath.Join(root, ".cache"), 0o755)
serialTag := c.Serial
if serialTag == "" {
serialTag = "default"
}
localLog := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+".log")
localCap := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+"_cap.json")
stop := make(chan struct{})
go capture.MirrorLogLoop(c, hints.RemoteLog, localLog, stop)
defer close(stop)
fmt.Println("[*] Waiting for any license / PSSH / manifest…")
fmt.Println(" watch:", localLog)
capData, err := capture.Wait(c, hints, localLog, localCap, opt.Wait)
if err != nil {
return res, err
}
licenseURL := capData.Get("license_url")
if strings.TrimSpace(capData.Get("pssh")) == "" && strings.TrimSpace(capData.Get("mpd")) != "" {
if pssh, err := extractHLSPSSH(capData.Get("mpd")); err == nil && pssh != "" {
capData["pssh"] = pssh
}
}
fmt.Println("[+] Capture:")
for _, k := range []string{"pid", "mpd", "license_url", "auth", "pssh"} {
v := capData.Get(k)
if v == "" {
continue
}
label := k
if k == "license_url" {
label = "license"
}
fmt.Println(" ", label+":", trim(v, 110))
}
key := ""
if capData.Get("pssh") != "" && licenseURL != "" {
k, kerr := fetchKey(opt, root, capData, licenseURL)
if kerr != nil {
fmt.Fprintf(os.Stderr, "[!] key fetch skipped: %v\n", kerr)
} else {
key = k
fmt.Println("[+] key:", strings.ReplaceAll(key, "\n", " | "))
}
} else {
fmt.Println("[*] Not enough fields for wvkey (need pssh + license_url) — raw capture saved")
}
sess := session.Session{
Channel: opt.Channel,
PSSH: capData.Get("pssh"),
Auth: capData.Get("auth"),
PID: capData.Get("pid"),
Key: key,
MPD: capData.Get("mpd"),
LicenseURL: licenseURL,
}
path, err := session.Write(root, "capture", sess)
if err != nil {
return res, err
}
fmt.Println("[+] session:", path)
res.Session = sess
res.Path = path
res.Key = key
res.MPD = sess.MPD
return res, nil
}
// Run executes the full phone capture pipeline for a registered app module.
func Run(opt Options) (res Result, err error) {
if opt.App == nil {
return res, fmt.Errorf("app plugin required (pass --app, or omit --app for passive capture)")
}
if opt.Client == nil {
opt.Client = adb.New()
}
if opt.Root == "" {
opt.Root = repo.Root()
}
if opt.Wait <= 0 {
opt.Wait = 180 * time.Second
}
c := opt.Client
a := opt.App
root := opt.Root
// Defers run LIFO: close app first, then clear proxy.
if opt.ClearProxy {
defer ClearProxyLater(c)
}
if opt.CloseApp {
defer func() {
fmt.Printf("[*] Closing %s…\n", a.Package())
c.ForceStop(a.Package())
}()
}
if err := c.EnsureDevice(); err != nil {
return res, err
}
proxyBin, caHash := resolveProxy(a, root)
hints := a.CaptureHints()
useTProxy := false
if tm, ok := a.(app.TransparentMITM); ok && tm.UseTransparentMITM() {
useTProxy = true
}
if useTProxy {
// Transparent REDIRECT — keeps phone VPN (BBC UK geo) working.
proxy.ClearHTTPProxy(c)
remoteDir := "/data/local/tmp/capture/" + a.Package()
if err := proxy.StartTransparent(c, proxyBin, a.Package(), "8080", remoteDir, false); err != nil {
return res, err
}
defer proxy.StopTransparent(c)
hints.RemoteCaps = []string{remoteDir + "/cap.json"}
hints.RemoteLog = remoteDir + "/appproxy.log"
fmt.Println("[*] Transparent MITM (VPN OK) — package", a.Package())
} else {
if err := proxy.PushAndStart(c, proxyBin, hints.Score.ProxyArgs()...); err != nil {
return res, err
}
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
return res, err
}
proxy.ReinjectCA(c, caHash)
}
if err := a.Launch(c); err != nil {
fmt.Fprintf(os.Stderr, "[!] launch: %v\n", err)
}
if !opt.NoAutoPlay {
if err := a.AutoPlay(c, opt.Channel); err != nil {
fmt.Fprintf(os.Stderr, "[!] auto-play failed: %v\n", err)
fmt.Println("[*] Open the channel on the phone manually and start playback…")
}
} else {
fmt.Println("[*] Open the channel on the phone and wait for playback…")
}
_ = os.MkdirAll(filepath.Join(root, ".cache"), 0o755)
serialTag := c.Serial
if serialTag == "" {
serialTag = "default"
}
localLog := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+".log")
localCap := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+"_cap.json")
stop := make(chan struct{})
go capture.MirrorLogLoop(c, hints.RemoteLog, localLog, stop)
defer close(stop)
need := hints.Require
if len(need) == 0 {
need = []string{"manifest"}
}
fmt.Printf("[*] Waiting for %s…\n", strings.Join(need, " + "))
fmt.Println(" watch:", localLog)
capData, err := capture.Wait(c, hints, localLog, localCap, opt.Wait)
if err != nil {
return res, err
}
licenseURL := first(capData.Get("license_url"), a.LicenseURL())
keyMode := strings.ToLower(strings.TrimSpace(opt.KeyMode))
if keyMode == "" || keyMode == "auto" {
keyMode = strings.ToLower(strings.TrimSpace(a.KeyMode()))
}
// Brightcove/HLS: proxy often captures license + master URL but not PSSH.
// Skip for clear/MPD-only apps (KeyMode none).
if keyMode != "none" && keyMode != "clear" {
if strings.TrimSpace(capData.Get("pssh")) == "" && strings.TrimSpace(capData.Get("mpd")) != "" {
if pssh, err := extractHLSPSSH(capData.Get("mpd")); err == nil && pssh != "" {
capData["pssh"] = pssh
} else if err != nil {
fmt.Fprintf(os.Stderr, "[!] HLS PSSH extract: %v\n", err)
}
}
}
mpdURL := strings.TrimSpace(capData.Get("mpd"))
// Console: always surface the MPD we will use (BBC clear streams only need this).
fmt.Println()
fmt.Println("========== MPD ==========")
if mpdURL != "" {
fmt.Println(mpdURL)
} else {
fmt.Println("(none)")
}
fmt.Println("=========================")
fmt.Println()
fmt.Println("[+] Capture:")
for _, k := range []string{"pid", "mpd", "license_url", "auth", "pssh"} {
v := capData.Get(k)
if k == "license_url" {
v = first(v, licenseURL)
}
if v == "" {
continue
}
label := k
if k == "license_url" {
label = "license"
}
fmt.Println(" ", label+":", trim(v, 120))
}
key := ""
if keyMode == "none" || keyMode == "clear" {
fmt.Println("[*] Key mode none — skipping wvkey (clear / MPD-only capture)")
} else {
var kerr error
key, kerr = fetchKey(opt, root, capData, licenseURL)
if kerr != nil {
return res, kerr
}
fmt.Println("[+] key:", strings.ReplaceAll(key, "\n", " | "))
}
sess := session.Session{
Channel: opt.Channel,
PSSH: capData.Get("pssh"),
Auth: capData.Get("auth"),
PID: capData.Get("pid"),
Key: key,
MPD: mpdURL,
LicenseURL: licenseURL,
}
path, err := session.Write(root, a.Name(), sess)
if err != nil {
return res, err
}
fmt.Println("[+] session:", path)
if mpdURL != "" {
fmt.Println("[+] mpd:", mpdURL)
}
res.Session = sess
res.Path = path
res.Key = key
res.MPD = sess.MPD
return res, nil
}
// ClearProxyLater clears http_proxy (exported for agent defer helpers).
func ClearProxyLater(c *adb.Client) {
proxy.ClearHTTPProxy(c)
}
func resolveProxy(a app.App, root string) (bin, caHash string) {
bin = filepath.Join(root, "bin", "proxy-android-arm64")
caHash = DefaultCAHash
if a != nil {
if p := a.ProxyBin(); p != "" {
bin = p
}
if h := a.CAHash(); h != "" {
caHash = h
}
}
if _, err := os.Stat(bin); err != nil {
for _, p := range []string{
filepath.Join(root, "bin", "proxy-android-arm64"),
filepath.Join(root, "apps", "proxy", "proxy-android-arm64"),
} {
if st, e := os.Stat(p); e == nil && !st.IsDir() {
bin = p
break
}
}
}
return bin, caHash
}
func fetchKey(opt Options, root string, cap capture.Data, licenseURL string) (string, error) {
if strings.TrimSpace(opt.WVD) == "" {
return "", fmt.Errorf("wvkey requires --wvd (path to .wvd device file)")
}
py := opt.Python
if py == "" {
py = filepath.Join(root, ".venv", "Scripts", "python.exe")
if _, err := os.Stat(py); err != nil {
py = filepath.Join(root, ".venv", "bin", "python")
}
}
wopt := wvkey.Options{
Python: py,
Script: filepath.Join(root, "apps", "wvkey", "wvkey.py"),
WVD: opt.WVD,
PSSH: cap.Get("pssh"),
Auth: cap.Get("auth"),
PID: cap.Get("pid"),
LicenseURL: licenseURL,
UserAgent: opt.UserAgent,
}
mode := strings.ToLower(strings.TrimSpace(opt.KeyMode))
if mode == "" || mode == "auto" {
mode = "modulardrm"
if opt.App != nil {
if m := strings.ToLower(strings.TrimSpace(opt.App.KeyMode())); m != "" {
mode = m
}
}
}
switch mode {
case "raw":
// Brightcove returns multiple CONTENT keys; NRE needs all of them.
keys, err := wvkey.FetchRawAll(wopt)
if err != nil {
return "", err
}
return strings.Join(keys, "\n"), nil
default:
return wvkey.Fetch(wopt)
}
}
func first(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
var (
reWVKeyURI = regexp.MustCompile(`(?is)KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed".*?URI="data:text/plain;base64,([A-Za-z0-9+/=]+)"`)
reWVKeyURI2 = regexp.MustCompile(`(?is)URI="data:text/plain;base64,([A-Za-z0-9+/=]+)".*?KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"`)
)
func extractHLSPSSH(masterURL string) (string, error) {
resp, err := http.Get(masterURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return "", err
}
s := string(body)
if m := reWVKeyURI.FindStringSubmatch(s); len(m) == 2 {
return m[1], nil
}
if m := reWVKeyURI2.FindStringSubmatch(s); len(m) == 2 {
return m[1], nil
}
return "", fmt.Errorf("no Widevine PSSH in HLS master")
}
func trim(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r
default:
return '_'
}
}, s)
return s
}

577
apps/pkg/proxy/proxy.go Normal file
View file

@ -0,0 +1,577 @@
package proxy
import (
"crypto/md5"
"crypto/x509"
"encoding/binary"
"encoding/pem"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"drmdecryption/adb"
"drmdecryption/repo"
)
const (
// Universal on-device paths (still kill legacy rteproxy process names).
RemoteBin = "/data/local/tmp/appproxy"
RemoteCap = "/data/local/tmp/appproxy_cap.json"
RemoteLog = "/data/local/tmp/appproxy.log"
RemoteTraffic = "/data/local/tmp/appproxy_traffic.jsonl"
RemoteCACrt = "/data/local/tmp/rteproxy-ca.crt"
DefaultCAHash = "6c3578b4"
)
var reWLANIPv4 = regexp.MustCompile(`(?m)^\s*inet\s+(\d{1,3}(?:\.\d{1,3}){3})/`)
// DeviceWLANIPv4 returns the phone's current wlan0 IPv4 address.
func DeviceWLANIPv4(c *adb.Client) (string, error) {
out := c.Out("shell", "ip", "-f", "inet", "addr", "show", "wlan0")
if m := reWLANIPv4.FindStringSubmatch(out); len(m) == 2 {
return m[1], nil
}
// Fallbacks used on some OEM builds.
for _, prop := range []string{"dhcp.wlan0.ipaddress", "dhcp.eth0.ipaddress"} {
if v := c.Out("shell", "getprop", prop); net.ParseIP(v) != nil && v != "0.0.0.0" {
return v, nil
}
}
return "", fmt.Errorf("no wlan0 IPv4 (is Wi‑Fi connected?)")
}
// DeviceHTTPProxyAddr returns "<wlan-ip>:port" for the on-device MITM.
// Loopback (127.0.0.1) must not be used: after MITM, some clients rewrite the
// upstream Host to the proxy address, and appproxy then dials 127.0.0.1:443.
func DeviceHTTPProxyAddr(c *adb.Client, port string) (string, error) {
if strings.TrimSpace(port) == "" {
port = "8080"
}
ip, err := DeviceWLANIPv4(c)
if err != nil {
return "", err
}
return net.JoinHostPort(ip, port), nil
}
func looksLikeLoopbackProxy(want string) bool {
host, _, err := net.SplitHostPort(strings.TrimSpace(want))
if err != nil {
host = strings.TrimSpace(want)
}
host = strings.Trim(host, "[]")
return host == "127.0.0.1" || host == "localhost" || host == "::1" || host == "0.0.0.0" || host == ""
}
// EnsureHTTPProxy points the device at the on-device mitm.
// Empty or loopback want is rewritten to the phone's WLAN IP:8080 so upstream
// MITM dials keep the real destination host (BBC/RTE/etc.).
func EnsureHTTPProxy(c *adb.Client, want string) error {
if looksLikeLoopbackProxy(want) {
port := "8080"
if hostport := strings.TrimSpace(want); hostport != "" {
if _, p, err := net.SplitHostPort(hostport); err == nil && p != "" {
port = p
}
}
addr, err := DeviceHTTPProxyAddr(c, port)
if err != nil {
return err
}
want = addr
}
cur := c.Out("shell", "settings", "get", "global", "http_proxy")
if cur == want {
fmt.Println("[+] HTTP proxy already", want)
return nil
}
fmt.Printf("[*] Setting HTTP proxy -> %s (was %q)\n", want, cur)
_, err := c.Shell("settings", "put", "global", "http_proxy", want)
got := c.Out("shell", "settings", "get", "global", "http_proxy")
if got != want {
return fmt.Errorf("failed to set http_proxy (got %q)", got)
}
return err
}
// ClearHTTPProxy disables the global HTTP proxy and stops any leftover mitm.
// Always call this after a capture/agent job so the phone can play apps normally.
func ClearHTTPProxy(c *adb.Client) {
_, _ = c.Shell("settings", "put", "global", "http_proxy", ":0")
_, _ = c.Shell("settings", "delete", "global", "http_proxy")
_, _ = c.Shell("settings", "delete", "global", "global_http_proxy_host")
_, _ = c.Shell("settings", "delete", "global", "global_http_proxy_port")
_, _ = c.Shell("settings", "delete", "global", "global_http_proxy_exclusion_list")
stopProxy(c)
fmt.Println("[*] HTTP proxy cleared")
}
func stopProxy(c *adb.Client) {
script := `
for name in appproxy rteproxy; do
pid=$(pidof $name 2>/dev/null || true)
if [ -n "$pid" ]; then kill $pid >/dev/null 2>&1 || true; fi
done
sleep 0.5
for name in appproxy rteproxy; do
pid=$(pidof $name 2>/dev/null || true)
if [ -n "$pid" ]; then kill -9 $pid >/dev/null 2>&1 || true; fi
done
# Root fallback — some builds ignore non-root kill.
if command -v su >/dev/null 2>&1; then
su -c 'killall appproxy rteproxy 2>/dev/null; killall -9 appproxy rteproxy 2>/dev/null; true' 2>/dev/null || true
fi
sleep 0.3
(pidof appproxy || pidof rteproxy) >/dev/null 2>&1 && echo STILL || echo STOPPED`
out, _ := c.Shell("sh", "-c", script)
if strings.Contains(out, "STILL") {
fmt.Println("[!] old appproxy still running after stop — port 8080 may stay busy")
}
}
// extraFlags renders extra device flags, quoting each value.
func extraFlags(args []string) string {
if len(args) == 0 {
return ""
}
var b strings.Builder
for _, a := range args {
b.WriteString(" ")
if strings.HasPrefix(a, "-") {
b.WriteString(a)
continue
}
b.WriteString("'" + strings.ReplaceAll(a, "'", "") + "'")
}
return b.String()
}
// PushAndStart installs and launches the on-device mitm binary. extraArgs are
// appended to its command line — app modules pass their manifest-scoring hosts
// that way, since the device binary cannot read a module's values file.
func PushAndStart(c *adb.Client, localBin string, extraArgs ...string) error {
if st, err := os.Stat(localBin); err != nil || st.IsDir() {
return fmt.Errorf("missing proxy binary %s — build apps/proxy first (proxyctl build)", localBin)
}
fmt.Println("[*] Stopping any old appproxy/rteproxy...")
stopProxy(c)
fmt.Println("[*] Pushing appproxy...")
if err := c.Push(localBin, RemoteBin); err != nil {
return err
}
_, _ = c.Shell("chmod", "755", RemoteBin)
_, _ = c.Shell("rm", "-f", RemoteCap, "/data/local/tmp/rte_cap.json", "/sdcard/Download/rte_cap.json", "/storage/emulated/0/Download/rte_cap.json", RemoteLog, "/data/local/tmp/rteproxy.log")
starter := "#!/system/bin/sh\n" +
"exec " + RemoteBin +
" -listen :8080" +
" -out " + RemoteCap +
" -ca-dir /data/local/tmp" +
" -dns 1.1.1.1,1.0.0.1,8.8.8.8,192.168.1.1" +
" >>" + RemoteLog + " 2>&1\n"
tmp := filepath.Join(os.TempDir(), "start_appproxy.sh")
if err := os.WriteFile(tmp, []byte(starter), 0o755); err != nil {
return err
}
defer os.Remove(tmp)
if err := c.Push(tmp, "/data/local/tmp/start_appproxy.sh"); err != nil {
return err
}
_, _ = c.Shell("chmod", "755", "/data/local/tmp/start_appproxy.sh")
// Keep backgrounded after adb exits.
_, _, _ = c.Run("shell", "setsid /data/local/tmp/start_appproxy.sh </dev/null >/dev/null 2>&1 &")
var pid, logHead string
for i := 0; i < 10; i++ {
time.Sleep(400 * time.Millisecond)
pid = c.Out("shell", "pidof", "appproxy")
if pid == "" {
pid = c.Out("shell", "pidof", "rteproxy")
}
logHead = c.Out("shell", "head", "-12", RemoteLog)
if pid != "" && strings.Contains(logHead, "listening") {
break
}
}
if logHead != "" {
fmt.Println(logHead)
}
errLog := c.Out("shell", "cat", RemoteLog)
if strings.Contains(errLog, "address already in use") ||
(strings.Contains(errLog, "listen ") && strings.Contains(errLog, "bind:")) {
fmt.Fprintln(os.Stderr, errLog)
return fmt.Errorf("appproxy failed to bind :8080 (old process still holding the port?)")
}
if pid == "" || !strings.Contains(logHead, "listening") {
if errLog != "" {
fmt.Fprintln(os.Stderr, errLog)
}
return fmt.Errorf("appproxy failed to start")
}
fmt.Printf("[+] appproxy pid=%s; capture -> %s\n", pid, RemoteCap)
return nil
}
// FindLocalBin returns the first existing proxy binary under the repo.
func FindLocalBin(root string) string {
if root == "" {
root = repo.Root()
}
for _, p := range []string{
filepath.Join(root, "bin", "proxy-android-arm64"),
filepath.Join(root, "apps", "proxy", "proxy-android-arm64"),
filepath.Join(root, "apps", "proxy", "rteproxy-android-arm64"),
filepath.Join(root, "bin", "rteproxy-android-arm64"),
} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
// FindLocalCA returns the first existing MITM CA cert under the repo.
func FindLocalCA(root string) string {
if root == "" {
root = repo.Root()
}
for _, p := range []string{
filepath.Join(root, "apps", "proxy", "rteproxy-ca.crt"),
filepath.Join(root, "data", "proxy-ca.crt"),
} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
// AndroidCAHash returns the OpenSSL subject_hash_old used for system CA files.
func AndroidCAHash(certPEM []byte) (string, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return "", fmt.Errorf("no PEM certificate found")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", err
}
sum := md5.Sum(cert.RawSubject)
n := binary.LittleEndian.Uint32(sum[0:4])
return fmt.Sprintf("%08x", n), nil
}
// InstallCA pushes the MITM CA onto the device as HASH.0 and optionally Magisk-reinjects it.
// When reinject is true, runs the full Magisk conscrypt bind (needs root / Magisk busybox).
func InstallCA(c *adb.Client, localCA string, reinject bool) (hash string, err error) {
if localCA == "" {
localCA = FindLocalCA("")
}
if localCA == "" {
return "", fmt.Errorf("no local CA cert found (expected apps/proxy/rteproxy-ca.crt)")
}
pemBytes, err := os.ReadFile(localCA)
if err != nil {
return "", err
}
hash, err = AndroidCAHash(pemBytes)
if err != nil {
return "", err
}
fmt.Printf("[*] Installing CA %s (hash %s)\n", localCA, hash)
if err := c.Push(localCA, RemoteCACrt); err != nil {
return hash, err
}
// Also drop a copy named after the process for clarity.
_ = c.Push(localCA, "/data/local/tmp/appproxy-ca.crt")
tmpHash := filepath.Join(os.TempDir(), hash+".0")
if err := os.WriteFile(tmpHash, pemBytes, 0o644); err != nil {
return hash, err
}
defer os.Remove(tmpHash)
remoteHash := "/data/local/tmp/" + hash + ".0"
if err := c.Push(tmpHash, remoteHash); err != nil {
return hash, err
}
_, _ = c.Shell("chmod", "644", remoteHash, RemoteCACrt)
fmt.Printf("[+] Pushed %s and %s\n", RemoteCACrt, remoteHash)
if reinject {
ReinjectCA(c, hash)
} else {
fmt.Println("[*] Skipped Magisk reinject (pass -reinject / install-ca --reinject)")
fmt.Println(" User-CA path: Settings → Security → Install a certificate → CA certificate")
}
return hash, nil
}
// Stop stops the on-device mitm process.
func Stop(c *adb.Client) {
stopProxy(c)
}
const (
RemoteTProxyScript = "/data/local/tmp/tproxy_iptables.sh"
RemoteCaptureRoot = "/data/local/tmp/capture"
)
// StartTransparent pushes appproxy in -transparent mode (no Wi‑Fi http_proxy),
// installs iptables UID REDIRECT for pkg, and writes captures under remoteDir.
func StartTransparent(c *adb.Client, localBin, pkg, port, remoteDir string, reinject bool) error {
if port == "" {
port = "8080"
}
if remoteDir == "" {
remoteDir = RemoteCaptureRoot + "/" + pkg
}
stopProxy(c)
_ = stopTransparentRules(c)
// Ensure no global HTTP proxy — transparent mode must not use one.
ClearHTTPProxy(c)
if reinject {
ReinjectCA(c, DefaultCAHash)
}
if st, err := os.Stat(localBin); err != nil || st.IsDir() {
return fmt.Errorf("missing proxy binary %s", localBin)
}
fmt.Println("[*] Pushing appproxy (transparent)...")
if err := c.Push(localBin, RemoteBin); err != nil {
return err
}
_, _ = c.Shell("chmod", "755", RemoteBin)
scriptLocal := filepath.Join(repo.Root(), "apps", "proxy", "device", "tproxy_iptables.sh")
if _, err := os.Stat(scriptLocal); err != nil {
return fmt.Errorf("missing %s", scriptLocal)
}
if err := c.Push(scriptLocal, RemoteTProxyScript); err != nil {
return err
}
_, _ = c.Shell("chmod", "755", RemoteTProxyScript)
_, _ = c.Shell("mkdir", "-p", remoteDir)
_, _ = c.Shell("rm", "-f", remoteDir+"/cap.json", remoteDir+"/traffic.jsonl", remoteDir+"/appproxy.log")
starter := "#!/system/bin/sh\n" +
"mkdir -p '" + remoteDir + "'\n" +
"exec " + RemoteBin +
" -transparent" +
" -listen :" + port +
" -mitm-internal 127.0.0.1:18080" +
" -out-dir '" + remoteDir + "'" +
" -ca-dir /data/local/tmp" +
" -dns 1.1.1.1,1.0.0.1,8.8.8.8" +
" -log-all" +
" -v" +
"\n"
tmp := filepath.Join(os.TempDir(), "start_appproxy_tproxy.sh")
if err := os.WriteFile(tmp, []byte(starter), 0o755); err != nil {
return err
}
defer os.Remove(tmp)
if err := c.Push(tmp, "/data/local/tmp/start_appproxy_tproxy.sh"); err != nil {
return err
}
_, _ = c.Shell("chmod", "755", "/data/local/tmp/start_appproxy_tproxy.sh")
_, _, _ = c.Run("shell", "setsid /data/local/tmp/start_appproxy_tproxy.sh </dev/null >/dev/null 2>&1 &")
var pid string
for i := 0; i < 15; i++ {
time.Sleep(400 * time.Millisecond)
pid = c.Out("shell", "pidof", "appproxy")
if pid != "" {
break
}
}
if pid == "" {
return fmt.Errorf("appproxy failed to start (transparent)")
}
fmt.Printf("[+] appproxy pid=%s; capture → %s\n", pid, remoteDir)
out, errOut, err := c.Run("shell", "su", "-c", "sh "+RemoteTProxyScript+" start "+pkg+" "+port)
fmt.Print(out)
if err != nil {
return fmt.Errorf("iptables: %v (%s)", err, strings.TrimSpace(errOut+out))
}
fmt.Println("[+] iptables REDIRECT installed (UK VPN can stay ON; do not set Wi‑Fi proxy)")
return nil
}
// StopTransparent removes iptables rules and stops appproxy (does not require Wi‑Fi proxy clear beyond safety).
func StopTransparent(c *adb.Client) {
_ = stopTransparentRules(c)
stopProxy(c)
ClearHTTPProxy(c)
fmt.Println("[*] transparent capture stopped")
}
func stopTransparentRules(c *adb.Client) error {
out, errOut, err := c.Run("shell", "su", "-c", "sh "+RemoteTProxyScript+" stop")
if strings.TrimSpace(out) != "" {
fmt.Print(out)
}
if err != nil && !strings.Contains(errOut+out, "STOPPED") {
return fmt.Errorf("iptables stop: %v %s", err, errOut)
}
return nil
}
// PullDir pulls regular files under remoteDir into destDir.
// Avoids `adb shell sh -c "ls …"` — on Windows that often lists `/` instead of the path.
func PullDir(c *adb.Client, remoteDir, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
remoteDir = strings.TrimRight(strings.TrimSpace(remoteDir), "/")
// Prefer known capture artifacts; fall back to find -type f.
// Do not use `adb shell sh -c "ls …"` — on Windows that often lists `/`.
var names []string
for _, n := range []string{"cap.json", "traffic.jsonl", "appproxy.log"} {
if fileExistsOnDevice(c, remoteDir+"/"+n) {
names = append(names, n)
}
}
if len(names) == 0 {
list := c.Out("shell", "find", remoteDir, "-maxdepth", "1", "-type", "f")
for _, line := range strings.Split(list, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
names = append(names, filepath.Base(filepath.Clean(line)))
}
}
if len(names) == 0 {
return fmt.Errorf("no files in %s", remoteDir)
}
seen := map[string]bool{}
pulled := 0
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) || seen[name] {
continue
}
seen[name] = true
remote := remoteDir + "/" + name
local := filepath.Join(destDir, name)
if err := c.Pull(remote, local); err != nil {
fmt.Printf("[!] pull %s: %v\n", remote, err)
continue
}
fmt.Printf("[+] %s\n", local)
pulled++
}
if pulled == 0 {
return fmt.Errorf("failed to pull any files from %s", remoteDir)
}
return nil
}
func fileExistsOnDevice(c *adb.Client, remote string) bool {
_, _, err := c.Run("shell", "ls", remote)
return err == nil
}
// PullCaptures pulls traffic/cap/log into destDir (created if missing).
func PullCaptures(c *adb.Client, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
for _, remote := range []string{RemoteTraffic, RemoteCap, RemoteLog} {
base := filepath.Base(remote)
local := filepath.Join(destDir, base)
if err := c.Pull(remote, local); err != nil {
fmt.Printf("[!] pull %s: %v\n", remote, err)
continue
}
fmt.Printf("[+] %s\n", local)
}
return nil
}
// DiscoverOutDir returns outputs/discover/<stamp> under the repo root.
func DiscoverOutDir(root, stamp string) string {
if root == "" {
root = repo.Root()
}
if stamp == "" {
stamp = time.Now().Format("20060102-150405")
}
return filepath.Join(root, "outputs", "discover", stamp)
}
// ReinjectCA best-effort Magisk bind of the mitm CA into conscrypt.
func ReinjectCA(c *adb.Client, caHash string) {
if caHash == "" {
caHash = DefaultCAHash
}
// Bound the per-app nsenter loop — wait on hundreds of PIDs can hang forever.
script := fmt.Sprintf(`
BB=/data/adb/magisk/busybox
HASH=%s
[ -f /data/local/tmp/$HASH.0 ] || { echo CA_SKIP; exit 0; }
[ -x "$BB" ] || { echo CA_SKIP; exit 0; }
rm -rf /data/local/tmp/cacerts-overlay
mkdir -p /data/local/tmp/cacerts-overlay
cp /apex/com.android.conscrypt/cacerts/* /data/local/tmp/cacerts-overlay/ 2>/dev/null || true
cp /data/local/tmp/$HASH.0 /data/local/tmp/cacerts-overlay/$HASH.0
chmod 644 /data/local/tmp/cacerts-overlay/*
$BB mount -t tmpfs tmpfs /system/etc/security/cacerts 2>/dev/null || true
cp /data/local/tmp/cacerts-overlay/* /system/etc/security/cacerts/ 2>/dev/null || true
chmod 644 /system/etc/security/cacerts/* 2>/dev/null || true
chcon u:object_r:system_file:s0 /system/etc/security/cacerts/* 2>/dev/null || true
$BB mount --bind /system/etc/security/cacerts /apex/com.android.conscrypt/cacerts 2>/dev/null || true
for Z in $(pidof zygote64 2>/dev/null); do
nsenter --mount=/proc/$Z/ns/mnt -- $BB mount --bind /system/etc/security/cacerts /apex/com.android.conscrypt/cacerts 2>/dev/null || true
done
# Only rebind the target app if set; otherwise skip the full zygote-child sweep (hangs).
PKG_UID_FILE=/data/local/tmp/reinject_target_uid
if [ -f "$PKG_UID_FILE" ]; then
TUID=$(cat "$PKG_UID_FILE")
for PID in $(ps -A -o PID=,UID= 2>/dev/null | awk -v u="$TUID" '$2==u {print $1}'); do
nsenter --mount=/proc/$PID/ns/mnt -- $BB mount --bind /system/etc/security/cacerts /apex/com.android.conscrypt/cacerts 2>/dev/null || true
done
fi
ls /apex/com.android.conscrypt/cacerts/$HASH.0 2>/dev/null && echo CA_OK || echo CA_SKIP
`, caHash)
fmt.Println("[*] Re-injecting system CA (Magisk)...")
tmp := filepath.Join(os.TempDir(), "reinject_ca.sh")
_ = os.WriteFile(tmp, []byte("#!/system/bin/sh\n"+script), 0o755)
defer os.Remove(tmp)
_ = c.Push(tmp, "/data/local/tmp/reinject_ca.sh")
_, _ = c.Shell("chmod", "755", "/data/local/tmp/reinject_ca.sh")
// Host-side timeout: su -mm + nsenter has hung on some Magisk builds.
type runResult struct {
out string
}
ch := make(chan runResult, 1)
go func() {
out, _, _ := c.Run("shell", "su", "-mm", "-c", "sh /data/local/tmp/reinject_ca.sh")
ch <- runResult{out: out}
}()
var out string
select {
case r := <-ch:
out = r.out
case <-time.After(20 * time.Second):
fmt.Println("[!] CA reinject timed out after 20s — continuing (CA likely already mounted)")
_, _ = c.Shell("su", "-c", "killall reinject_ca.sh 2>/dev/null; true")
return
}
if strings.Contains(out, "CA_OK") {
fmt.Println("[+] System CA present in conscrypt")
} else {
fmt.Println("[!] CA inject skipped/failed — if TLS errors, re-run Magisk CA mount")
}
}

42
apps/pkg/repo/root.go Normal file
View file

@ -0,0 +1,42 @@
package repo
import (
"os"
"path/filepath"
)
// Root finds the repository root (directory that contains apps/pkg/go.mod
// and is not itself under apps/ — i.e. the real checkout root).
func Root() string {
wd, err := os.Getwd()
if err != nil {
wd = "."
}
cur := wd
for i := 0; i < 16; i++ {
// Require apps/pkg/go.mod AND a sibling marker so that walking from
// inside apps/pkg does not treat apps/ as the root (apps/pkg/go.mod
// would match the old top-level pkg/ layout check).
pkgMod := filepath.Join(cur, "apps", "pkg", "go.mod")
if _, err := os.Stat(pkgMod); err == nil {
if markerOK(cur) {
return cur
}
}
parent := filepath.Dir(cur)
if parent == cur {
break
}
cur = parent
}
return wd
}
func markerOK(root string) bool {
for _, name := range []string{"build.ps1", "README.md", "configs", "outputs"} {
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
return true
}
}
return false
}

101
apps/pkg/session/session.go Normal file
View file

@ -0,0 +1,101 @@
package session
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Session is the on-disk capture result (shared with restream tooling).
type Session struct {
App string `json:"app"`
Channel string `json:"channel,omitempty"`
CapturedAt string `json:"captured_at"`
PSSH string `json:"pssh,omitempty"`
Auth string `json:"auth,omitempty"`
PID string `json:"pid,omitempty"`
Key string `json:"key,omitempty"`
KID string `json:"kid,omitempty"`
KeyHex string `json:"key_hex,omitempty"`
MPD string `json:"mpd,omitempty"`
LicenseURL string `json:"license_url,omitempty"`
}
// Write creates outputs/<app>/<stamp>/session.json (+ field txt files + latest).
func Write(root, app string, s Session) (string, error) {
stamp := time.Now().Format("20060102-150405")
dir := filepath.Join(root, "outputs", app, stamp)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
s.App = app
if s.CapturedAt == "" {
s.CapturedAt = time.Now().Format(time.RFC3339)
}
if s.Key != "" && s.KID == "" {
parts := strings.SplitN(s.Key, ":", 2)
if len(parts) == 2 {
s.KID = parts[0]
s.KeyHex = parts[1]
}
}
raw, err := json.MarshalIndent(s, "", " ")
if err != nil {
return "", err
}
path := filepath.Join(dir, "session.json")
if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil {
return "", err
}
fields := map[string]string{
"pssh": s.PSSH,
"auth": s.Auth,
"pid": s.PID,
"key": s.Key,
"mpd": s.MPD,
}
for name, val := range fields {
if val == "" {
continue
}
_ = os.WriteFile(filepath.Join(dir, name+".txt"), []byte(val+"\n"), 0o644)
}
latest := filepath.Join(root, "outputs", app, "latest")
_ = os.RemoveAll(latest)
// Best-effort directory copy for Windows (symlinks often need admin).
if err := copyDir(dir, latest); err != nil {
fmt.Fprintf(os.Stderr, "[!] latest link/copy: %v\n", err)
}
return path, nil
}
func copyDir(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
in := filepath.Join(src, e.Name())
out := filepath.Join(dst, e.Name())
if e.IsDir() {
if err := copyDir(in, out); err != nil {
return err
}
continue
}
b, err := os.ReadFile(in)
if err != nil {
return err
}
if err := os.WriteFile(out, b, 0o644); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,66 @@
package session
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
)
// Stream is a streamd-ready description of one playable channel, as produced by
// a provider catalog lookup. MPD holds the manifest URL whatever its type.
type Stream struct {
Name string `json:"name"`
Title string `json:"title"`
App string `json:"app"`
Channel string `json:"channel"`
MPD string `json:"mpd"`
Key string `json:"key"`
Keys []string `json:"keys,omitempty"`
PSSH string `json:"pssh,omitempty"`
PrimaryKID string `json:"primary_kid,omitempty"`
HeadersJSON string `json:"headers_json"`
Rewriter string `json:"rewriter"`
LicenseURL string `json:"license_url,omitempty"`
AccountID string `json:"account_id,omitempty"`
VideoID string `json:"video_id,omitempty"`
PlaybackURL string `json:"playback_url,omitempty"`
ManifestType string `json:"manifest_type,omitempty"`
}
// WriteStream writes outputs/<app>/<stamp>/{session.json,mpd.txt,license.txt,…}
// and refreshes outputs/<app>/latest. The app name comes from the caller so no
// provider name is baked in here.
func WriteStream(root, app string, info Stream) (string, error) {
stamp := time.Now().Format("20060102-150405")
dir := filepath.Join(root, "outputs", app, stamp)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
raw, err := json.MarshalIndent(info, "", " ")
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(dir, "session.json"), append(raw, '\n'), 0o644); err != nil {
return "", err
}
write := func(name, val string) {
if val == "" {
return
}
_ = os.WriteFile(filepath.Join(dir, name), []byte(val+"\n"), 0o644)
}
write("mpd.txt", info.MPD)
write("license.txt", info.LicenseURL)
write("pssh.txt", info.PSSH)
write("key.txt", info.Key)
write("pid.txt", info.VideoID)
if len(info.Keys) > 0 {
write("keys.txt", strings.Join(info.Keys, "\n"))
}
latest := filepath.Join(root, "outputs", app, "latest")
_ = os.RemoveAll(latest)
_ = copyDir(dir, latest)
return dir, nil
}

360
apps/pkg/uiflow/uiflow.go Normal file
View file

@ -0,0 +1,360 @@
// Package uiflow holds the phone-UI primitives app modules drive playback with:
// wait for a node, tap a node, wait for playback, find scrollable live cards.
// It knows nothing about any particular app — every threshold and pattern is an
// argument.
package uiflow
import (
"fmt"
"regexp"
"strings"
"time"
"drmdecryption/adb"
)
// Pick selects among several matching nodes.
type Pick string
const (
PickLargest Pick = "largest"
PickSmallest Pick = "smallest"
PickFirst Pick = "first"
)
// Match describes which UI node to look for. An empty Match never matches.
type Match struct {
TextRE []string
DescRE []string
TextContains []string
DescContains []string
ResourceContains []string
Pick Pick
MinArea int
}
type compiled struct {
textRE, descRE []*regexp.Regexp
m Match
}
// CompileRes compiles case-insensitive patterns, skipping empties.
func CompileRes(pats []string) []*regexp.Regexp {
out := make([]*regexp.Regexp, 0, len(pats))
for _, p := range pats {
if p == "" {
continue
}
out = append(out, regexp.MustCompile("(?i)"+p))
}
return out
}
func (m Match) compile() compiled {
return compiled{textRE: CompileRes(m.TextRE), descRE: CompileRes(m.DescRE), m: m}
}
func (c compiled) empty() bool {
return len(c.textRE) == 0 && len(c.descRE) == 0 &&
len(c.m.TextContains) == 0 && len(c.m.DescContains) == 0 && len(c.m.ResourceContains) == 0
}
func (c compiled) matches(n adb.Node) bool {
if c.empty() {
return false
}
if n.Text != "" {
for _, p := range c.textRE {
if p.MatchString(n.Text) {
return true
}
}
for _, s := range c.m.TextContains {
if s != "" && strings.Contains(strings.ToLower(n.Text), strings.ToLower(s)) {
return true
}
}
}
if n.Desc != "" {
for _, p := range c.descRE {
if p.MatchString(n.Desc) {
return true
}
}
for _, s := range c.m.DescContains {
if s != "" && strings.Contains(strings.ToLower(n.Desc), strings.ToLower(s)) {
return true
}
}
}
for _, s := range c.m.ResourceContains {
if s != "" && strings.Contains(n.ResourceID, s) {
return true
}
}
return false
}
func pickNode(hits []adb.Node, pick Pick, minArea int) *adb.Node {
if len(hits) == 0 {
return nil
}
var best *adb.Node
switch pick {
case PickSmallest:
best = &hits[0]
for i := range hits {
if hits[i].Area() < best.Area() {
best = &hits[i]
}
}
case PickFirst:
best = &hits[0]
default: // largest
best = &hits[0]
for i := range hits {
if hits[i].Area() > best.Area() {
best = &hits[i]
}
}
}
if minArea > 0 && best.Area() < minArea {
return nil
}
return best
}
// Find polls the UI until a node matches, returning it.
func Find(c *adb.Client, cacheDir string, m Match, timeout time.Duration) (*adb.Node, error) {
cm := m.compile()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
nodes, err := c.DumpUI(cacheDir)
if err != nil {
time.Sleep(time.Second)
continue
}
var hits []adb.Node
for _, n := range nodes {
if cm.matches(n) {
hits = append(hits, n)
}
}
if n := pickNode(hits, m.Pick, m.MinArea); n != nil {
return n, nil
}
time.Sleep(time.Second)
}
return nil, fmt.Errorf("ui node not found within %s", timeout)
}
// WaitUI waits for a node to exist without tapping it.
func WaitUI(c *adb.Client, cacheDir string, m Match, timeout time.Duration) error {
_, err := Find(c, cacheDir, m, timeout)
return err
}
// TapUI waits for a node and taps its centre.
func TapUI(c *adb.Client, cacheDir string, m Match, timeout time.Duration) error {
n, err := Find(c, cacheDir, m, timeout)
if err != nil {
return err
}
fmt.Printf("[*] tap_ui %q/%q @ %d,%d\n", n.Text, n.Desc, n.CX(), n.CY())
return c.Tap(n.CX(), n.CY())
}
// PlaybackOpts tunes WaitPlayback. The UI signals are a fallback for players
// whose audio session does not show up in dumpsys.
type PlaybackOpts struct {
Timeout time.Duration
SoftFail bool
OrDescContains []string
OrResourceContains []string
}
// WaitPlayback blocks until the package is playing audio, or a UI signal says the
// player is up. With SoftFail a timeout is logged and tolerated.
func WaitPlayback(c *adb.Client, cacheDir, pkg string, opt PlaybackOpts) error {
if opt.Timeout <= 0 {
opt.Timeout = 45 * time.Second
}
deadline := time.Now().Add(opt.Timeout)
for time.Now().Before(deadline) {
if c.PlaybackActive(pkg) {
fmt.Printf("[+] %s is PLAYING\n", pkg)
return nil
}
if len(opt.OrDescContains) > 0 || len(opt.OrResourceContains) > 0 {
if nodes, err := c.DumpUI(cacheDir); err == nil {
for _, n := range nodes {
desc := strings.ToLower(n.Desc)
for _, d := range opt.OrDescContains {
if d != "" && strings.Contains(desc, strings.ToLower(d)) {
fmt.Printf("[+] UI signal %q\n", d)
return nil
}
}
for _, r := range opt.OrResourceContains {
if r != "" && strings.Contains(n.ResourceID, r) {
fmt.Printf("[+] resource signal %q\n", r)
return nil
}
}
}
}
}
time.Sleep(time.Second)
}
if opt.SoftFail {
fmt.Printf("[!] timed out waiting for playback; continuing capture\n")
return nil
}
return fmt.Errorf("timed out waiting for playback")
}
// CardOpts describes what a content card looks like in a given app: a clickable
// row at least MinH tall and MinW wide, below MinY, whose description/text is not
// chrome. Callers supply the denylists; nothing is hardcoded.
type CardOpts struct {
MinY, MinH, MinW int
DescDeny []string
TextDeny []string
// ScrollMax limits swipe attempts when the wanted index is off-screen.
ScrollMax int
// Swipe coordinates used to scroll the card list.
SwipeX, SwipeFromY, SwipeToY, SwipeMS int
}
// WithDefaults fills unset geometry with values that suit a typical 1080x1920
// phone card list.
func (o CardOpts) WithDefaults() CardOpts {
if o.MinY <= 0 {
o.MinY = 300
}
if o.MinH <= 0 {
o.MinH = 250
}
if o.MinW <= 0 {
o.MinW = 600
}
if o.ScrollMax <= 0 {
o.ScrollMax = 8
}
if o.SwipeX == 0 {
o.SwipeX = 540
}
if o.SwipeFromY == 0 {
o.SwipeFromY = 1700
}
if o.SwipeToY == 0 {
o.SwipeToY = 700
}
if o.SwipeMS == 0 {
o.SwipeMS = 350
}
return o
}
// Cards returns the content cards visible in a UI dump, top to bottom.
func Cards(nodes []adb.Node, opt CardOpts) []adb.Node {
opt = opt.WithDefaults()
var cards []adb.Node
for _, n := range nodes {
if !n.Clickable {
continue
}
h := n.Y2 - n.Y1
w := n.X2 - n.X1
if n.Y1 < opt.MinY || h < opt.MinH || w < opt.MinW {
continue
}
if containsAny(n.Desc, opt.DescDeny) {
continue
}
if containsAnyFold(n.Text, opt.TextDeny) {
continue
}
cards = append(cards, n)
}
sortByTop(cards)
return cards
}
// WaitCard polls for the index-th content card, scrolling when it is not yet on
// screen (each scroll consumes one index, matching how the list advances).
func WaitCard(c *adb.Client, cacheDir string, index int, timeout time.Duration, opt CardOpts) (*adb.Node, error) {
opt = opt.WithDefaults()
deadline := time.Now().Add(timeout)
target := index
var last int
scrolled := 0
for time.Now().Before(deadline) {
nodes, err := c.DumpUI(cacheDir)
if err == nil {
cards := Cards(nodes, opt)
last = len(cards)
if target >= 0 && target < len(cards) {
card := cards[target]
return &card, nil
}
if target >= last && last > 0 && scrolled < opt.ScrollMax {
fmt.Printf("[*] card need local #%d (%d on screen) — scrolling\n", target, last)
_ = c.Swipe(opt.SwipeX, opt.SwipeFromY, opt.SwipeX, opt.SwipeToY, opt.SwipeMS)
scrolled++
if target > 0 {
target--
}
time.Sleep(1200 * time.Millisecond)
continue
}
}
time.Sleep(800 * time.Millisecond)
}
return nil, fmt.Errorf("only saw %d cards (need index %d)", last, index)
}
// TapCard waits for and taps the index-th content card.
func TapCard(c *adb.Client, cacheDir string, index int, timeout time.Duration, opt CardOpts) error {
card, err := WaitCard(c, cacheDir, index, timeout, opt)
if err != nil {
return err
}
fmt.Printf("[*] tap card #%d @ %d,%d\n", index, card.CX(), card.CY())
return c.Tap(card.CX(), card.CY())
}
// MonkeyLaunch starts an app through its launcher intent.
func MonkeyLaunch(c *adb.Client, pkg string) error {
_, _, err := c.Run("shell", "monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1")
return err
}
func containsAny(s string, needles []string) bool {
for _, n := range needles {
if n != "" && strings.Contains(s, n) {
return true
}
}
return false
}
func containsAnyFold(s string, needles []string) bool {
ls := strings.ToLower(s)
for _, n := range needles {
if n != "" && strings.Contains(ls, strings.ToLower(n)) {
return true
}
}
return false
}
func sortByTop(cards []adb.Node) {
for i := 0; i < len(cards); i++ {
for j := i + 1; j < len(cards); j++ {
if cards[j].Y1 < cards[i].Y1 {
cards[i], cards[j] = cards[j], cards[i]
}
}
}
}

128
apps/pkg/wvkey/wvkey.go Normal file
View file

@ -0,0 +1,128 @@
package wvkey
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Options for the Python wvkey.py helper.
type Options struct {
Python string // e.g. .venv/Scripts/python.exe
Script string // path to wvkey.py
WVD string
PSSH string
Auth string
PID string
LicenseURL string
UserAgent string
}
// Fetch runs wvkey.py --quiet (ModularDrm) and returns the first KID:KEY line.
func Fetch(opt Options) (string, error) {
lines, err := run(opt, false, false)
if err != nil {
return "", err
}
if len(lines) == 0 {
return "", fmt.Errorf("wvkey produced no KID:KEY")
}
return lines[0], nil
}
// FetchRaw runs wvkey.py --mode raw (Brightcove / octet-stream license).
func FetchRaw(opt Options) (string, error) {
lines, err := run(opt, true, false)
if err != nil {
return "", err
}
if len(lines) == 0 {
return "", fmt.Errorf("wvkey produced no KID:KEY")
}
return lines[0], nil
}
// FetchRawAll returns every CONTENT key from a raw license response.
func FetchRawAll(opt Options) ([]string, error) {
return run(opt, true, true)
}
func run(opt Options, raw, all bool) ([]string, error) {
py := opt.Python
if py == "" {
py = findPython()
}
script := opt.Script
if script == "" {
return nil, fmt.Errorf("wvkey script path required")
}
if strings.TrimSpace(opt.WVD) == "" {
return nil, fmt.Errorf("wvkey --wvd path required")
}
if strings.TrimSpace(opt.PSSH) == "" {
return nil, fmt.Errorf("wvkey --pssh required")
}
if strings.TrimSpace(opt.LicenseURL) == "" {
return nil, fmt.Errorf("wvkey --license-url required")
}
args := []string{
script,
"--wvd", opt.WVD,
"--pssh", opt.PSSH,
"--license-url", opt.LicenseURL,
"--quiet",
}
if raw {
args = append(args, "--mode", "raw")
} else {
args = append(args, "--mode", "modulardrm", "--auth", opt.Auth, "--pid", opt.PID)
}
if all {
args = append(args, "--all")
}
if opt.UserAgent != "" {
args = append(args, "--user-agent", opt.UserAgent)
}
cmd := exec.Command(py, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("wvkey failed: %w\n%s", err, strings.TrimSpace(stderr.String()))
}
var out []string
for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
line = strings.TrimSpace(line)
if strings.Count(line, ":") == 1 && len(line) > 40 {
out = append(out, line)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("wvkey produced no KID:KEY (stderr=%s)", strings.TrimSpace(stderr.String()))
}
return out, nil
}
func findPython() string {
candidates := []string{
filepath.Join(".venv", "Scripts", "python.exe"),
filepath.Join(".venv", "bin", "python"),
"python3",
"python",
}
for _, c := range candidates {
if filepath.IsAbs(c) || strings.Contains(c, string(os.PathSeparator)) {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
continue
}
if p, err := exec.LookPath(c); err == nil {
return p
}
}
return "python"
}