@@ -72,22 +72,23 @@ final class ThemeRegistryInstaller {
7272
7373 progress ( 0.7 )
7474
75- // Extract ZIP
75+ // Extract ZIP off main thread
7676 let extractDir = tempDir. appendingPathComponent ( " extracted " , isDirectory: true )
7777 try FileManager . default. createDirectory ( at: extractDir, withIntermediateDirectories: true )
7878
7979 let zipPath = tempDir. appendingPathComponent ( " theme.zip " )
8080 try FileManager . default. moveItem ( at: tempDownloadURL, to: zipPath)
8181
82- let process = Process ( )
83- process. executableURL = URL ( fileURLWithPath: " /usr/bin/ditto " )
84- process. arguments = [ " -xk " , zipPath. path, extractDir. path]
85- try process. run ( )
86- process. waitUntilExit ( )
87-
88- guard process. terminationStatus == 0 else {
89- throw PluginError . installFailed ( " Failed to extract theme archive " )
90- }
82+ try await Task . detached ( priority: . userInitiated) {
83+ let process = Process ( )
84+ process. executableURL = URL ( fileURLWithPath: " /usr/bin/ditto " )
85+ process. arguments = [ " -xk " , zipPath. path, extractDir. path]
86+ try process. run ( )
87+ process. waitUntilExit ( )
88+ guard process. terminationStatus == 0 else {
89+ throw PluginError . installFailed ( " Failed to extract theme archive " )
90+ }
91+ } . value
9192
9293 // Find all JSON files in extracted directory
9394 let jsonFiles = try findJsonFiles ( in: extractDir)
@@ -97,20 +98,25 @@ final class ThemeRegistryInstaller {
9798
9899 progress ( 0.9 )
99100
100- // Validate and install each theme
101- var installedThemes : [ InstalledRegistryTheme ] = [ ]
101+ // Decode all themes first to validate before writing any files
102102 let decoder = JSONDecoder ( )
103+ var decodedThemes : [ ThemeDefinition ] = [ ]
103104
104105 for jsonURL in jsonFiles {
105106 let data = try Data ( contentsOf: jsonURL)
106107 var theme = try decoder. decode ( ThemeDefinition . self, from: data)
107108
108- // Rewrite ID to registry namespace
109- let originalSuffix = theme. id. contains ( " . " ) ?
110- String ( theme. id. split ( separator: " . " ) . last ?? Substring ( theme. id) ) :
111- theme. id
112- theme. id = " registry. \( plugin. id) . \( originalSuffix) "
109+ // Rewrite ID to registry namespace using full original ID (dots replaced with hyphens)
110+ let sanitizedId = theme. id. replacingOccurrences ( of: " . " , with: " - " )
111+ theme. id = " registry. \( plugin. id) . \( sanitizedId) "
112+
113+ decodedThemes. append ( theme)
114+ }
115+
116+ // All decoded successfully — now write atomically
117+ var installedThemes : [ InstalledRegistryTheme ] = [ ]
113118
119+ for theme in decodedThemes {
114120 try ThemeStorage . saveRegistryTheme ( theme)
115121
116122 installedThemes. append ( InstalledRegistryTheme (
@@ -138,13 +144,19 @@ final class ThemeRegistryInstaller {
138144 var meta = ThemeStorage . loadRegistryMeta ( )
139145 let themesToRemove = meta. installed. filter { $0. registryPluginId == registryPluginId }
140146
141- for entry in themesToRemove {
142- try ThemeStorage . deleteRegistryTheme ( id: entry. id)
143- }
144-
147+ // Update meta first so state is always consistent even if file cleanup fails
145148 meta. installed. removeAll { $0. registryPluginId == registryPluginId }
146149 try ThemeStorage . saveRegistryMeta ( meta)
147150
151+ // Best-effort file cleanup
152+ for entry in themesToRemove {
153+ do {
154+ try ThemeStorage . deleteRegistryTheme ( id: entry. id)
155+ } catch {
156+ Self . logger. warning ( " Failed to delete registry theme file \( entry. id) : \( error) " )
157+ }
158+ }
159+
148160 ThemeEngine . shared. reloadAvailableThemes ( )
149161
150162 // Fall back if the active theme was uninstalled
0 commit comments