mirror of Walter-Sparrow / lunar-tear
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Multi platform support

+254 -108
+37 -2
server/cmd/auth-server/handlers.go
··· 18 18 19 19 var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html")) 20 20 21 + // oauthRedirectTmpl drives the fbconnect:// hand-off via a renderer-initiated 22 + // navigation instead of a server-side 302. Android WebView does NOT invoke 23 + // WebViewClient.shouldOverrideUrlLoading for 302 redirects from POST form 24 + // submissions to non-http schemes (documented Chromium WebView limitation, 25 + // Stack Overflow #6738328 / Google issuetracker #36918490). Returning a 200 26 + // HTML page with both <meta http-equiv="refresh"> and window.location.replace() 27 + // makes the cross-scheme navigation renderer-initiated, which DOES invoke 28 + // shouldOverrideUrlLoading, so the FB SDK can extract access_token from the 29 + // URL fragment and complete its login flow. html/template auto-escapes {{.}} 30 + // correctly for the meta URL-attribute context and the JS string-literal 31 + // context inside <script>. 32 + var oauthRedirectTmpl = template.Must(template.New("oauthRedirect").Parse( 33 + `<!doctype html><html><head><meta charset="utf-8"> 34 + <meta http-equiv="refresh" content="0;url={{.}}"> 35 + <script>window.location.replace({{.}});</script> 36 + </head><body> 37 + <noscript><a href="{{.}}">Continue</a></noscript> 38 + </body></html> 39 + `)) 40 + 21 41 type Handlers struct { 22 42 store *AuthStore 23 43 tok *TokenService ··· 30 50 type loginPageData struct { 31 51 RedirectURI string 32 52 State string 53 + Scope string 33 54 Error string 34 55 Username string 35 56 } ··· 77 98 data := loginPageData{ 78 99 RedirectURI: r.URL.Query().Get("redirect_uri"), 79 100 State: r.URL.Query().Get("state"), 101 + Scope: r.URL.Query().Get("scope"), 80 102 } 81 103 w.Header().Set("Content-Type", "text/html; charset=utf-8") 82 104 if err := loginTmpl.Execute(w, data); err != nil { ··· 95 117 action := r.FormValue("action") 96 118 redirectURI := r.FormValue("redirect_uri") 97 119 state := r.FormValue("state") 120 + scope := r.FormValue("scope") 98 121 99 122 renderErr := func(msg string) { 100 123 data := loginPageData{ 101 124 RedirectURI: redirectURI, 102 125 State: state, 126 + Scope: scope, 103 127 Error: msg, 104 128 Username: username, 105 129 } ··· 158 182 } 159 183 160 184 payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID) 161 - b64 := base64.StdEncoding.EncodeToString([]byte(payload)) 185 + b64 := base64.RawURLEncoding.EncodeToString([]byte(payload)) 162 186 163 187 fragment := url.Values{} 164 188 fragment.Set("access_token", token) 165 189 fragment.Set("token_type", "bearer") 166 190 fragment.Set("expires_in", strconv.FormatInt(int64(tokenTTL.Seconds()), 10)) 167 191 fragment.Set("signed_request", "0."+b64) 192 + // iOS FBSDKLoginManager treats an empty granted_scopes set as a cancelled login 193 + // (LoginManager.swift -> getSuccessResult -> getCancelledResult). Echo back the 194 + // scope the SDK sent so parameters.permissions is non-empty and the SDK fires 195 + // its success path. Android tolerates either way. 196 + if scope != "" { 197 + fragment.Set("granted_scopes", scope) 198 + fragment.Set("denied_scopes", "") 199 + } 168 200 if state != "" { 169 201 fragment.Set("state", state) 170 202 } 171 203 172 204 target := redirectURI + "?" + fragment.Encode() 173 205 log.Printf("redirecting to %s", target) 174 - http.Redirect(w, r, target, http.StatusFound) 206 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 207 + if err := oauthRedirectTmpl.Execute(w, target); err != nil { 208 + log.Printf("render oauth redirect: %v", err) 209 + } 175 210 } 176 211 177 212 func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) {
+1
server/cmd/auth-server/login.html
··· 128 128 129 129 <input type="hidden" name="redirect_uri" value="{{.RedirectURI}}"> 130 130 <input type="hidden" name="state" value="{{.State}}"> 131 + <input type="hidden" name="scope" value="{{.Scope}}"> 131 132 132 133 <label for="username">Username</label> 133 134 <input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required>
+52 -11
server/cmd/wizard/main.go
··· 122 122 var requiredAssets = []assetCheck{ 123 123 {"assets", true}, 124 124 {"assets/release/20240404193219.bin.e", false}, 125 - {"assets/revisions/0/list.bin", false}, 126 - {"assets/revisions/0/assetbundle", true}, 127 - {"assets/revisions/0/resources", true}, 125 + } 126 + 127 + // platformAssets holds the per-platform rev-0 layout. At least one full set 128 + // must be present; users may have only Android or only iOS extracted. 129 + var platformAssets = [][]assetCheck{ 130 + { 131 + {"assets/revisions/0/android/list.bin", false}, 132 + {"assets/revisions/0/android/assetbundle", true}, 133 + {"assets/revisions/0/android/resources", true}, 134 + }, 135 + { 136 + {"assets/revisions/0/ios/list.bin", false}, 137 + {"assets/revisions/0/ios/assetbundle", true}, 138 + {"assets/revisions/0/ios/resources", true}, 139 + }, 140 + } 141 + 142 + func checkAsset(a assetCheck) (missing string, ok bool) { 143 + info, err := os.Stat(a.path) 144 + if err != nil { 145 + return a.path, false 146 + } 147 + if a.dir && !info.IsDir() { 148 + return a.path + string(filepath.Separator), false 149 + } 150 + if !a.dir && info.IsDir() { 151 + return a.path, false 152 + } 153 + return "", true 128 154 } 129 155 130 156 func validateAssets() { 131 157 var missing []string 132 158 for _, a := range requiredAssets { 133 - info, err := os.Stat(a.path) 134 - if err != nil { 135 - missing = append(missing, a.path) 136 - continue 159 + if m, ok := checkAsset(a); !ok { 160 + missing = append(missing, m) 161 + } 162 + } 163 + 164 + var platformMissing [][]string 165 + anyPlatformOK := false 166 + for _, group := range platformAssets { 167 + var groupMissing []string 168 + for _, a := range group { 169 + if m, ok := checkAsset(a); !ok { 170 + groupMissing = append(groupMissing, m) 171 + } 172 + } 173 + if len(groupMissing) == 0 { 174 + anyPlatformOK = true 137 175 } 138 - if a.dir && !info.IsDir() { 139 - missing = append(missing, a.path+string(filepath.Separator)) 140 - } else if !a.dir && info.IsDir() { 141 - missing = append(missing, a.path) 176 + platformMissing = append(platformMissing, groupMissing) 177 + } 178 + if !anyPlatformOK { 179 + for _, gm := range platformMissing { 180 + missing = append(missing, gm...) 142 181 } 143 182 } 144 183 ··· 160 199 } 161 200 b.WriteString("\n") 162 201 b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again.")) 202 + b.WriteString("\n") 203 + b.WriteString(dimStyle.Render(" At least one of assets/revisions/0/android/ or assets/revisions/0/ios/ must be fully present.")) 163 204 b.WriteString("\n") 164 205 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")) 165 206 b.WriteString("\n")
+7 -7
server/internal/service/asset_resolver.go
··· 70 70 return revision 71 71 } 72 72 73 - func (r *assetResolver) Resolve(objectId, assetType, activeRevision string) (assetResolution, bool) { 73 + func (r *assetResolver) Resolve(objectId, assetType, activeRevision, platform string) (assetResolution, bool) { 74 74 start := time.Now() 75 75 resolution := assetResolution{ActiveRevision: activeRevision} 76 76 revision := activeRevision 77 77 78 - candidates, listSize, ok := objectIdToFilePathCandidates(r.baseDir, revision, assetType, objectId) 78 + candidates, listSize, ok := objectIdToFilePathCandidates(r.baseDir, revision, platform, assetType, objectId) 79 79 if ok && len(candidates) > 0 { 80 80 resolution.ListRevision = revision 81 81 resolution.ListSize = listSize 82 82 resolution.Candidates = candidates 83 83 if elapsed := time.Since(start); elapsed > 100*time.Millisecond { 84 - log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, activeRevision, revision, elapsed) 84 + 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) 85 85 } 86 86 return resolution, true 87 87 } 88 88 89 89 if elapsed := time.Since(start); elapsed > 100*time.Millisecond { 90 - log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s active_revision=%s elapsed=%s", objectId, assetType, activeRevision, elapsed) 90 + log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s platform=%s active_revision=%s elapsed=%s", objectId, assetType, platform, activeRevision, elapsed) 91 91 } 92 92 return resolution, false 93 93 } 94 94 95 - func (r *assetResolver) Prewarm(activeRevision string) { 95 + func (r *assetResolver) Prewarm(activeRevision, platform string) { 96 96 if activeRevision == "" { 97 97 return 98 98 } 99 - _, _ = loadListBinIndex(r.baseDir, activeRevision) 100 - _ = loadInfoIndex(r.baseDir, activeRevision) 99 + _, _ = loadListBinIndex(r.baseDir, activeRevision, platform) 100 + _ = loadInfoIndex(r.baseDir, activeRevision, platform) 101 101 }
+98 -67
server/internal/service/listbin.go
··· 45 45 } 46 46 47 47 var ( 48 - listBinCache = make(map[string]listBinIndex) // revision → index 48 + listBinCache = make(map[string]listBinIndex) // revision/platform → index 49 49 listBinInflight = make(map[string]*listBinLoad) 50 50 listBinCacheMu sync.RWMutex 51 - infoCache = make(map[string]map[string]infoAlias) // revision → from-name → duplicate target 51 + infoCache = make(map[string]map[string]infoAlias) // revision/platform → from-name → duplicate target 52 52 infoInflight = make(map[string]*infoLoad) 53 53 infoCacheMu sync.RWMutex 54 54 ) 55 + 56 + func cacheKey(revision, platform string) string { 57 + if platform == "" { 58 + return revision + "/_shared" 59 + } 60 + return revision + "/" + platform 61 + } 55 62 56 63 // infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name). 57 64 type infoJSONEntry struct { ··· 208 215 return idx 209 216 } 210 217 211 - func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) { 218 + func loadListBinIndex(baseDir, revision, platform string) (listBinIndex, bool) { 219 + key := cacheKey(revision, platform) 212 220 listBinCacheMu.RLock() 213 - idx, ok := listBinCache[revision] 221 + idx, ok := listBinCache[key] 214 222 listBinCacheMu.RUnlock() 215 223 if ok { 216 224 return idx, true 217 225 } 218 226 219 227 listBinCacheMu.Lock() 220 - if idx, ok := listBinCache[revision]; ok { 228 + if idx, ok := listBinCache[key]; ok { 221 229 listBinCacheMu.Unlock() 222 230 return idx, true 223 231 } 224 - if load := listBinInflight[revision]; load != nil { 232 + if load := listBinInflight[key]; load != nil { 225 233 listBinCacheMu.Unlock() 226 234 <-load.done 227 235 return load.idx, load.ok 228 236 } 229 237 load := &listBinLoad{done: make(chan struct{})} 230 - listBinInflight[revision] = load 238 + listBinInflight[key] = load 231 239 listBinCacheMu.Unlock() 232 240 233 - filePath := filepath.Join(baseDir, "assets", "revisions", revision, "list.bin") 241 + filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "list.bin") 234 242 data, err := os.ReadFile(filePath) 235 243 if err != nil { 236 244 listBinCacheMu.Lock() 237 - delete(listBinInflight, revision) 245 + delete(listBinInflight, key) 238 246 close(load.done) 239 247 listBinCacheMu.Unlock() 240 248 return nil, false ··· 243 251 load.idx = idx 244 252 load.ok = true 245 253 listBinCacheMu.Lock() 246 - listBinCache[revision] = idx 247 - delete(listBinInflight, revision) 254 + listBinCache[key] = idx 255 + delete(listBinInflight, key) 248 256 close(load.done) 249 257 listBinCacheMu.Unlock() 250 258 return idx, true 251 259 } 252 260 253 - func loadInfoIndex(baseDir, revision string) map[string]infoAlias { 261 + func loadInfoIndex(baseDir, revision, platform string) map[string]infoAlias { 262 + key := cacheKey(revision, platform) 254 263 infoCacheMu.RLock() 255 - m, ok := infoCache[revision] 264 + m, ok := infoCache[key] 256 265 infoCacheMu.RUnlock() 257 266 if ok { 258 267 return m 259 268 } 260 269 261 270 infoCacheMu.Lock() 262 - if m, ok := infoCache[revision]; ok { 271 + if m, ok := infoCache[key]; ok { 263 272 infoCacheMu.Unlock() 264 273 return m 265 274 } 266 - if load := infoInflight[revision]; load != nil { 275 + if load := infoInflight[key]; load != nil { 267 276 infoCacheMu.Unlock() 268 277 <-load.done 269 278 return load.m 270 279 } 271 280 load := &infoLoad{done: make(chan struct{})} 272 - infoInflight[revision] = load 281 + infoInflight[key] = load 273 282 infoCacheMu.Unlock() 274 283 275 - filePath := filepath.Join(baseDir, "assets", "revisions", revision, "info.json") 284 + filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "info.json") 276 285 data, err := os.ReadFile(filePath) 277 286 if err != nil { 278 287 infoCacheMu.Lock() 279 - infoCache[revision] = nil 280 - delete(infoInflight, revision) 288 + infoCache[key] = nil 289 + delete(infoInflight, key) 281 290 close(load.done) 282 291 infoCacheMu.Unlock() 283 292 return nil ··· 285 294 var entries []infoJSONEntry 286 295 if err := json.Unmarshal(data, &entries); err != nil { 287 296 infoCacheMu.Lock() 288 - infoCache[revision] = nil 289 - delete(infoInflight, revision) 297 + infoCache[key] = nil 298 + delete(infoInflight, key) 290 299 close(load.done) 291 300 infoCacheMu.Unlock() 292 301 return nil ··· 307 316 } 308 317 load.m = m 309 318 infoCacheMu.Lock() 310 - infoCache[revision] = m 311 - delete(infoInflight, revision) 319 + infoCache[key] = m 320 + delete(infoInflight, key) 312 321 close(load.done) 313 322 infoCacheMu.Unlock() 314 323 return m ··· 378 387 // an en locale fallback is appended (marked IsLocaleFallback so callers can skip MD5 validation). 379 388 // For paths with non-ASCII characters, mojibake (double-encoded) and fullwidth-to-ASCII 380 389 // variants are also tried. 381 - func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCandidate { 390 + func pathStrToFullPaths(baseDir, revision, platform, assetType, pathStr string) []pathCandidate { 382 391 fsPath := strings.ReplaceAll(pathStr, ")", "/") 383 392 if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") { 384 393 return nil ··· 402 411 if strings.Contains(pathStr, ")ko)") { 403 412 entries = append(entries, tagged{strings.ReplaceAll(pathStr, ")ko)", ")en)"), true}) 404 413 } 405 - base := filepath.Join(baseDir, "assets", "revisions", revision) 414 + base := filepath.Join(baseDir, "assets", "revisions", revision, platform) 406 415 var out []pathCandidate 407 416 seen := make(map[string]bool) 408 417 for _, e := range entries { ··· 434 443 return append(candidates, candidate) 435 444 } 436 445 437 - func duplicateCandidatePath(baseDir string, candidate assetCandidate, assetType, targetRevision, targetBaseName string) string { 438 - root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, assetType) 446 + func duplicateCandidatePath(baseDir string, candidate assetCandidate, platform, assetType, targetRevision, targetBaseName string) string { 447 + root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, platform, assetType) 439 448 rel, err := filepath.Rel(root, candidate.Path) 440 449 if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { 441 450 return "" 442 451 } 443 - return filepath.Join(baseDir, "assets", "revisions", targetRevision, assetType, filepath.Dir(rel), targetBaseName) 452 + return filepath.Join(baseDir, "assets", "revisions", targetRevision, platform, assetType, filepath.Dir(rel), targetBaseName) 444 453 } 445 454 446 455 // objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks 447 456 // (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision). 448 457 // The original locale path is tried first (with MD5 validation); locale fallbacks are tried after 449 458 // (without MD5 validation, since the hash in list.bin refers to the original locale's content). 459 + // 460 + // Two tiers are searched in order: the requested platform tree (e.g. revisions/0/ios/...) and then 461 + // the un-split shared tree (revisions/0/...) which acts as a fallback for operators who deploy a 462 + // single unified asset dump. Each tier carries its own list.bin md5 so corruption is still detected. 450 463 // Callers should try each path until one exists on disk. 451 - func objectIdToFilePathCandidates(baseDir, revision, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) { 452 - idx, ok := loadListBinIndex(baseDir, revision) 453 - if !ok || idx == nil { 454 - return nil, 0, false 455 - } 456 - entry, ok := idx[objectId] 457 - if !ok || entry.Path == "" { 458 - return nil, 0, false 459 - } 460 - paths := pathStrToFullPaths(baseDir, revision, assetType, entry.Path) 461 - if len(paths) == 0 { 462 - return nil, 0, false 463 - } 464 + func objectIdToFilePathCandidates(baseDir, revision, platform, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) { 464 465 seen := make(map[string]bool) 465 - for _, pc := range paths { 466 - md5 := entry.MD5 467 - if pc.IsLocaleFallback { 468 - md5 = "" 466 + var firstSize int64 467 + var anyHit bool 468 + 469 + appendForPlatform := func(p, label string) { 470 + idx, idxOk := loadListBinIndex(baseDir, revision, p) 471 + if !idxOk || idx == nil { 472 + return 473 + } 474 + entry, entryOk := idx[objectId] 475 + if !entryOk || entry.Path == "" { 476 + return 469 477 } 470 - candidates = appendUniqueCandidate(candidates, seen, assetCandidate{ 471 - Path: pc.Path, 472 - Revision: revision, 473 - Source: "list.bin", 474 - ExpectedMD5: md5, 475 - }) 476 - } 477 - infoIndex := loadInfoIndex(baseDir, revision) 478 - if len(infoIndex) > 0 { 479 - for _, c := range candidates { 480 - alias, ok := infoIndex[filepath.Base(c.Path)] 481 - if !ok || alias.ToName == "" { 482 - continue 483 - } 484 - alt := duplicateCandidatePath(baseDir, c, assetType, alias.ToRevision, alias.ToName) 485 - if alt == "" { 486 - continue 478 + paths := pathStrToFullPaths(baseDir, revision, p, assetType, entry.Path) 479 + if len(paths) == 0 { 480 + return 481 + } 482 + tierStart := len(candidates) 483 + for _, pc := range paths { 484 + md5 := entry.MD5 485 + if pc.IsLocaleFallback { 486 + md5 = "" 487 487 } 488 488 candidates = appendUniqueCandidate(candidates, seen, assetCandidate{ 489 - Path: alt, 490 - Revision: alias.ToRevision, 491 - Source: "info.json redirect", 492 - ExpectedMD5: alias.MD5, 489 + Path: pc.Path, 490 + Revision: revision, 491 + Source: "list.bin (" + label + ")", 492 + ExpectedMD5: md5, 493 493 }) 494 494 } 495 + infoIndex := loadInfoIndex(baseDir, revision, p) 496 + if len(infoIndex) > 0 { 497 + tierCandidates := candidates[tierStart:] 498 + for _, c := range tierCandidates { 499 + alias, aliasOk := infoIndex[filepath.Base(c.Path)] 500 + if !aliasOk || alias.ToName == "" { 501 + continue 502 + } 503 + alt := duplicateCandidatePath(baseDir, c, p, assetType, alias.ToRevision, alias.ToName) 504 + if alt == "" { 505 + continue 506 + } 507 + candidates = appendUniqueCandidate(candidates, seen, assetCandidate{ 508 + Path: alt, 509 + Revision: alias.ToRevision, 510 + Source: "info.json redirect (" + label + ")", 511 + ExpectedMD5: alias.MD5, 512 + }) 513 + } 514 + } 515 + if !anyHit { 516 + firstSize = entry.Size 517 + anyHit = true 518 + } 495 519 } 496 - return candidates, entry.Size, true 520 + 521 + appendForPlatform(platform, platform) 522 + appendForPlatform("", "shared") 523 + 524 + if !anyHit { 525 + return nil, 0, false 526 + } 527 + return candidates, firstSize, true 497 528 }
+35 -21
server/internal/service/octo.go
··· 133 133 revisions: newRevisionTracker(), 134 134 resolver: newAssetResolver(baseDir), 135 135 } 136 - s.resolver.Prewarm("0") 136 + s.resolver.Prewarm("0", platformAndroid) 137 + s.resolver.Prewarm("0", platformIOS) 138 + s.resolver.Prewarm("0", "") 137 139 s.mux.HandleFunc("/", s.handleAll) 138 140 return s 139 141 } ··· 142 144 return s.mux 143 145 } 144 146 147 + // listBinPath prefers the platform-split list.bin and falls back to the un-split shared tree 148 + // when the platform-specific file is missing, so operators with a single unified asset dump 149 + // keep working. 150 + func (s *OctoHTTPServer) listBinPath(revision, platform string) string { 151 + p := filepath.Join(s.BaseDir, "assets", "revisions", revision, platform, "list.bin") 152 + if _, err := os.Stat(p); err == nil { 153 + return p 154 + } 155 + return filepath.Join(s.BaseDir, "assets", "revisions", revision, "list.bin") 156 + } 157 + 145 158 func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) { 146 159 path := r.URL.Path 160 + platform := platformFromUserAgent(r) 147 161 isAssetRequest := strings.Contains(path, "/unso-") 148 162 isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin") 149 163 if !isAssetRequest && !isMasterDataRequest { 150 - log.Printf("[HTTP] %s %s (Host: %s)", r.Method, r.URL.String(), r.Host) 164 + log.Printf("[HTTP] %s %s (Host: %s, platform: %s)", r.Method, r.URL.String(), r.Host, platform) 151 165 for k, v := range r.Header { 152 166 log.Printf("[HTTP] %s: %s", k, v) 153 167 } ··· 155 169 156 170 // Octo v2 API — asset bundle management 157 171 if strings.HasPrefix(path, "/v2/") { 158 - s.handleOctoV2(w, r, path) 172 + s.handleOctoV2(w, r, path, platform) 159 173 return 160 174 } 161 175 162 176 // Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision 163 177 if strings.HasPrefix(path, "/v1/list/") { 164 - s.serveOctoV1List(w, r, path) 178 + s.serveOctoV1List(w, r, path, platform) 165 179 return 166 180 } 167 181 ··· 188 202 189 203 // Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media) 190 204 if strings.Contains(path, "/unso-") { 191 - s.serveUnsoAsset(w, r, path) 205 + s.serveUnsoAsset(w, r, path, platform) 192 206 return 193 207 } 194 208 ··· 218 232 w.Write([]byte{}) 219 233 } 220 234 221 - func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) { 222 - log.Printf("[OctoV2] %s %s", r.Method, path) 235 + func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path, platform string) { 236 + log.Printf("[OctoV2] %s %s (platform=%s)", r.Method, path, platform) 223 237 224 238 // /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing 225 239 if strings.Contains(path, "/list/") { ··· 228 242 requestedRevision := parts[len(parts)-1] 229 243 if requestedRevision != "" { 230 244 revision := "0" 231 - filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin") 245 + filePath := s.listBinPath(revision, platform) 232 246 if requestedRevision != revision { 233 247 log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision) 234 248 } 235 - log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s)", filePath, requestedRevision, revision) 249 + log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", filePath, requestedRevision, revision, platform) 236 250 s.revisions.Remember(r.RemoteAddr, revision) 237 - go s.resolver.Prewarm(revision) 251 + go s.resolver.Prewarm(revision, platform) 238 252 s.serveListBin(w, filePath) 239 253 return 240 254 } ··· 259 273 w.WriteHeader(200) 260 274 } 261 275 262 - // serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin. 263 - func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) { 276 + // serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/{platform}/list.bin. 277 + func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path, platform string) { 264 278 parts := strings.Split(strings.Trim(path, "/"), "/") 265 279 // ["v1", "list", "300116832", "0"] -> revision = last segment 266 280 requestedRevision := "0" ··· 268 282 requestedRevision = parts[len(parts)-1] 269 283 } 270 284 revision := "0" 271 - filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin") 285 + filePath := s.listBinPath(revision, platform) 272 286 if requestedRevision != revision { 273 287 log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision) 274 288 } 275 - log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s)", r.Method, path, filePath, requestedRevision, revision) 289 + log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", r.Method, path, filePath, requestedRevision, revision, platform) 276 290 s.revisions.Remember(r.RemoteAddr, revision) 277 - go s.resolver.Prewarm(revision) 291 + go s.resolver.Prewarm(revision, platform) 278 292 s.serveListBin(w, filePath) 279 293 } 280 294 281 295 // serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}. 282 - func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path string) { 296 + func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path, platform string) { 283 297 parts := strings.Split(strings.Trim(path, "/"), "/") 284 298 var segment, objectId string 285 299 for i, p := range parts { ··· 311 325 return 312 326 } 313 327 activeRevision := s.revisions.Active(r.RemoteAddr) 314 - resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision) 328 + resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision, platform) 315 329 if !ok { 316 - log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s) no candidates", path, objectId, assetType, activeRevision) 330 + log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s) no candidates", path, objectId, assetType, platform, activeRevision) 317 331 w.Header().Set("Content-Type", "application/octet-stream") 318 332 w.WriteHeader(http.StatusNotFound) 319 333 return ··· 354 368 } 355 369 if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) { 356 370 md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5) 357 - 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) 371 + 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) 358 372 f.Close() 359 373 continue 360 374 } ··· 366 380 return 367 381 } 368 382 if len(md5Mismatches) > 0 { 369 - 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) 383 + 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) 370 384 } 371 - 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) 385 + 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) 372 386 w.Header().Set("Content-Type", "application/octet-stream") 373 387 w.WriteHeader(http.StatusNotFound) 374 388 }
+24
server/internal/service/platform_http.go
··· 1 + package service 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + ) 7 + 8 + const ( 9 + platformAndroid = "android" 10 + platformIOS = "ios" 11 + ) 12 + 13 + // platformFromUserAgent classifies an HTTP request as iOS vs Android based on 14 + // the User-Agent header. Unity's UnityWebRequest does not set a UA on iOS, so 15 + // CFNetwork's default ("<bundle>/<build> CFNetwork/x Darwin/x") is what arrives; 16 + // on Android Unity sets "UnityPlayer/... (UnityWebRequest/...)". Any other UA 17 + // (or none) is treated as Android, matching model.DefaultPlatform. 18 + func platformFromUserAgent(r *http.Request) string { 19 + ua := r.Header.Get("User-Agent") 20 + if strings.Contains(ua, "Darwin/") || strings.Contains(ua, "CFNetwork/") { 21 + return platformIOS 22 + } 23 + return platformAndroid 24 + }