Documentation
¶
Index ¶
- Constants
- Variables
- func AccountTag(email string) string
- func CredentialsPath() string
- func DefaultSessionPath(d *Device, accountTag string) string
- func DescribeCredentials(path string) string
- func ExchangeOAuthToken(ctx context.Context, hc *http.Client, d *Device, email, pastedToken string) (string, error)
- func ExtractOAuthToken(pasted string) string
- func ListDevices() ([]string, error)
- func NormalizeLocale(s string) (string, error)
- func OpenBrowser(u string) error
- func RedactEmail(email string) string
- func Retryable(err error) bool
- func SHA256File(path string) (string, int64, error)
- func SaveSession(path string, s *Session) error
- func Verify(rec *FileRecord, expectedSize int64)
- type Account
- type AppInfo
- type AuthOptions
- type BundleInfo
- type Client
- func (c *Client) AcceptTos(ctx context.Context) error
- func (c *Client) Authenticate(ctx context.Context, opts AuthOptions) error
- func (c *Client) DebugGet(ctx context.Context, endpoint string, kv ...string) (*gplay.ResponseWrapper, error)
- func (c *Client) Delivery(ctx context.Context, pkg string, versionCode int32, offerType int32) (*Delivery, error)
- func (c *Client) Details(ctx context.Context, pkg string) (*AppInfo, error)
- func (c *Client) Fetch(ctx context.Context, f *RemoteFile, w io.Writer) (int64, error)
- func (c *Client) Search(ctx context.Context, query string) ([]SearchHit, error)
- type Credentials
- type Delivery
- type Device
- type DeviceOptions
- type DeviceSummary
- type DispenserError
- type FileDigest
- type FileRecord
- type FileRole
- type HTTPError
- type Hashes
- type Manifest
- type NotAvailableError
- type PlayError
- type RemoteFile
- type SearchHit
- type Session
- type SourceInfo
- type Store
Constants ¶
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.
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.
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.
const ManifestSchema = "playfetch/v1"
ManifestSchema is bumped when the on-disk shape changes incompatibly.
const OAuthCookieName = "oauth_token"
OAuthCookieName is the cookie to read from that page after signing in.
Variables ¶
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 ¶
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 ¶
DefaultSessionPath keys the cache by profile, locale and account for the same reason.
func DescribeCredentials ¶
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 ¶
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 ¶
ListDevices returns one human-readable line per bundled profile, newest Android level first.
func NormalizeLocale ¶
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 ¶
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 ¶
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 ¶
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 ¶
SHA256File digests a file already on disk.
func SaveSession ¶
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.
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 (*Client) AcceptTos ¶
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.
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.
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 ¶
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) Summary ¶
func (d *Device) Summary() DeviceSummary
Summary is what gets embedded in a manifest.
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 ¶
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 ¶
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 HTTPError ¶
HTTPError is a transport-level failure: the request never reached the API surface, so retrying with fresh credentials will not necessarily help.
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.
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 ¶
ReadManifest loads a previously written manifest; used by `watch` to decide whether anything changed.
func (*Manifest) AllVerified ¶
AllVerified reports whether every file matched a Play-published digest.
type NotAvailableError ¶
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 ¶
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.
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 ¶
LoadSession returns nil (no error) when the cache is absent or unusable.
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 ¶
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) Put ¶
Put adds or replaces an account by name, and makes it the default when it is the only one.