play

package
v0.9.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 31, 2026 License: GPL-3.0 Imports: 27 Imported by: 0

Documentation

Index

Constants

View Source
const DefaultDevice = "px_9a"

DefaultDevice is a Pixel 9a profile: SDK 35, arm64-v8a only, 420dpi.

Two reasons for this one. The SDK level has to stay ahead of the minSdk of whatever is being fetched, or Play declines to serve at all. And listing arm64-v8a as the sole ABI removes any ambiguity about which split_config.<abi>.apk comes back -- an armeabi-v7a fallback in the list is a way to silently receive the wrong native library.

View Source
const DispenserUserAgent = "com.aurora.store"

DispenserUserAgent is what the reference dispenser expects. It sits behind Cloudflare and answers 403 to anything else -- the endpoint exists to serve Aurora Store clients, so it only talks to one. Override with -dispenser-ua when pointing at a self-hosted dispenser that does not care.

View Source
const EmbeddedSetupURL = "https://accounts.google.com/EmbeddedSetup"

EmbeddedSetupURL is where the one-time OAuth token is minted. It is the page an Android device opens when you add a Google account during setup, which is why it hands out a token usable for the account-manager APIs.

View Source
const ManifestSchema = "playfetch/v1"

ManifestSchema is bumped when the on-disk shape changes incompatibly.

View Source
const OAuthCookieName = "oauth_token"

OAuthCookieName is the cookie to read from that page after signing in.

Variables

View Source
var DefaultDispensers = []string{
	"https://auroraoss.com/api/auth",
}

DefaultDispensers is tried in order. Override with --dispenser or the PLAYFETCH_DISPENSER environment variable.

Functions

func AccountTag

func AccountTag(email string) string

AccountTag identifies which account a cached session belongs to, so a session minted for one identity is never silently reused for another. Anonymous runs share a single tag because any pool account is equivalent.

func CredentialsPath

func CredentialsPath() string

CredentialsPath is where accounts are stored. Override with PLAYFETCH_CREDENTIALS.

func DefaultSessionPath

func DefaultSessionPath(d *Device, accountTag string) string

DefaultSessionPath keys the cache by profile, locale and account for the same reason.

func DescribeCredentials

func DescribeCredentials(path string) string

DescribeCredentials is the one-line summary shown by doctor and the prompt.

func ExchangeOAuthToken

func ExchangeOAuthToken(ctx context.Context, hc *http.Client, d *Device, email, pastedToken string) (string, error)

ExchangeOAuthToken turns the single-use `oauth2_4/...` value from the embedded-setup page into a long-lived AAS token.

The token really is single-use: a failure here means signing in again, so the error says so rather than suggesting a retry.

func ExtractOAuthToken

func ExtractOAuthToken(pasted string) string

ExtractOAuthToken pulls the token out of arbitrary pasted text.

All of these work:

oauth2_4/0AQlEd8x…
oauth_token=oauth2_4/0AQlEd8x…; __Host-GAPS=1:abc
set-cookie: oauth_token=oauth2_4/0AQlEd8x…; Path=/; Secure; HttpOnly
"oauth_token": "oauth2_4/0AQlEd8x…"

A trailing semicolon, quote or comma is stripped, since every one of those shows up in a real copy-paste.

func ListDevices

func ListDevices() ([]string, error)

ListDevices returns one human-readable line per bundled profile, newest Android level first.

func NormalizeLocale

func NormalizeLocale(s string) (string, error)

NormalizeLocale canonicalises a locale to the ll_CC form Play expects.

Play silently ignores a malformed locale rather than complaining, so `-locale en_us` looks like it worked and changes nothing. Accepting the spellings people actually type, and rejecting the rest loudly, is better than passing garbage through.

func OpenBrowser

func OpenBrowser(u string) error

OpenBrowser asks the desktop to open a URL. A failure is not fatal anywhere it is used: the caller prints the URL so the user can open it by hand.

func RedactEmail

func RedactEmail(email string) string

RedactEmail keeps enough of an address to recognise which account is in use without printing the whole thing into logs or CI output.

func Retryable

func Retryable(err error) bool

Retryable reports whether an error is worth another attempt. Play's download hosts return 503 and 429 under load often enough that a single-shot fetch of a 100 MiB split is unreliable.

