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:
zarzet
2026-07-09 18:19:08 +07:00
parent 6b30eba947
commit 8abb9d119b
3 changed files with 99 additions and 12 deletions
@@ -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{
+33 -10
View File
@@ -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, &registry); 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 = &registry
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, &registry); 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 &registry, nil
}
+18 -2
View File
@@ -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());
}
}