mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-25 21:40:57 +02:00
fix(store): harden extension repo registry handling
- Persist registry URL inside store_cache.json and restore it on init so the disk cache survives app restarts instead of being wiped every launch - Fall back to the cached registry when the fetched body fails to parse, matching the existing network/HTTP error fallbacks - Detect HTML responses and surface a clear message instead of the raw Go JSON error (invalid character '<') seen in issue #474 - Validate the registry loads before persisting a new URL, and roll the backend back to the previous URL on failure, so a broken link never survives a restart or Android auto-backup restore
This commit is contained in:
@@ -178,6 +178,54 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionStoreDiskCacheSurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
registryURL := "https://registry.example.com/registry.json"
|
||||
store := &extensionStore{
|
||||
registryURL: registryURL,
|
||||
cacheDir: dir,
|
||||
cacheTTL: time.Hour,
|
||||
cache: &storeRegistry{
|
||||
Version: 1,
|
||||
Extensions: []storeExtension{{ID: "ext", Name: "ext", Version: "1.0.0"}},
|
||||
},
|
||||
cacheTime: time.Now(),
|
||||
}
|
||||
store.saveDiskCache()
|
||||
|
||||
// Simulates an app restart: a fresh store loads the disk cache, then the
|
||||
// Dart layer re-applies the same registry URL.
|
||||
restarted := &extensionStore{cacheDir: dir, cacheTTL: time.Hour}
|
||||
restarted.loadDiskCache()
|
||||
if restarted.getRegistryURL() != registryURL {
|
||||
t.Fatalf("registry URL after restart = %q", restarted.getRegistryURL())
|
||||
}
|
||||
restarted.setRegistryURL(registryURL)
|
||||
if restarted.cache == nil || len(restarted.cache.Extensions) != 1 {
|
||||
t.Fatalf("expected cache to survive re-applying the same registry URL, got %#v", restarted.cache)
|
||||
}
|
||||
|
||||
restarted.setRegistryURL("https://other.example.com/registry.json")
|
||||
if restarted.cache != nil {
|
||||
t.Fatal("expected cache reset after registry URL change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegistryBody(t *testing.T) {
|
||||
registry, err := parseRegistryBody([]byte(`{"version":1,"extensions":[{"id":"ext","name":"ext","version":"1.0.0"}]}`))
|
||||
if err != nil || len(registry.Extensions) != 1 {
|
||||
t.Fatalf("parseRegistryBody = %#v/%v", registry, err)
|
||||
}
|
||||
|
||||
if _, err := parseRegistryBody([]byte("<!DOCTYPE html><html></html>")); err == nil || !strings.Contains(err.Error(), "web page") {
|
||||
t.Fatalf("expected web page error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := parseRegistryBody([]byte("not json")); err == nil || !strings.Contains(err.Error(), "failed to parse registry") {
|
||||
t.Fatalf("expected parse error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := &extensionStore{
|
||||
|
||||
@@ -195,8 +195,9 @@ func (s *extensionStore) loadDiskCache() {
|
||||
}
|
||||
|
||||
var cacheData struct {
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
RegistryURL string `json:"registry_url"`
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &cacheData); err != nil {
|
||||
@@ -205,6 +206,11 @@ func (s *extensionStore) loadDiskCache() {
|
||||
|
||||
s.cache = &cacheData.Registry
|
||||
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
|
||||
if s.registryURL == "" {
|
||||
// Restore the URL that produced this cache so a later setRegistryURL
|
||||
// with the same URL keeps the cache instead of wiping it.
|
||||
s.registryURL = cacheData.RegistryURL
|
||||
}
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
@@ -214,11 +220,13 @@ func (s *extensionStore) saveDiskCache() {
|
||||
}
|
||||
|
||||
cacheData := struct {
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
RegistryURL string `json:"registry_url"`
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}{
|
||||
Registry: *s.cache,
|
||||
CacheTime: s.cacheTime.Unix(),
|
||||
RegistryURL: s.registryURL,
|
||||
Registry: *s.cache,
|
||||
CacheTime: s.cacheTime.Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cacheData)
|
||||
@@ -283,16 +291,31 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
var registry storeRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
registry, err := parseRegistryBody(body)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Failed to parse registry, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cache = ®istry
|
||||
s.cache = registry
|
||||
s.cacheTime = time.Now()
|
||||
s.saveDiskCache()
|
||||
|
||||
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func parseRegistryBody(body []byte) (*storeRegistry, error) {
|
||||
var registry storeRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
if strings.HasPrefix(strings.TrimSpace(string(body)), "<") {
|
||||
return nil, fmt.Errorf("registry URL returned a web page instead of JSON. Make sure the URL points to a registry.json file or a GitHub repository that contains one")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -265,23 +265,39 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
final previousUrl = state.registryUrl;
|
||||
try {
|
||||
await PlatformBridge.setStoreRegistryUrl(trimmed);
|
||||
|
||||
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||
|
||||
// Validate the registry actually loads before persisting the URL, so a
|
||||
// broken link never survives an app restart (or a backup restore).
|
||||
final extensions = await PlatformBridge.getStoreExtensions(
|
||||
forceRefresh: true,
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
||||
|
||||
state = state.copyWith(
|
||||
registryUrl: resolvedUrl,
|
||||
extensions: const [],
|
||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
_log.i('Registry URL set to: $resolvedUrl');
|
||||
await refresh(forceRefresh: true);
|
||||
} catch (e) {
|
||||
_log.e('Failed to set registry URL: $e');
|
||||
try {
|
||||
if (previousUrl.isNotEmpty) {
|
||||
await PlatformBridge.setStoreRegistryUrl(previousUrl);
|
||||
} else {
|
||||
await PlatformBridge.clearStoreRegistryUrl();
|
||||
}
|
||||
} catch (restoreError) {
|
||||
_log.w('Failed to restore previous registry URL: $restoreError');
|
||||
}
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user