func SHA256File

func SHA256File(path string) (string, int64, error)

SHA256File digests a file already on disk.

func SaveSession

func SaveSession(path string, s *Session) error

SaveSession best-effort persists the session; a failure here is not fatal to the fetch that produced it.

func Verify

func Verify(rec *FileRecord, expectedSize int64)

Verify compares local digests against whatever Play published and fills in rec.Verified / rec.VerifyNote.

Types

type Account

type Account struct {
	// Name is how the account is selected on the command line. Unique.
	Name     string `json:"name"`
	Email    string `json:"email"`
	AasToken string `json:"aas_token"`

	// Region is a label you assign, e.g. "JP". Nothing verifies it.
	Region string `json:"region,omitempty"`

	// Currency is the storefront currency Play itself reported when the
	// account was added -- observed, not assumed. It is the closest thing to a
	// trustworthy answer to "which catalogue does this account see", since Play
	// exposes no country field. JPY means a Japanese storefront; EUR narrows it
	// only to the eurozone.
	Currency string `json:"currency,omitempty"`

	AddedAt time.Time `json:"added_at"`
}

Account is one stored Google account.

func (*Account) Describe

func (a *Account) Describe() string

Describe renders an account for humans, with the address redacted.

type AppInfo

type AppInfo struct {
	Package     string   `json:"package"`
	Title       string   `json:"title"`
	Developer   string   `json:"developer"`
	VersionCode int32    `json:"version_code"`
	VersionName string   `json:"version_name"`
	UploadDate  string   `json:"upload_date"`
	InstallSize int64    `json:"install_size"`
	TargetSDK   int32    `json:"target_sdk"`
	SplitIDs    []string `json:"split_ids,omitempty"`
	CertHashes  []string `json:"certificate_hashes,omitempty"`
	Free        bool     `json:"free"`
	OfferType   int32    `json:"offer_type"`

	// Currency is the storefront currency Play quoted for this account. Play
	// exposes no country field, so this is the closest available answer to
	// "which catalogue am I looking at" -- and it is the account's storefront,
	// not the app's home market: a JP account is quoted JPY for every app.
	Currency string `json:"currency,omitempty"`
}

AppInfo is the subset of Play's details response worth recording.

type AuthOptions

type AuthOptions struct {
	SessionPath string   // where to cache; "" disables caching
	Dispensers  []string // used when Email is empty
	Email       string   // manual account
	AasToken    string   // long-lived; exchanged for an AUTH token
	AuthToken   string   // short-lived; used as-is
	Refresh     bool     // ignore any cached session

	// DispenserUA overrides the User-Agent used when talking to a dispenser.
	// Empty means DispenserUserAgent.
	DispenserUA string

	// AccountTag identifies the intended identity for session-cache purposes.
	// Empty means anonymous.
	AccountTag string

	// AcceptTos accepts Play's terms of service for the account if it has not
	// already done so. A brand-new account can otherwise be refused delivery.
	// Off by default: it consents to something on an account's behalf, which
	// should be a deliberate act rather than a side effect.
	AcceptTos bool
}

AuthOptions selects how Authenticate obtains credentials.

type BundleInfo

type BundleInfo struct {
	Name   string `json:"name"`
	Size   int64  `json:"size"`
	SHA256 string `json:"sha256"`
}

BundleInfo describes the optional packed .apks archive.

func PackAPKS

func PackAPKS(dir string, files []FileRecord, outPath string) (*BundleInfo, error)

PackAPKS bundles the APKs into a single zip, the layout SAI and bundletool accept. OBB files are left out: they are not part of an .apks archive.

type Client

type Client struct {
	HTTP    *http.Client
	Device  *Device
	Session *Session

	// Verbose dumps one line per API call to stderr when set.
	Verbose func(format string, args ...any)
}

Client talks to fdfe as one fake device with one set of credentials.

func NewClient

func NewClient(d *Device, hc *http.Client) *Client

NewClient does not perform any network I/O; call Authenticate first.

func (*Client) AcceptTos

func (c *Client) AcceptTos(ctx context.Context) error

AcceptTos accepts Play's terms of service for the current account, if it has not already. Fresh accounts -- including ones just handed out by a dispenser -- can be refused delivery until this happens.

