From 0a75f8e55a1752979aedb7db7b2c411c48bc310a Mon Sep 17 00:00:00 2001 From: Prince Soni Date: Sun, 13 Sep 2026 01:46:40 +0530 Subject: [PATCH] Fix VS Code theme not appearing (register in extensions.json) VS Code no longer treats a bare extension folder as installed. It checks ~/.vscode/extensions/extensions.json to know what is installed, so Aether's generated extension folder was never recognized, even though its files were correct. Omarchy's own omarchy-theme-set-vscode script already works around this for its bundled theme by writing a matching entry into extensions.json. This applies the same fix to Aether's ApplyVSCodeTheme: register the extension there after writing its files, and clear any leftover .obsolete marker from a previous uninstall. Existing entries in extensions.json are preserved untouched, so Marketplace extensions keep their metadata. Registration failures are logged rather than failing theme application. Fixes #113 --- internal/theme/applier_vscode.go | 119 ++++++++++++++++++- internal/theme/applier_vscode_test.go | 161 ++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 internal/theme/applier_vscode_test.go diff --git a/internal/theme/applier_vscode.go b/internal/theme/applier_vscode.go index 5d7e26d..6630bdc 100644 --- a/internal/theme/applier_vscode.go +++ b/internal/theme/applier_vscode.go @@ -2,6 +2,8 @@ package theme import ( "embed" + "encoding/json" + "fmt" "log" "os" "path/filepath" @@ -9,6 +11,14 @@ import ( "aether/internal/platform" ) +// vscodeExtensionID and vscodeExtensionVersion identify the generated +// extension in VS Code's own extensions.json. They must match the +// publisher/name/version baked into templates/vscode-extension/package.json. +const ( + vscodeExtensionID = "local.theme-aether" + vscodeExtensionVersion = "1.0.0" +) + // ApplyVSCodeTheme installs the VSCode extension theme by copying the // vscode-extension template directory from the embedded FS to // ~/.vscode/extensions/theme-aether/, processing template variables along @@ -19,7 +29,8 @@ func ApplyVSCodeTheme(fsys embed.FS, templatesDir string, variables map[string]s return err } - extensionDir := filepath.Join(home, ".vscode", "extensions", "local.theme-aether-1.0.0") + extensionsDir := filepath.Join(home, ".vscode", "extensions") + extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0") if err := platform.EnsureDir(extensionDir); err != nil { return err } @@ -28,6 +39,112 @@ func ApplyVSCodeTheme(fsys embed.FS, templatesDir string, variables map[string]s return err } + // Recent VS Code releases only treat an extension as installed once it + // has an entry in extensions.json; a well-formed folder sitting in the + // extensions directory is no longer picked up on its own. Omarchy's own + // VS Code theme hook (omarchy-theme-set-vscode) registers its bundled + // "local.omarchy-theme" extension the same way for the same reason. + if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil { + log.Printf("Failed to register VS Code extension in extensions.json: %v", err) + } + log.Printf("VS Code theme extension installed to: %s", extensionDir) return nil } + +// registerVSCodeExtension records extensionDir as an installed extension in +// extensionsDir/extensions.json, the manifest VS Code consults to decide +// what is installed. Unrelated entries are preserved byte-for-byte via +// json.RawMessage so extensions installed from the Marketplace keep their +// metadata intact. +func registerVSCodeExtension(extensionsDir, extensionDir string) error { + extensionsFile := filepath.Join(extensionsDir, "extensions.json") + + var entries []json.RawMessage + if platform.FileExists(extensionsFile) { + data, err := os.ReadFile(extensionsFile) + if err != nil { + return fmt.Errorf("read %s: %w", extensionsFile, err) + } + if err := json.Unmarshal(data, &entries); err != nil { + return fmt.Errorf("parse %s: %w", extensionsFile, err) + } + } + + filtered := entries[:0] + for _, raw := range entries { + if extensionEntryID(raw) != vscodeExtensionID { + filtered = append(filtered, raw) + } + } + + entry := map[string]interface{}{ + "identifier": map[string]string{"id": vscodeExtensionID}, + "version": vscodeExtensionVersion, + "location": map[string]interface{}{ + "$mid": 1, + "fsPath": extensionDir, + "external": "file://" + extensionDir, + "path": extensionDir, + "scheme": "file", + }, + "relativeLocation": filepath.Base(extensionDir), + } + raw, err := json.Marshal(entry) + if err != nil { + return err + } + filtered = append(filtered, raw) + + if err := platform.WriteJSON(extensionsFile, filtered); err != nil { + return fmt.Errorf("write %s: %w", extensionsFile, err) + } + + return unmarkVSCodeExtensionObsolete(extensionsDir) +} + +// extensionEntryID extracts identifier.id from a raw extensions.json entry, +// returning "" if the entry isn't shaped as expected. +func extensionEntryID(raw json.RawMessage) string { + var entry struct { + Identifier struct { + ID string `json:"id"` + } `json:"identifier"` + } + if err := json.Unmarshal(raw, &entry); err != nil { + return "" + } + return entry.Identifier.ID +} + +// unmarkVSCodeExtensionObsolete removes any stale obsolete-extension marker +// left behind by a previous uninstall of this extension. VS Code will not +// surface an extension that's still listed there, even once it's back in +// extensions.json. +func unmarkVSCodeExtensionObsolete(extensionsDir string) error { + obsoleteFile := filepath.Join(extensionsDir, ".obsolete") + if !platform.FileExists(obsoleteFile) { + return nil + } + + data, err := os.ReadFile(obsoleteFile) + if err != nil { + return fmt.Errorf("read %s: %w", obsoleteFile, err) + } + + var obsolete map[string]json.RawMessage + if err := json.Unmarshal(data, &obsolete); err != nil { + return fmt.Errorf("parse %s: %w", obsoleteFile, err) + } + + key := vscodeExtensionID + "-" + vscodeExtensionVersion + if _, ok := obsolete[key]; !ok { + return nil + } + delete(obsolete, key) + + if len(obsolete) == 0 { + return platform.DeleteFile(obsoleteFile) + } + return platform.WriteJSON(obsoleteFile, obsolete) +} diff --git a/internal/theme/applier_vscode_test.go b/internal/theme/applier_vscode_test.go new file mode 100644 index 0000000..82d4fc5 --- /dev/null +++ b/internal/theme/applier_vscode_test.go @@ -0,0 +1,161 @@ +package theme + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "aether/internal/platform" +) + +func TestApplyVSCodeThemeRegistersExtension(t *testing.T) { + home := setupWriterTestEnv(t) + + variables := map[string]string{"theme_type": "dark"} + if err := ApplyVSCodeTheme(omarchyV4TestTemplates, "testdata/v4", variables); err != nil { + t.Fatalf("ApplyVSCodeTheme() error = %v", err) + } + + extensionsFile := filepath.Join(home, ".vscode", "extensions", "extensions.json") + entries := readExtensionEntries(t, extensionsFile) + entry := findExtensionEntry(t, entries, vscodeExtensionID) + + wantDir := filepath.Join(home, ".vscode", "extensions", "local.theme-aether-1.0.0") + location, ok := entry["location"].(map[string]interface{}) + if !ok { + t.Fatalf("entry[location] = %#v, want an object", entry["location"]) + } + if got := location["path"]; got != wantDir { + t.Errorf("location.path = %v, want %q", got, wantDir) + } + if got := entry["relativeLocation"]; got != "local.theme-aether-1.0.0" { + t.Errorf("relativeLocation = %v, want local.theme-aether-1.0.0", got) + } +} + +func TestRegisterVSCodeExtensionPreservesExistingEntries(t *testing.T) { + extensionsDir := t.TempDir() + extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0") + + marketplaceEntry := `{"identifier":{"id":"ms-python.python","uuid":"abc"},"version":"1.2.3","location":{"$mid":1,"path":"/home/u/.vscode/extensions/ms-python.python-1.2.3","scheme":"file"},"relativeLocation":"ms-python.python-1.2.3","metadata":{"source":"gallery","installedTimestamp":123}}` + writeExtensionsJSON(t, extensionsDir, "["+marketplaceEntry+"]") + + if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil { + t.Fatalf("registerVSCodeExtension() error = %v", err) + } + + entries := readExtensionEntries(t, filepath.Join(extensionsDir, "extensions.json")) + if len(entries) != 2 { + t.Fatalf("len(entries) = %d, want 2: %#v", len(entries), entries) + } + + preserved := findExtensionEntry(t, entries, "ms-python.python") + metadata, ok := preserved["metadata"].(map[string]interface{}) + if !ok || metadata["source"] != "gallery" { + t.Errorf("marketplace entry lost its metadata: %#v", preserved) + } + + findExtensionEntry(t, entries, vscodeExtensionID) +} + +func TestRegisterVSCodeExtensionIsIdempotent(t *testing.T) { + extensionsDir := t.TempDir() + extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0") + + for i := 0; i < 2; i++ { + if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil { + t.Fatalf("registerVSCodeExtension() error = %v", err) + } + } + + entries := readExtensionEntries(t, filepath.Join(extensionsDir, "extensions.json")) + if len(entries) != 1 { + t.Fatalf("len(entries) = %d, want 1 (no duplicates): %#v", len(entries), entries) + } +} + +func TestRegisterVSCodeExtensionClearsObsoleteMarker(t *testing.T) { + extensionsDir := t.TempDir() + extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0") + + obsoleteFile := filepath.Join(extensionsDir, ".obsolete") + key := vscodeExtensionID + "-" + vscodeExtensionVersion + writeFile(t, obsoleteFile, `{"`+key+`":true,"other.ext-1.0.0":true}`) + + if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil { + t.Fatalf("registerVSCodeExtension() error = %v", err) + } + + data, err := os.ReadFile(obsoleteFile) + if err != nil { + t.Fatalf("read .obsolete: %v", err) + } + var obsolete map[string]interface{} + if err := json.Unmarshal(data, &obsolete); err != nil { + t.Fatalf("parse .obsolete: %v", err) + } + if _, ok := obsolete[key]; ok { + t.Errorf(".obsolete still lists %q", key) + } + if _, ok := obsolete["other.ext-1.0.0"]; !ok { + t.Errorf(".obsolete lost unrelated key: %#v", obsolete) + } +} + +func TestRegisterVSCodeExtensionRemovesObsoleteFileWhenEmpty(t *testing.T) { + extensionsDir := t.TempDir() + extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0") + + obsoleteFile := filepath.Join(extensionsDir, ".obsolete") + key := vscodeExtensionID + "-" + vscodeExtensionVersion + writeFile(t, obsoleteFile, `{"`+key+`":true}`) + + if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil { + t.Fatalf("registerVSCodeExtension() error = %v", err) + } + + if platform.FileExists(obsoleteFile) { + t.Error(".obsolete still exists after its only entry was cleared") + } +} + +func writeExtensionsJSON(t *testing.T, extensionsDir, content string) { + t.Helper() + writeFile(t, filepath.Join(extensionsDir, "extensions.json"), content) +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func readExtensionEntries(t *testing.T, path string) []map[string]interface{} { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var entries []map[string]interface{} + if err := json.Unmarshal(data, &entries); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return entries +} + +func findExtensionEntry(t *testing.T, entries []map[string]interface{}, id string) map[string]interface{} { + t.Helper() + for _, entry := range entries { + identifier, ok := entry["identifier"].(map[string]interface{}) + if ok && identifier["id"] == id { + return entry + } + } + t.Fatalf("no entry with identifier.id = %q in %#v", id, entries) + return nil +}