← All work

Frostify

An Android music player with no backend of its own

Role
Sole developer - Rust request layer, Vue client, native Android integration, release engineering
Period
2026 - present
Status
In development
Stack
Rust · Tauri 2 · Vue · TypeScript · Kotlin · SQLite · Shaka Player
28
address ranges refused
0
servers I operate
0
camera, mic or location permissions
5
audio sources, no account on any

Search and play music from SoundCloud, Audius, Bandcamp, the Internet Archive and Jamendo, with no account and no server of mine in between - every request originates from the user’s own device, and the app asks for almost no permissions to do it.

01No backend, and what that costs

There is no Frostify server. Requests go from the phone to the provider directly, which means no relay holds a listening history, there is nothing to subpoena, nothing to breach, and no hosting bill that grows with adoption.

It also means rate limits and regional availability are the user’s and not mine, which the project documents instead of hiding. Free tracks play as progressive audio in the WebView; SoundCloud’s protected catalogue plays through the device’s own Widevine CDM using the standard EME flow via Shaka Player. Nothing here circumvents DRM - it uses the licensed path, which is more work and the only version that can carry a company name.

02The Rust layer is a boundary, not a proxy

A WebView cannot make some provider requests because of CORS, so a Rust HTTP layer makes them instead. That is a request-forwarding component inside a consumer app, which is the classic shape of a server-side request forgery hole, so it is written as a boundary with rules rather than as a convenience.

Initial URLs are restricted to approved provider domains. HTTPS on the standard port only. Embedded URL credentials rejected, only GET, HEAD and POST accepted, fixed request and response size limits, sensitive headers stripped, and errors that never hand a full URL back to the UI.

The address filter is not a loopback check. Twenty-eight ranges are refused across both families: the private and loopback blocks, and also carrier-grade NAT, link-local, the three documentation networks, the benchmarking range, the 6to4 relay anycast address, IETF protocol assignments, unique-local and site-local IPv6, the discard prefix and segment-routing space. An IPv4-mapped IPv6 address is unwrapped and checked as IPv4, and so is the IPv4 embedded in a NAT64 address - the two places this kind of filter is usually walked around.

The filter sits inside the resolver rather than in front of it. A custom DNS resolver drops every non-public address a name resolves to and fails the request outright if nothing public is left, so there is no gap between the check and the connection for the answer to change underneath it.

A redirect has to pass the transport rules and land back on a provider domain. Audius is the exception, because it resolves a stream by delegating to an independently operated content node - and the URL the chain started on decides whether that is allowed, so a provider open redirect cannot hand the layer to an unrelated host.

The rule written into the project is that this boundary is not weakened to fix a provider integration. If a provider needs a domain, the domain and its tests get added.

The only path out of the app, and the rules on itThe WebView, the Widevine CDM, the Kotlin media session and the on-device database all sit on the phone. A Rust HTTP layer is the only component that makes outbound requests, and it enforces a fixed set of rules: approved provider domains, HTTPS on the standard port, GET, HEAD and POST only, no credentials in the URL, size limits, stripped sensitive headers, a DNS resolver that returns only public addresses so a name with none fails outright, twenty-eight refused ranges across both address families, and redirects re-checked against the same rules. There is no Frostify server anywhere on the path.THE PHONEWebView UIprogressive audioEME · Widevinethe device’s own CDMMediaSessionbackground · lockscreenBluetooth · Android AutoSQLitelikes · playlistslistening historyRust HTTP layerthe only thing here that opens a connectionThe rules on itapproved provider domains onlyHTTPS, standard portGET · HEAD · POSTno credentials in the URLfixed request and response limitssensitive headers strippedresolver returns public IPs only28 ranges refused, both familiesCGNAT · 6to4 · NAT64 unwrappedno public address, no requestredirects re-checked, not trustederrors never hand a full URL backTHE PROVIDERSFive sourcesaudio and metadata, directLRCLIB for synced lyricsrate limits and regional reachare the user’s, and documentedThere is no Frostify server on this diagram, because there is noneno relay holds a listening history · nothing to subpoena · nothing to breach · no bill that grows with adoption
The rule written into the project is that this boundary does not get relaxed to make a provider integration work. If a provider needs a domain, the domain and its tests get added instead.

03The native half

A Kotlin MediaSession and foreground service give background playback, notification and lockscreen controls, Bluetooth and Android Auto - the parts that decide whether a music player is usable at all, and none of which a WebView can provide.

The media browser validates the calling package and UID through AndroidX before it returns anything, because Android Auto browsing is an interface any app on the device can ask to speak to. Internal service actions require the app’s own token, and artwork downloads stay HTTPS-only, provider-scoped, redirect-checked and bounded in time and size.

On the device: SQLite for likes, playlists and listening history, WebView storage for settings and playback position, LRCLIB for time-synced lyrics, and responsive layouts for phones and tablets.

04Shipping it

The manifest is treated as a permission budget: backups off, cleartext traffic disabled, no camera, microphone, location or storage permission, no FileProvider, no deep link, and no notification permission beyond the media-session exemption. An Android music player has no business asking for any of those.

A release is not finished when the build passes. The exact signed artifact goes onto physical devices, its signature is verified, its checksum published, and an upgrade from the previous public version is tested for data loss - and background playback, the media notification, lockscreen and Bluetooth controls, Android Auto and Widevine playback are all exercised before it goes out.

05Decisions worth explaining

The address check belongs inside the resolver

Checking a hostname and then connecting to it are two operations, and between them the answer can change - which is the whole of DNS rebinding. So the filter is not a step before the request; it is a DNS resolver that returns only public addresses and errors when a name has none. The request cannot reach an address the resolver never handed out, which removes the race instead of narrowing it.

The permissions an app does not ask for

Every permission in the manifest is one a reviewer, a store listing and a privacy-minded user has to be convinced about. Keeping the list at almost nothing means most of those conversations never happen, and it disciplines the design: a feature that would need location access has to justify itself against the whole product, not against a checkbox.

Naming what the providers might not like

The project documents that SoundCloud’s unofficial API and Bandcamp page parsing may conflict with those services’ terms, while Audius, the Internet Archive and Jamendo expose public APIs. Writing that down is uncomfortable and correct; a music app that implies every source is blessed is the one that surprises its users later.

Questions about this project?

I am happy to walk through the architecture, the parts that did not work, or the code itself.