I found some documentation in the Chromium source code:
// Zoom can be defined at three levels: default zoom, zoom for host, and zoom
// for host with specific scheme. Setting any of the levels leaves settings
// for other settings intact. Getting the zoom level starts at the most
// specific setting and progresses to the less specific: first the zoom for the
// host and scheme pair is checked, secondly the zoom for the host only and
// lastly default zoom.
And in zoom_controller.cc it seems like it just uses the scheme/host from the URL:
GURL url = content::HostZoomMap::GetURLFromEntry(entry);
std::string host = net::GetHostOrSpecFromURL(url);
if (zoom_map->HasZoomLevel(url.scheme(), host)) {
// If there are other tabs with the same origin, then set this tab's
// zoom level to match theirs. The temporary zoom level will be
// cleared below, but this call will make sure this tab re-draws at
// the correct zoom level.
double origin_zoom_level =
zoom_map->GetZoomLevelForHostAndScheme(url.scheme(), host);
And
std::string GetHostOrSpecFromURL(const GURL& url) {
return url.has_host() ? TrimEndingDot(url.host_piece()) : url.spec();
}
url.spec() actually returns the entire URL, which suggests to me that if I browse file:// URLs they'll get separate zoom levels. I verified this experimentally and it does seem to be the case.
In any case I figured out what was happening in my case - I was running in development mode which uses the WebPack dev server. In that case all files are served from localhost so they always get the same zoom.
However in production using the app:// protocol my code was setting the host to . so URLs were like app://./index.html. The host was actually ignored by the custom protocol handler, so to give windows separate origins you can just make up a fake hostname for them, like app://main/index.html or app://help/help.html. Seems to work perfectly.