The marketing-email opt-in is explicitly declined.

func (*Client) Authenticate

func (c *Client) Authenticate(ctx context.Context, opts AuthOptions) error

Authenticate reuses a cached session when one exists for this exact device identity, otherwise builds a new one.

func (*Client) DebugGet

func (c *Client) DebugGet(ctx context.Context, endpoint string, kv ...string) (*gplay.ResponseWrapper, error)

DebugGet performs an arbitrary fdfe GET and returns the decoded envelope. Diagnostics only: it is how the shape of an unfamiliar endpoint gets figured out without guessing at the schema.

func (*Client) Delivery

func (c *Client) Delivery(ctx context.Context, pkg string, versionCode int32, offerType int32) (*Delivery, error)

Delivery resolves a versionCode to concrete URLs and hashes. Free apps must be "acquired" first, so a purchase call is attempted when plain delivery comes back empty.

func (*Client) Details

func (c *Client) Details(ctx context.Context, pkg string) (*AppInfo, error)

Details fetches metadata without acquiring or downloading anything.

func (*Client) Fetch

func (c *Client) Fetch(ctx context.Context, f *RemoteFile, w io.Writer) (int64, error)

Fetch streams one remote file through w, returning the bytes written. Verification is the caller's job -- see hashing in manifest.go.

func (*Client) Search

func (c *Client) Search(ctx context.Context, query string) ([]SearchHit, error)

Search returns app results for a free-text query, deduplicated by package.

type Credentials

type Credentials struct {
	Email  string `json:"email"`
	Token  string `json:"token"`
	Kind   string `json:"kind"`   // "auth" or "aas"
	Origin string `json:"origin"` // dispenser URL, or "manual"
}

Credentials is an email plus a token of a known kind.

func FetchAnonymous

func FetchAnonymous(ctx context.Context, hc *http.Client, dispensers []string, userAgent string) (*Credentials, error)

FetchAnonymous asks each dispenser in turn for a throwaway account, returning the first that answers with a token.

type Delivery

type Delivery struct {
	Package     string        `json:"package"`
	VersionCode int32         `json:"version_code"`
	Files       []*RemoteFile `json:"files"`
}

Delivery is the full download plan for one versionCode.

func (*Delivery) TotalSize

func (d *Delivery) TotalSize() int64

TotalSize is the number of bytes a full pull will transfer.

type Device

type Device struct {
	Name     string
	Locale   string
	Timezone string
	// contains filtered or unexported fields
}

Device is one fake handset: the .properties payload plus the locale and timezone we present to Play.

func LoadDevice

func LoadDevice(nameOrPath string, opts DeviceOptions) (*Device, error)

LoadDevice resolves nameOrPath against the bundled profiles first, then the filesystem, and applies opts on top.

func (*Device) AuthUserAgent

func (d *Device) AuthUserAgent() string

AuthUserAgent is what the Google account manager sends to the auth endpoint. It is a different identity from UserAgent, which impersonates the store.

func (*Device) CheckinRequest

func (d *Device) CheckinRequest() *gplay.AndroidCheckinRequest

CheckinRequest asks Google for a fresh GSF ID (the anonymous device identity). No account is attached, which is exactly what we want.

func (*Device) DeviceConfiguration

func (d *Device) DeviceConfiguration() *gplay.DeviceConfigurationProto

DeviceConfiguration is uploaded once per session; Play keys split selection off the values in here.

func (*Device) MccMnc

func (d *Device) MccMnc() string

MccMnc feeds the X-DFE-MCCMNC header.

func (*Device) Summary

func (d *Device) Summary() DeviceSummary

Summary is what gets embedded in a manifest.

func (*Device) UserAgent

func (d *Device) UserAgent() string

UserAgent is the Android-Finsky string the real Play Store sends.

type DeviceOptions

type DeviceOptions struct {
	Locale   string
	Timezone string
	SDK      int    // 0 = keep profile value
	ABIs     string // "" = keep; comma-separated, most-preferred first
	Density  int    // 0 = keep
}

DeviceOptions overrides individual profile keys without editing the file.

type DeviceSummary

