@@ -6,6 +6,7 @@ import kotlinx.serialization.decodeFromString
66import kotlinx.serialization.encodeToString
77import kotlinx.serialization.json.Json
88import soy.iko.opencode.data.model.ServerProfile
9+import soy.iko.opencode.util.runCatchingCancellable
910
1011/** A server entry in a backup. [password] is present only when the user opted to include it. */
1112@Serializable
@@ -137,78 +138,64 @@ class BackupManager(
137138 require(data.version in supportedVersions) {
138139 "Unsupported backup version ${data.version}; supported: $supportedVersions"
139140 }
141+ var hadError = false
140142 for (s in data.servers) {
141− val username = s.username?.takeIf { it.isNotBlank() }
142− val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
143− // A backup exported *without* passwords carries null. This is an upsert, so don't let
144− // that null wipe a password already stored for this server id — preserve the existing
145− // secret when the backup didn't include one (only the backup explicitly carrying a
146− // password should overwrite it). Re-importing a password-less backup would otherwise
147− // silently break auth for every matching server.
148− val password = incomingPassword ?: username?.let {
149− profileStore.resolve(
150− ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
151− ).password
152− }
153− profileStore.save(
154− ServerProfile(
155− id = s.id,
156− label = s.label,
157− baseUrl = s.baseUrl,
158− username = username,
159− password = password,
160− lastUsed = 0,
161− requireHttps = s.requireHttps,
162− certPin = s.certPin?.takeIf { it.isNotBlank() },
163− ),
164− )
143+ runCatchingCancellable {
144+ val username = s.username?.takeIf { it.isNotBlank() }
145+ val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
146+ val password = incomingPassword ?: username?.let {
147+ profileStore.resolve(
148+ ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
149+ ).password
150+ }
151+ profileStore.save(
152+ ServerProfile(
153+ id = s.id,
154+ label = s.label,
155+ baseUrl = s.baseUrl,
156+ username = username,
157+ password = password,
158+ lastUsed = 0,
159+ requireHttps = s.requireHttps,
160+ certPin = s.certPin?.takeIf { it.isNotBlank() },
161+ ),
162+ )
163+ }.onFailure { hadError = true }
165164 }
166165 data.settings?.let { settings ->
167− // Parse the enum defensively (a bad name from an edited backup shouldn't abort the
168− // import) WITHOUT wrapping the suspend setThemeMode call — wrapping it would swallow
169− // a CancellationException and break structured concurrency.
170− ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
171− settingsStore.setDynamicColor(settings.dynamicColor)
172− settingsStore.setSendOnEnter(settings.sendOnEnter)
173− settingsStore.setAppLock(settings.appLock)
174− settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
175− settingsStore.setChatTextScale(settings.chatTextScale)
176− settingsStore.setCodeWrap(settings.codeWrap)
177− // sessionSortMode is written below, AFTER validation against the known enum
178− // entries — do NOT write it unconditionally here, or a garbage string from a
179− // hand-edited/malicious backup would persist with no in-app reset path (the
180− // validated write below only fires when the enum is found, so an earlier
181− // unconditional write would leave the corrupt value in place when it's not).
182− settingsStore.setSessionSortDescending(settings.sessionSortDescending)
183− settingsStore.setSessionShowArchived(settings.sessionShowArchived)
184− settingsStore.setPreferredModelId(settings.preferredModelId)
185− settingsStore.setPreferredAgentName(settings.preferredAgentName)
186− settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
187− settingsStore.setHapticsEnabled(settings.hapticsEnabled)
188− settingsStore.setReducedMotion(settings.reducedMotion)
189− // Validate the sort mode against the known enum entries before persisting, so a
190− // garbage string from a hand-edited/malicious backup can't produce a broken sort
191− // with no in-app reset path (the picker writes known values, but a corrupt one
192− // would persist and leave the list in an undefined order). Mirrors the defensive
193− // enum parsing used for themeMode and swipe actions above.
194− soy.iko.opencode.ui.session.SessionSortMode.entries
195− .find { it.name == settings.sessionSortMode }
196− ?.let { settingsStore.setSessionSortMode(it.name) }
197− // Validate the language override is a plausible ISO 639-1 code (2–3 lowercase
198− // letters, optionally with a region/script subtag) before persisting, so a bad
199− // string doesn't reach AppCompat's per-app language API. Empty (system default)
200− // is always allowed.
201− val lang = settings.languageOverride
202− if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
203− settingsStore.setLanguageOverride(lang)
204− }
205− settingsStore.setNotifRunComplete(settings.notifRunComplete)
206− settingsStore.setNotifPermission(settings.notifPermission)
207− settingsStore.setNotifError(settings.notifError)
208− SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
209− SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
166+ runCatchingCancellable {
167+ ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
168+ settingsStore.setDynamicColor(settings.dynamicColor)
169+ settingsStore.setSendOnEnter(settings.sendOnEnter)
170+ settingsStore.setAppLock(settings.appLock)
171+ settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
172+ settingsStore.setChatTextScale(settings.chatTextScale)
173+ settingsStore.setCodeWrap(settings.codeWrap)
174+ settingsStore.setSessionSortDescending(settings.sessionSortDescending)
175+ settingsStore.setSessionShowArchived(settings.sessionShowArchived)
176+ settingsStore.setPreferredModelId(settings.preferredModelId)
177+ settingsStore.setPreferredAgentName(settings.preferredAgentName)
178+ settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
179+ settingsStore.setHapticsEnabled(settings.hapticsEnabled)
180+ settingsStore.setReducedMotion(settings.reducedMotion)
181+ soy.iko.opencode.ui.session.SessionSortMode.entries
182+ .find { it.name == settings.sessionSortMode }
183+ ?.let { settingsStore.setSessionSortMode(it.name) }
184+ val lang = settings.languageOverride
185+ if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
186+ settingsStore.setLanguageOverride(lang)
187+ }
188+ settingsStore.setNotifRunComplete(settings.notifRunComplete)
189+ settingsStore.setNotifPermission(settings.notifPermission)
190+ settingsStore.setNotifError(settings.notifError)
191+ SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
192+ SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
193+ }.onFailure { hadError = true }
210194 }
211− data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
212− data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
195+ runCatchingCancellable {
196+ data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
197+ data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
198+ }.onFailure { hadError = true }
199+ if (hadError) throw IllegalStateException("Backup import completed with errors")
213200 }
214201}