diff --git a/server/cmd/auth-server/handlers.go b/server/cmd/auth-server/handlers.go index d71375b..e866d7b 100644 --- a/server/cmd/auth-server/handlers.go +++ b/server/cmd/auth-server/handlers.go @@ -18,6 +18,26 @@ var loginFS embed.FS var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html")) +// oauthRedirectTmpl drives the fbconnect:// hand-off via a renderer-initiated +// navigation instead of a server-side 302. Android WebView does NOT invoke +// WebViewClient.shouldOverrideUrlLoading for 302 redirects from POST form +// submissions to non-http schemes (documented Chromium WebView limitation, +// Stack Overflow #6738328 / Google issuetracker #36918490). Returning a 200 +// HTML page with both and window.location.replace() +// makes the cross-scheme navigation renderer-initiated, which DOES invoke +// shouldOverrideUrlLoading, so the FB SDK can extract access_token from the +// URL fragment and complete its login flow. html/template auto-escapes {{.}} +// correctly for the meta URL-attribute context and the JS string-literal +// context inside + + + +`)) + type Handlers struct { store *AuthStore tok *TokenService @@ -30,6 +50,7 @@ func NewHandlers(store *AuthStore, tok *TokenService) *Handlers { type loginPageData struct { RedirectURI string State string + Scope string Error string Username string } @@ -77,6 +98,7 @@ func (h *Handlers) oauthGet(w http.ResponseWriter, r *http.Request) { data := loginPageData{ RedirectURI: r.URL.Query().Get("redirect_uri"), State: r.URL.Query().Get("state"), + Scope: r.URL.Query().Get("scope"), } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := loginTmpl.Execute(w, data); err != nil { @@ -95,11 +117,13 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) { action := r.FormValue("action") redirectURI := r.FormValue("redirect_uri") state := r.FormValue("state") + scope := r.FormValue("scope") renderErr := func(msg string) { data := loginPageData{ RedirectURI: redirectURI, State: state, + Scope: scope, Error: msg, Username: username, } @@ -158,20 +182,31 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) { } payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID) - b64 := base64.StdEncoding.EncodeToString([]byte(payload)) + b64 := base64.RawURLEncoding.EncodeToString([]byte(payload)) fragment := url.Values{} fragment.Set("access_token", token) fragment.Set("token_type", "bearer") fragment.Set("expires_in", strconv.FormatInt(int64(tokenTTL.Seconds()), 10)) fragment.Set("signed_request", "0."+b64) + // iOS FBSDKLoginManager treats an empty granted_scopes set as a cancelled login + // (LoginManager.swift -> getSuccessResult -> getCancelledResult). Echo back the + // scope the SDK sent so parameters.permissions is non-empty and the SDK fires + // its success path. Android tolerates either way. + if scope != "" { + fragment.Set("granted_scopes", scope) + fragment.Set("denied_scopes", "") + } if state != "" { fragment.Set("state", state) } target := redirectURI + "?" + fragment.Encode() log.Printf("redirecting to %s", target) - http.Redirect(w, r, target, http.StatusFound) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := oauthRedirectTmpl.Execute(w, target); err != nil { + log.Printf("render oauth redirect: %v", err) + } } func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) { diff --git a/server/cmd/auth-server/login.html b/server/cmd/auth-server/login.html index 362ea9c..2dbb890 100644 --- a/server/cmd/auth-server/login.html +++ b/server/cmd/auth-server/login.html @@ -128,6 +128,7 @@ + diff --git a/server/cmd/wizard/main.go b/server/cmd/wizard/main.go index b4d9ea2..d70c63f 100644 --- a/server/cmd/wizard/main.go +++ b/server/cmd/wizard/main.go @@ -122,23 +122,62 @@ type assetCheck struct { var requiredAssets = []assetCheck{ {"assets", true}, {"assets/release/20240404193219.bin.e", false}, - {"assets/revisions/0/list.bin", false}, - {"assets/revisions/0/assetbundle", true}, - {"assets/revisions/0/resources", true}, +} + +// platformAssets holds the per-platform rev-0 layout. At least one full set +// must be present; users may have only Android or only iOS extracted. +var platformAssets = [][]assetCheck{ + { + {"assets/revisions/0/android/list.bin", false}, + {"assets/revisions/0/android/assetbundle", true}, + {"assets/revisions/0/android/resources", true}, + }, + { + {"assets/revisions/0/ios/list.bin", false}, + {"assets/revisions/0/ios/assetbundle", true}, + {"assets/revisions/0/ios/resources", true}, + }, +} + +func checkAsset(a assetCheck) (missing string, ok bool) { + info, err := os.Stat(a.path) + if err != nil { + return a.path, false + } + if a.dir && !info.IsDir() { + return a.path + string(filepath.Separator), false + } + if !a.dir && info.IsDir() { + return a.path, false + } + return "", true } func validateAssets() { var missing []string for _, a := range requiredAssets { - info, err := os.Stat(a.path) - if err != nil { - missing = append(missing, a.path) - continue + if m, ok := checkAsset(a); !ok { + missing = append(missing, m) } - if a.dir && !info.IsDir() { - missing = append(missing, a.path+string(filepath.Separator)) - } else if !a.dir && info.IsDir() { - missing = append(missing, a.path) + } + + var platformMissing [][]string + anyPlatformOK := false + for _, group := range platformAssets { + var groupMissing []string + for _, a := range group { + if m, ok := checkAsset(a); !ok { + groupMissing = append(groupMissing, m) + } + } + if len(groupMissing) == 0 { + anyPlatformOK = true + } + platformMissing = append(platformMissing, groupMissing) + } + if !anyPlatformOK { + for _, gm := range platformMissing { + missing = append(missing, gm...) } } @@ -161,6 +200,8 @@ func validateAssets() { b.WriteString("\n") b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again.")) b.WriteString("\n") + b.WriteString(dimStyle.Render(" At least one of assets/revisions/0/android/ or assets/revisions/0/ios/ must be fully present.")) + b.WriteString("\n") b.WriteString(dimStyle.Render(" Get them from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord: ") + hlStyle.Hyperlink("https://discord.com/invite/MZAf5aVkJG").Render("https://discord.com/invite/MZAf5aVkJG")) b.WriteString("\n") diff --git a/server/internal/service/asset_resolver.go b/server/internal/service/asset_resolver.go index fd5a5b5..c5edf57 100644 --- a/server/internal/service/asset_resolver.go +++ b/server/internal/service/asset_resolver.go @@ -70,32 +70,32 @@ func (t *revisionTracker) Active(clientAddr string) string { return revision } -func (r *assetResolver) Resolve(objectId, assetType, activeRevision string) (assetResolution, bool) { +func (r *assetResolver) Resolve(objectId, assetType, activeRevision, platform string) (assetResolution, bool) { start := time.Now() resolution := assetResolution{ActiveRevision: activeRevision} revision := activeRevision - candidates, listSize, ok := objectIdToFilePathCandidates(r.baseDir, revision, assetType, objectId) + candidates, listSize, ok := objectIdToFilePathCandidates(r.baseDir, revision, platform, assetType, objectId) if ok && len(candidates) > 0 { resolution.ListRevision = revision resolution.ListSize = listSize resolution.Candidates = candidates if elapsed := time.Since(start); elapsed > 100*time.Millisecond { - log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, activeRevision, revision, elapsed) + log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s platform=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, platform, activeRevision, revision, elapsed) } return resolution, true } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { - log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s active_revision=%s elapsed=%s", objectId, assetType, activeRevision, elapsed) + log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s platform=%s active_revision=%s elapsed=%s", objectId, assetType, platform, activeRevision, elapsed) } return resolution, false } -func (r *assetResolver) Prewarm(activeRevision string) { +func (r *assetResolver) Prewarm(activeRevision, platform string) { if activeRevision == "" { return } - _, _ = loadListBinIndex(r.baseDir, activeRevision) - _ = loadInfoIndex(r.baseDir, activeRevision) + _, _ = loadListBinIndex(r.baseDir, activeRevision, platform) + _ = loadInfoIndex(r.baseDir, activeRevision, platform) } diff --git a/server/internal/service/listbin.go b/server/internal/service/listbin.go index 5211c74..794d9ef 100644 --- a/server/internal/service/listbin.go +++ b/server/internal/service/listbin.go @@ -45,14 +45,21 @@ type infoLoad struct { } var ( - listBinCache = make(map[string]listBinIndex) // revision → index + listBinCache = make(map[string]listBinIndex) // revision/platform → index listBinInflight = make(map[string]*listBinLoad) listBinCacheMu sync.RWMutex - infoCache = make(map[string]map[string]infoAlias) // revision → from-name → duplicate target + infoCache = make(map[string]map[string]infoAlias) // revision/platform → from-name → duplicate target infoInflight = make(map[string]*infoLoad) infoCacheMu sync.RWMutex ) +func cacheKey(revision, platform string) string { + if platform == "" { + return revision + "/_shared" + } + return revision + "/" + platform +} + // infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name). type infoJSONEntry struct { FromName string `json:"from-name"` @@ -208,33 +215,34 @@ func parseListBin(data []byte) listBinIndex { return idx } -func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) { +func loadListBinIndex(baseDir, revision, platform string) (listBinIndex, bool) { + key := cacheKey(revision, platform) listBinCacheMu.RLock() - idx, ok := listBinCache[revision] + idx, ok := listBinCache[key] listBinCacheMu.RUnlock() if ok { return idx, true } listBinCacheMu.Lock() - if idx, ok := listBinCache[revision]; ok { + if idx, ok := listBinCache[key]; ok { listBinCacheMu.Unlock() return idx, true } - if load := listBinInflight[revision]; load != nil { + if load := listBinInflight[key]; load != nil { listBinCacheMu.Unlock() <-load.done return load.idx, load.ok } load := &listBinLoad{done: make(chan struct{})} - listBinInflight[revision] = load + listBinInflight[key] = load listBinCacheMu.Unlock() - filePath := filepath.Join(baseDir, "assets", "revisions", revision, "list.bin") + filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "list.bin") data, err := os.ReadFile(filePath) if err != nil { listBinCacheMu.Lock() - delete(listBinInflight, revision) + delete(listBinInflight, key) close(load.done) listBinCacheMu.Unlock() return nil, false @@ -243,41 +251,42 @@ func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) { load.idx = idx load.ok = true listBinCacheMu.Lock() - listBinCache[revision] = idx - delete(listBinInflight, revision) + listBinCache[key] = idx + delete(listBinInflight, key) close(load.done) listBinCacheMu.Unlock() return idx, true } -func loadInfoIndex(baseDir, revision string) map[string]infoAlias { +func loadInfoIndex(baseDir, revision, platform string) map[string]infoAlias { + key := cacheKey(revision, platform) infoCacheMu.RLock() - m, ok := infoCache[revision] + m, ok := infoCache[key] infoCacheMu.RUnlock() if ok { return m } infoCacheMu.Lock() - if m, ok := infoCache[revision]; ok { + if m, ok := infoCache[key]; ok { infoCacheMu.Unlock() return m } - if load := infoInflight[revision]; load != nil { + if load := infoInflight[key]; load != nil { infoCacheMu.Unlock() <-load.done return load.m } load := &infoLoad{done: make(chan struct{})} - infoInflight[revision] = load + infoInflight[key] = load infoCacheMu.Unlock() - filePath := filepath.Join(baseDir, "assets", "revisions", revision, "info.json") + filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "info.json") data, err := os.ReadFile(filePath) if err != nil { infoCacheMu.Lock() - infoCache[revision] = nil - delete(infoInflight, revision) + infoCache[key] = nil + delete(infoInflight, key) close(load.done) infoCacheMu.Unlock() return nil @@ -285,8 +294,8 @@ func loadInfoIndex(baseDir, revision string) map[string]infoAlias { var entries []infoJSONEntry if err := json.Unmarshal(data, &entries); err != nil { infoCacheMu.Lock() - infoCache[revision] = nil - delete(infoInflight, revision) + infoCache[key] = nil + delete(infoInflight, key) close(load.done) infoCacheMu.Unlock() return nil @@ -307,8 +316,8 @@ func loadInfoIndex(baseDir, revision string) map[string]infoAlias { } load.m = m infoCacheMu.Lock() - infoCache[revision] = m - delete(infoInflight, revision) + infoCache[key] = m + delete(infoInflight, key) close(load.done) infoCacheMu.Unlock() return m @@ -378,7 +387,7 @@ func hasNonASCII(s string) bool { // an en locale fallback is appended (marked IsLocaleFallback so callers can skip MD5 validation). // For paths with non-ASCII characters, mojibake (double-encoded) and fullwidth-to-ASCII // variants are also tried. -func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCandidate { +func pathStrToFullPaths(baseDir, revision, platform, assetType, pathStr string) []pathCandidate { fsPath := strings.ReplaceAll(pathStr, ")", "/") if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") { return nil @@ -402,7 +411,7 @@ func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCand if strings.Contains(pathStr, ")ko)") { entries = append(entries, tagged{strings.ReplaceAll(pathStr, ")ko)", ")en)"), true}) } - base := filepath.Join(baseDir, "assets", "revisions", revision) + base := filepath.Join(baseDir, "assets", "revisions", revision, platform) var out []pathCandidate seen := make(map[string]bool) for _, e := range entries { @@ -434,64 +443,86 @@ func appendUniqueCandidate(candidates []assetCandidate, seen map[string]bool, ca return append(candidates, candidate) } -func duplicateCandidatePath(baseDir string, candidate assetCandidate, assetType, targetRevision, targetBaseName string) string { - root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, assetType) +func duplicateCandidatePath(baseDir string, candidate assetCandidate, platform, assetType, targetRevision, targetBaseName string) string { + root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, platform, assetType) rel, err := filepath.Rel(root, candidate.Path) if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { return "" } - return filepath.Join(baseDir, "assets", "revisions", targetRevision, assetType, filepath.Dir(rel), targetBaseName) + return filepath.Join(baseDir, "assets", "revisions", targetRevision, platform, assetType, filepath.Dir(rel), targetBaseName) } // objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks // (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision). // The original locale path is tried first (with MD5 validation); locale fallbacks are tried after // (without MD5 validation, since the hash in list.bin refers to the original locale's content). +// +// Two tiers are searched in order: the requested platform tree (e.g. revisions/0/ios/...) and then +// the un-split shared tree (revisions/0/...) which acts as a fallback for operators who deploy a +// single unified asset dump. Each tier carries its own list.bin md5 so corruption is still detected. // Callers should try each path until one exists on disk. -func objectIdToFilePathCandidates(baseDir, revision, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) { - idx, ok := loadListBinIndex(baseDir, revision) - if !ok || idx == nil { - return nil, 0, false - } - entry, ok := idx[objectId] - if !ok || entry.Path == "" { - return nil, 0, false - } - paths := pathStrToFullPaths(baseDir, revision, assetType, entry.Path) - if len(paths) == 0 { - return nil, 0, false - } +func objectIdToFilePathCandidates(baseDir, revision, platform, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) { seen := make(map[string]bool) - for _, pc := range paths { - md5 := entry.MD5 - if pc.IsLocaleFallback { - md5 = "" + var firstSize int64 + var anyHit bool + + appendForPlatform := func(p, label string) { + idx, idxOk := loadListBinIndex(baseDir, revision, p) + if !idxOk || idx == nil { + return } - candidates = appendUniqueCandidate(candidates, seen, assetCandidate{ - Path: pc.Path, - Revision: revision, - Source: "list.bin", - ExpectedMD5: md5, - }) - } - infoIndex := loadInfoIndex(baseDir, revision) - if len(infoIndex) > 0 { - for _, c := range candidates { - alias, ok := infoIndex[filepath.Base(c.Path)] - if !ok || alias.ToName == "" { - continue - } - alt := duplicateCandidatePath(baseDir, c, assetType, alias.ToRevision, alias.ToName) - if alt == "" { - continue + entry, entryOk := idx[objectId] + if !entryOk || entry.Path == "" { + return + } + paths := pathStrToFullPaths(baseDir, revision, p, assetType, entry.Path) + if len(paths) == 0 { + return + } + tierStart := len(candidates) + for _, pc := range paths { + md5 := entry.MD5 + if pc.IsLocaleFallback { + md5 = "" } candidates = appendUniqueCandidate(candidates, seen, assetCandidate{ - Path: alt, - Revision: alias.ToRevision, - Source: "info.json redirect", - ExpectedMD5: alias.MD5, + Path: pc.Path, + Revision: revision, + Source: "list.bin (" + label + ")", + ExpectedMD5: md5, }) } + infoIndex := loadInfoIndex(baseDir, revision, p) + if len(infoIndex) > 0 { + tierCandidates := candidates[tierStart:] + for _, c := range tierCandidates { + alias, aliasOk := infoIndex[filepath.Base(c.Path)] + if !aliasOk || alias.ToName == "" { + continue + } + alt := duplicateCandidatePath(baseDir, c, p, assetType, alias.ToRevision, alias.ToName) + if alt == "" { + continue + } + candidates = appendUniqueCandidate(candidates, seen, assetCandidate{ + Path: alt, + Revision: alias.ToRevision, + Source: "info.json redirect (" + label + ")", + ExpectedMD5: alias.MD5, + }) + } + } + if !anyHit { + firstSize = entry.Size + anyHit = true + } } - return candidates, entry.Size, true + + appendForPlatform(platform, platform) + appendForPlatform("", "shared") + + if !anyHit { + return nil, 0, false + } + return candidates, firstSize, true } diff --git a/server/internal/service/octo.go b/server/internal/service/octo.go index 8e50797..e99fce9 100644 --- a/server/internal/service/octo.go +++ b/server/internal/service/octo.go @@ -133,7 +133,9 @@ func NewOctoHTTPServer(resourcesBaseURL, baseDir string) *OctoHTTPServer { revisions: newRevisionTracker(), resolver: newAssetResolver(baseDir), } - s.resolver.Prewarm("0") + s.resolver.Prewarm("0", platformAndroid) + s.resolver.Prewarm("0", platformIOS) + s.resolver.Prewarm("0", "") s.mux.HandleFunc("/", s.handleAll) return s } @@ -142,12 +144,24 @@ func (s *OctoHTTPServer) Handler() http.Handler { return s.mux } +// listBinPath prefers the platform-split list.bin and falls back to the un-split shared tree +// when the platform-specific file is missing, so operators with a single unified asset dump +// keep working. +func (s *OctoHTTPServer) listBinPath(revision, platform string) string { + p := filepath.Join(s.BaseDir, "assets", "revisions", revision, platform, "list.bin") + if _, err := os.Stat(p); err == nil { + return p + } + return filepath.Join(s.BaseDir, "assets", "revisions", revision, "list.bin") +} + func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) { path := r.URL.Path + platform := platformFromUserAgent(r) isAssetRequest := strings.Contains(path, "/unso-") isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin") if !isAssetRequest && !isMasterDataRequest { - log.Printf("[HTTP] %s %s (Host: %s)", r.Method, r.URL.String(), r.Host) + log.Printf("[HTTP] %s %s (Host: %s, platform: %s)", r.Method, r.URL.String(), r.Host, platform) for k, v := range r.Header { log.Printf("[HTTP] %s: %s", k, v) } @@ -155,13 +169,13 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) { // Octo v2 API — asset bundle management if strings.HasPrefix(path, "/v2/") { - s.handleOctoV2(w, r, path) + s.handleOctoV2(w, r, path, platform) return } // Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision if strings.HasPrefix(path, "/v1/list/") { - s.serveOctoV1List(w, r, path) + s.serveOctoV1List(w, r, path, platform) return } @@ -188,7 +202,7 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) { // Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media) if strings.Contains(path, "/unso-") { - s.serveUnsoAsset(w, r, path) + s.serveUnsoAsset(w, r, path, platform) return } @@ -218,8 +232,8 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) { w.Write([]byte{}) } -func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) { - log.Printf("[OctoV2] %s %s", r.Method, path) +func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path, platform string) { + log.Printf("[OctoV2] %s %s (platform=%s)", r.Method, path, platform) // /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing if strings.Contains(path, "/list/") { @@ -228,13 +242,13 @@ func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, pa requestedRevision := parts[len(parts)-1] if requestedRevision != "" { revision := "0" - filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin") + filePath := s.listBinPath(revision, platform) if requestedRevision != revision { log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision) } - log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s)", filePath, requestedRevision, revision) + log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", filePath, requestedRevision, revision, platform) s.revisions.Remember(r.RemoteAddr, revision) - go s.resolver.Prewarm(revision) + go s.resolver.Prewarm(revision, platform) s.serveListBin(w, filePath) return } @@ -259,8 +273,8 @@ func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, pa w.WriteHeader(200) } -// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin. -func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) { +// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/{platform}/list.bin. +func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path, platform string) { parts := strings.Split(strings.Trim(path, "/"), "/") // ["v1", "list", "300116832", "0"] -> revision = last segment requestedRevision := "0" @@ -268,18 +282,18 @@ func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, requestedRevision = parts[len(parts)-1] } revision := "0" - filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin") + filePath := s.listBinPath(revision, platform) if requestedRevision != revision { log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision) } - log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s)", r.Method, path, filePath, requestedRevision, revision) + log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", r.Method, path, filePath, requestedRevision, revision, platform) s.revisions.Remember(r.RemoteAddr, revision) - go s.resolver.Prewarm(revision) + go s.resolver.Prewarm(revision, platform) s.serveListBin(w, filePath) } // serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}. -func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path string) { +func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path, platform string) { parts := strings.Split(strings.Trim(path, "/"), "/") var segment, objectId string for i, p := range parts { @@ -311,9 +325,9 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, return } activeRevision := s.revisions.Active(r.RemoteAddr) - resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision) + resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision, platform) if !ok { - log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s) no candidates", path, objectId, assetType, activeRevision) + log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s) no candidates", path, objectId, assetType, platform, activeRevision) w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(http.StatusNotFound) return @@ -354,7 +368,7 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, } if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) { md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5) - log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source) + log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s platform=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, platform, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source) f.Close() continue } @@ -366,9 +380,9 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, return } if len(md5Mismatches) > 0 { - log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches) + log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s platform=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, platform, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches) } - log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, triedPaths) + log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, platform, resolution.ActiveRevision, resolution.ListRevision, triedPaths) w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(http.StatusNotFound) } diff --git a/server/internal/service/platform_http.go b/server/internal/service/platform_http.go new file mode 100644 index 0000000..f8bb11e --- /dev/null +++ b/server/internal/service/platform_http.go @@ -0,0 +1,24 @@ +package service + +import ( + "net/http" + "strings" +) + +const ( + platformAndroid = "android" + platformIOS = "ios" +) + +// platformFromUserAgent classifies an HTTP request as iOS vs Android based on +// the User-Agent header. Unity's UnityWebRequest does not set a UA on iOS, so +// CFNetwork's default ("/ CFNetwork/x Darwin/x") is what arrives; +// on Android Unity sets "UnityPlayer/... (UnityWebRequest/...)". Any other UA +// (or none) is treated as Android, matching model.DefaultPlatform. +func platformFromUserAgent(r *http.Request) string { + ua := r.Header.Get("User-Agent") + if strings.Contains(ua, "Darwin/") || strings.Contains(ua, "CFNetwork/") { + return platformIOS + } + return platformAndroid +}