type DeviceSummary struct {
	Profile     string   `json:"profile"`
	Model       string   `json:"model"`
	SDK         int      `json:"sdk"`
	Release     string   `json:"release"`
	ABIs        []string `json:"abis"`
	Density     int      `json:"density"`
	Screen      string   `json:"screen"`
	Locale      string   `json:"locale"`
	Timezone    string   `json:"timezone"`
	PlayVersion string   `json:"play_version"`
}

DeviceSummary is the machine-readable form recorded in manifests.

type DispenserError

type DispenserError struct {
	URL    string
	Status int
	Body   string
	Err    error
}

DispenserError distinguishes "the pool is exhausted / rate-limited" from a real bug, because it is by far the most common failure and the fix is different: wait, switch dispenser, or supply your own account.

func (*DispenserError) Error

func (e *DispenserError) Error() string

func (*DispenserError) Unwrap

func (e *DispenserError) Unwrap() error

type FileDigest

type FileDigest struct {
	Hashes Hashes
	Size   int64
}

FileDigest is the result of hashing bytes on disk.

func HashFile

func HashFile(path string) (FileDigest, error)

HashFile digests an existing file in one pass.

type FileRecord

type FileRecord struct {
	Role  FileRole `json:"role"`
	Name  string   `json:"name"`
	Split string   `json:"split,omitempty"`
	Size  int64    `json:"size"`

	// PlaySHA1/PlaySHA256 are Google's own digests, taken from the delivery
	// response. Empty means Play did not publish one for this file.
	PlaySHA1   string `json:"play_sha1,omitempty"`
	PlaySHA256 string `json:"play_sha256,omitempty"`

	Local Hashes `json:"local"`

	// Verified is true only when at least one Play-published digest existed and
	// every published digest matched. Unverified is not the same as corrupt --
	// see VerifyNote.
	Verified   bool   `json:"verified"`
	VerifyNote string `json:"verify_note,omitempty"`
}

FileRecord pairs what Play promised with what we received.

type FileRole

type FileRole string

FileRole distinguishes the pieces of a delivery.

const (
	RoleBase  FileRole = "base"
	RoleSplit FileRole = "split"
	RoleOBB   FileRole = "obb"
)

type HTTPError

type HTTPError struct {
	Endpoint string
	Status   int
	Body     string
}

HTTPError is a transport-level failure: the request never reached the API surface, so retrying with fresh credentials will not necessarily help.

func (*HTTPError) Error

func (e *HTTPError) Error() string

type Hashes

type Hashes struct {
	MD5    string `json:"md5"`
	SHA1   string `json:"sha1"`
	SHA256 string `json:"sha256"`
}

Hashes are the digests computed locally over the bytes on disk.

func FetchHashed

func FetchHashed(ctx context.Context, c *Client, f *RemoteFile, w io.Writer) (Hashes, int64, error)

FetchHashed streams a remote file into w while digesting it, so the bytes are read once whether they are being written to disk or discarded.

type Manifest

type Manifest struct {
	Schema    string        `json:"schema"`
	Label     string        `json:"label,omitempty"`
	FetchedAt time.Time     `json:"fetched_at"`
	Source    SourceInfo    `json:"source"`
	Device    DeviceSummary `json:"device"`
	App       *AppInfo      `json:"app"`
	Files     []FileRecord  `json:"files"`
	Bundle    *BundleInfo   `json:"bundle,omitempty"`
}

Manifest is written next to the downloaded files.

func ReadManifest

func ReadManifest(path string) (*Manifest, error)

ReadManifest loads a previously written manifest; used by `watch` to decide whether anything changed.

func (*Manifest) AllVerified

func (m *Manifest) AllVerified() bool

AllVerified reports whether every file matched a Play-published digest.

func (*Manifest) TotalSize

func (m *Manifest) TotalSize() int64

TotalSize is the sum of the downloaded artifacts.

func (*Manifest) Write

func (m *Manifest) Write(path string) error

Write serializes the manifest to path.

type NotAvailableError

type NotAvailableError struct {
	Package string
	Reason  string
}

NotAvailableError means the request succeeded but the package is not obtainable by this account on this device -- the usual outcome for a region-locked title fetched through an anonymous account registered in another country.

func (*NotAvailableError) Error

func (e *NotAvailableError) Error() string

type PlayError

