diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index 30748d41..b009ebdc 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -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("")); 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{ diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 32bfa899..58736bf8 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -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 } diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index 4691bb05..70d246e4 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -265,23 +265,39 @@ class StoreNotifier extends Notifier { 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()); } }