# Writing an app module A module teaches the binary how to drive one streaming app. It is a Go package, it is compiled in, and it contains no secrets. Work through [capture.md](capture.md) first — you need the app's package id, license URL, manifest host and UI selectors before you can write any of this. ## Anatomy ```text apps/modules/myapp/ config.go Config struct + defaults + flag binding myapp.go init(), the App type, Launch, AutoPlay, capability methods steps.go navigation idioms specific to this app catalog.go optional: resolve channels without a phone mpd.go optional: manifest rewriting module.yaml GITIGNORED - your values ``` Everything is registered from `init()`, and one line in `apps/modules/all/all.go` links it into the binary. ## 1. Config Embed `provider.Common` for the fields every module has, then add your own. ```go package myapp import ( "drmdecryption/modcfg" "drmdecryption/modules/provider" ) const Name = "myapp" type Channel struct { Label string `yaml:"label"` LiveCardIndex int `yaml:"live_card_index"` PlayDesc []string `yaml:"play_desc"` } type Config struct { provider.Common `yaml:",inline"` // Provider values. Never give these defaults in code. APIBase string `yaml:"api_base"` Channels map[string]Channel `yaml:"channels"` Timeouts Timeouts `yaml:"timeouts"` UI UI `yaml:"ui"` } type Timeouts struct { // provider.Duration accepts "25s" and bare seconds; time.Duration alone // would force raw nanoseconds in YAML. LaunchWaitUI provider.Duration `yaml:"launch_wait_ui"` Playback provider.Duration `yaml:"playback"` } type UI struct { LaunchResourceContains []string `yaml:"launch_resource_contains"` LaunchDescContains []string `yaml:"launch_desc_contains"` } func (c Config) withDefaults() Config { t := &c.Timeouts t.LaunchWaitUI = provider.Duration(t.LaunchWaitUI.D(25 * time.Second)) t.Playback = provider.Duration(t.Playback.D(45 * time.Second)) return c } ``` `provider.Common` gives you `package`, `license_url`, `proxy_bin`, `ca_hash`, `aliases` and `score`. ### Loading and overrides ```go var flags struct { packageName string apiBase string } func loadConfig() (Config, modcfg.Result, error) { var cfg Config res, err := modcfg.Load(Name, &cfg) // reads apps/modules/myapp/module.yaml if err != nil { return cfg, res, err } cfg.Package = modcfg.Override(Name, "package", flags.packageName, cfg.Package) cfg.APIBase = modcfg.Override(Name, "api_base", flags.apiBase, cfg.APIBase) return cfg.withDefaults(), res, nil } ``` `modcfg.Override` applies **flag → environment (`MYAPP_API_BASE`) → file**. A missing `module.yaml` is not an error: the module must still construct so `drm modules` can report it as unconfigured. ## 2. The App type ```go func init() { appreg.Register(Name, New) appreg.RegisterFlags(bindFlags) } func bindFlags(fs *flag.FlagSet) { fs.StringVar(&flags.packageName, Name+".package", "", "override "+Name+" package id") fs.StringVar(&flags.apiBase, Name+".api-base", "", "override "+Name+" API base") } type App struct { *provider.Base cfg Config } func New() (appreg.App, error) { cfg, res, err := loadConfig() if err != nil { return nil, err } labels := map[string]string{} for id, ch := range cfg.Channels { labels[id] = ch.Label } return &App{Base: provider.New(Name, cfg.Common, labels, res), cfg: cfg}, nil } ``` Flag binding is a separate hook from construction on purpose: flags must be bound to the config holder *before* any app is built, or overrides would be read too late. `provider.Base` supplies `Name`, `Package`, `LicenseURL`, `ProxyBin`, `CAHash`, `HasChannel`, `Label`, `ChannelIDs`, `CacheDir`, alias-aware `Resolve`, and `Hints(require…)` preloaded with your `score:` config. ### Required methods ```go func (a *App) KeyMode() string { return "raw" } // or "modulardrm" func (a *App) CaptureHints() capture.Hints { return a.Hints("license_url", "mpd") // fields the MITM must yield } func (a *App) MPDRewriter() mpd.Rewriter { return mpd.Passthrough{} } func (a *App) Channels() []appreg.Channel { out := make([]appreg.Channel, 0, len(a.cfg.Channels)) for _, id := range a.ChannelIDs() { out = append(out, appreg.Channel{ID: id, Label: a.Label(id)}) } return out } ``` `Hints(...)` names the JSON keys that must be present before a capture counts as complete. Common sets: | Field | Meaning | |---|---| | `mpd` | manifest URL (DASH or HLS — the field name is historical) | | `license_url` | license endpoint seen on the wire | | `pssh` | Widevine init data | | `auth` | Authorization header value | | `pid` | provider-specific id (e.g. a release/program id) | Ask only for what the app actually produces. Requiring `pssh` from an HLS app that puts it in the playlist will hang the capture. ## 3. Launch and AutoPlay ```go func (a *App) Launch(c *adb.Client) error { c.EnsureAwake() c.ForceStop(a.Package()) time.Sleep(400 * time.Millisecond) if err := uiflow.MonkeyLaunch(c, a.Package()); err != nil { return err } time.Sleep(time.Duration(a.cfg.Timeouts.LaunchSettle)) c.DismissShadeIfFocused() return uiflow.WaitUI(c, a.CacheDir(), uiflow.Match{ ResourceContains: a.cfg.UI.LaunchResourceContains, DescContains: a.cfg.UI.LaunchDescContains, }, time.Duration(a.cfg.Timeouts.LaunchWaitUI)) } func (a *App) AutoPlay(c *adb.Client, channel string) error { id, err := a.Base.Resolve(channel) if err != nil { return err } ch := a.cfg.Channels[id] if err := a.openLivePage(c); err != nil { return err } if err := uiflow.TapCard(c, a.CacheDir(), ch.LiveCardIndex, time.Duration(a.cfg.Timeouts.Card), a.cfg.Cards); err != nil { return err } return uiflow.WaitPlayback(c, a.CacheDir(), a.Package(), uiflow.PlaybackOpts{ Timeout: time.Duration(a.cfg.Timeouts.Playback), SoftFail: true, OrResourceContains: a.cfg.UI.PlaybackResourceContains, }) } ``` A `Launch` error is logged and tolerated by the capture pipeline, so pick a launch marker that genuinely exists — otherwise every run silently burns the full timeout before `AutoPlay` recovers. **Prefer resource ids over descriptions**: ids are stable and not localized. Dump the real tree to find them: ```bash adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xml ``` ### uiflow | Function | Use | |---|---| | `WaitUI(c, cache, Match, timeout)` | block until a node exists | | `TapUI(c, cache, Match, timeout)` | wait, then tap its centre | | `Find(c, cache, Match, timeout)` | get the node for custom handling | | `WaitPlayback(c, cache, pkg, PlaybackOpts)` | audio session, or a UI fallback signal | | `Cards(nodes, CardOpts)` | content rows in a dump, top to bottom | | `WaitCard` / `TapCard` | the nth content row, scrolling if needed | | `MonkeyLaunch(c, pkg)` | launcher-intent start | | `CompileRes([]string)` | case-insensitive regexes | `Match` combines `TextRE`, `DescRE`, `TextContains`, `DescContains`, `ResourceContains`, plus `Pick` (`largest`/`smallest`/`first`) and `MinArea`. `WaitPlayback` normally confirms via the audio session. Players that start without one need `SoftFail: true` plus a UI signal (`OrDescContains`, `OrResourceContains`) so capture proceeds anyway. `CardOpts` carries geometry (`MinY`, `MinH`, `MinW`) and denylists (`DescDeny`, `TextDeny`) so chrome is not mistaken for content. Defaults suit a 1080-wide phone; put real values in `module.yaml` if your app differs. ## 4. Optional: catalog Implement `app.Catalog` and `drm catalog --app myapp` starts working. ```go func (a *App) Resolve(channel string) (session.Stream, error) { id, err := a.Base.Resolve(channel) // ... hit the provider API, pick a manifest + license URL ... return session.Stream{ Name: Name + "-" + id, App: Name, Channel: id, MPD: manifestURL, LicenseURL: licenseURL, HeadersJSON: "{}", Rewriter: "none", ManifestType: "hls", }, nil } func (a *App) EnrichWithKeys(info *session.Stream, python, wvd string) error { body, err := brightcove.Get(info.MPD) // ... extract PSSH, then wvkey.FetchRawAll ... } ``` For a nicer `--list`, also implement `app.CatalogLister` returning `[]appreg.CatalogRow`. Decode remote config generically. Do not model a provider's JSON field names as Go struct fields — read into `map[string]any` and let `module.yaml` name the fields: ```yaml channels: main: token_field: liveLoggedOutTokenIOI stream_id_field: irelandStreamId ``` A renamed upstream field then needs an edit to your values file, not a rebuild. ## 5. Optional: manifest rewriting Only needed when the captured manifest expires faster than the stream. ```go func init() { mpd.Register(Name, func() mpd.Rewriter { return NewRewriter() }) } func (r *Rewriter) Name() string { return Name } func (r *Rewriter) Rewrite(upstreamXML []byte, kidHint string) ([]byte, error) { // identify the origin channel from the manifest or the KID, then: return mpd.BuildSynthetic(r.cfg.Synthetic, channel, originBase, kid) } // Optional: recover the origin when upstream is gone and only the KID remains. func (r *Rewriter) ResolveKID(kidHex string) (channel, originBase string, ok bool) func (r *Rewriter) Prime(channel, originBase string) ``` Register a **factory**, not an instance — a rewriter may hold per-stream state. ## 6. Optional: streamd settings ```go func (a *App) VideoSelect() string { return "for=best" } func (a *App) AudioSelect() string { return "lang=en:for=best" } func (a *App) RewriterName() string { return Name } // or "none" func (a *App) LiveWaitSeconds() int { return 6 } func (a *App) TSReadyBytes() int64 { return 2 << 20 } ``` Without this, streamd uses neutral defaults. This is what replaced guessing from app and stream names. ## 7. Link it in ```go // apps/modules/all/all.go import ( _ "drmdecryption/modules/myapp" _ "drmdecryption/modules/rte" _ "drmdecryption/modules/tg4" ) ``` ```bash go -C apps/cli build -o ../../bin/drm . ./bin/drm modules ``` ## 8. Test it Assert the interfaces at compile time and prove no secrets leaked: ```go var ( _ appreg.App = (*App)(nil) _ appreg.Catalog = (*App)(nil) _ appreg.StreamDefaults = (*App)(nil) ) func TestNoHardcodedCredentials(t *testing.T) { cfg := Config{}.withDefaults() if cfg.Package != "" || cfg.APIBase != "" { t.Fatal("provider values must come from module.yaml") } } ``` `apps/modules/all/all_test.go` already checks every registered module constructs with no values file present, reports a valid key mode, and — if it declares a rewriter name — actually registered one. For anything needing the live service or your local values, use a build tag so the normal run stays hermetic: ```go //go:build live ``` ```bash go -C apps/modules test -tags live ./myapp/ -v ``` ## Checklist - [ ] No account id, key, token, video id, hostname or KID in any `.go` file - [ ] `module.yaml` is gitignored (`git check-ignore -v apps/modules/myapp/module.yaml`) - [ ] `New()` succeeds with no `module.yaml` - [ ] Launch marker actually matches — watch for a silent full-timeout wait - [ ] `CaptureHints` asks only for fields the app really produces - [ ] `KeyMode` is correct (`raw` for binary challenges, `modulardrm` for JSON) - [ ] Added to `all/all.go` - [ ] `drm modules` lists it with the right channel count