type PlayError struct {
	Endpoint string
	Message  string
}

PlayError is an error Play itself reported, already localized to our requested locale. DF-DFERH-01 in particular is Google's catch-all and usually means expired credentials or stale DFE headers.

func (*PlayError) Error

func (e *PlayError) Error() string

type RemoteFile

type RemoteFile struct {
	Role       FileRole `json:"role"`
	Name       string   `json:"name"`
	Split      string   `json:"split,omitempty"`
	Size       int64    `json:"size"`
	PlaySHA1   string   `json:"play_sha1,omitempty"`
	PlaySHA256 string   `json:"play_sha256,omitempty"`
	// contains filtered or unexported fields
}

RemoteFile is one downloadable artifact plus the hashes Google publishes for it. Those hashes are the whole point: they let a download be verified against the store rather than against itself.

func (*RemoteFile) MarshalJSON

func (f *RemoteFile) MarshalJSON() ([]byte, error)

MarshalJSON keeps the signed URL out of serialized output by default.

func (*RemoteFile) URL

func (f *RemoteFile) URL() string

URL is exposed for diagnostics; it is signed and short-lived.

type SearchHit

type SearchHit struct {
	Package   string `json:"package"`
	Title     string `json:"title"`
	Developer string `json:"developer"`
}

Search is how you find the package name of a storefront variant -- e.g. a global release whose suffix differs from the JP one.

type Session

type Session struct {
	Email             string `json:"email"`
	AuthToken         string `json:"auth_token"`
	AasToken          string `json:"aas_token,omitempty"`
	Origin            string `json:"origin"`
	GsfID             uint64 `json:"gsf_id"`
	DeviceConfigToken string `json:"device_config_token"`
	ConsistencyToken  string `json:"consistency_token"`
	// Account is the identity this session was minted for: "anonymous" for a
	// dispenser account, or a tag derived from the email otherwise. Sessions are
	// never shared across identities -- reusing an anonymous session for a
	// signed-in run would silently query Play as the wrong account.
	Account       string    `json:"account"`
	DeviceProfile string    `json:"device_profile"`
	Locale        string    `json:"locale"`
	Timezone      string    `json:"timezone"`
	CreatedAt     time.Time `json:"created_at"`
}

Session is everything needed to talk to fdfe, cached between runs.

func LoadSession

func LoadSession(path string) *Session

LoadSession returns nil (no error) when the cache is absent or unusable.

func (*Session) GsfIDHex

func (s *Session) GsfIDHex() string

GsfIDHex is the form the X-DFE-Device-Id header wants.

func (*Session) Matches

func (s *Session) Matches(d *Device, accountTag string) bool

Matches reports whether a cached session was built for this exact device identity and account. A session created for another profile, locale or account would silently yield the wrong split set or query Play as somebody else, so it is discarded rather than reused.

type SourceInfo

type SourceInfo struct {
	Kind      string `json:"kind"`
	Dispenser string `json:"dispenser,omitempty"`
	Account   string `json:"account"`
}

SourceInfo records how the bytes were obtained.

type Store

type Store struct {
	Version  int       `json:"version"`
	Default  string    `json:"default,omitempty"`
	Accounts []Account `json:"accounts"`
}

Store is the credential file.

func LoadStore

func LoadStore(path string) *Store

LoadStore reads the credential file, upgrading a version-1 file on the way. A missing or unreadable file yields an empty store, not an error: anonymous mode still works, and `login` will write a fresh one.

func (*Store) Empty

func (s *Store) Empty() bool

Empty reports whether anything is stored.

func (*Store) Get

func (s *Store) Get(name string) *Account

Get returns the account with this exact name.

func (*Store) Put

func (s *Store) Put(a Account)

Put adds or replaces an account by name, and makes it the default when it is the only one.

func (*Store) Remove

func (s *Store) Remove(name string) bool

Remove deletes an account, moving the default if it pointed there.

func (*Store) Resolve

func (s *Store) Resolve(selector string) (*Account, error)

Resolve picks the account a command should use.

An empty selector means the default. Otherwise a name, an email address, or a region label -- whichever matches, in that order, because a name is the only one guaranteed unique.

func (*Store) Save

func (s *Store) Save(path string) error

Save writes the store with owner-only permissions.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL