Skip to content

Commit fe2ce29

Browse files
committed
feat: Implement database backup and restore for wasmJs
- Implement `DatabaseFileTransfer` for `wasmJs` using the Browser File System Access API and Origin Private File System (OPFS). - Create `WasmBrowserFileSystemApi` with JS interop functions to support `showSaveFilePicker`, `showOpenFilePicker`, and writable streams. - Introduce `WasmDatabaseTransferRegistry` to manage file handles and source references across the backup domain and UI. - Implement `WasmDatabaseFilePicker` to handle platform-specific file picker interactions in the UI layer. - Update `DatabaseFileTransfer.copyDatabase` to be a `suspend` function across all platforms to support asynchronous web APIs. - Update project documentation and feature matrix to reflect backup support for the web platform. - Add necessary coroutines and domain dependencies to the backup UI module for `wasmJs`.
1 parent 58a1a9c commit fe2ce29

File tree

8 files changed

+273
-12
lines changed

8 files changed

+273
-12
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Supported platforms:
4040
|:------------------:|:-------:|:---:|:------------:|:---:|
4141
| database |||||
4242
| encryption |||| |
43-
| backup |||| |
43+
| backup |||| |
4444

4545
Check out [CONTRIBUTING.md](/CONTRIBUTING.md) if you want to develop missing features.
4646

feature/backup/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ Backup feature modules that handle database import/export and user-facing file s
1414

1515
## Notes
1616

17-
- Non-WASM targets use Okio-based transfer; WASM throws `UnsupportedOperationException`.
17+
- Non-WASM targets use Okio-based transfer.
18+
- WASM uses browser file pickers and OPFS copy logic for import/export.

feature/backup/domain/src/commonMain/kotlin/com/softartdev/notedelight/repository/DatabaseFileTransfer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ package com.softartdev.notedelight.repository
22

33
expect object DatabaseFileTransfer {
44

5-
fun copyDatabase(sourcePath: String, destinationPath: String)
5+
suspend fun copyDatabase(sourcePath: String, destinationPath: String)
66
}

feature/backup/domain/src/nonWasmMain/kotlin/com/softartdev/notedelight/repository/DatabaseFileTransfer.nonWasm.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ import okio.SYSTEM
66

77
actual object DatabaseFileTransfer {
88

9-
actual fun copyDatabase(sourcePath: String, destinationPath: String) = FileSystem.SYSTEM
9+
actual suspend fun copyDatabase(sourcePath: String, destinationPath: String) = FileSystem.SYSTEM
1010
.copy(source = sourcePath.toPath(), target = destinationPath.toPath())
1111
}
Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,50 @@
1+
@file:OptIn(ExperimentalWasmJsInterop::class)
2+
13
package com.softartdev.notedelight.repository
24

5+
import kotlinx.coroutines.await
6+
import kotlin.js.JsAny
7+
import kotlin.js.Promise
8+
39
actual object DatabaseFileTransfer {
410

5-
actual fun copyDatabase(sourcePath: String, destinationPath: String) {
6-
throw UnsupportedOperationException("Database import/export is not supported on wasmJs")
11+
actual suspend fun copyDatabase(sourcePath: String, destinationPath: String) {
12+
val exportTarget: JsAny? = WasmDatabaseTransferRegistry.consumeExportTarget(destinationPath)
13+
if (exportTarget != null) {
14+
exportToHandle(sourcePath = sourcePath, exportTarget = exportTarget)
15+
return
16+
}
17+
val importSource: JsAny? = WasmDatabaseTransferRegistry.consumeImportSource(sourcePath)
18+
if (importSource != null) {
19+
importFromSource(importSource = importSource, destinationPath = destinationPath)
20+
return
21+
}
22+
throw UnsupportedOperationException("Database import/export on wasmJs requires a browser-picked source/target")
23+
}
24+
25+
private suspend fun exportToHandle(sourcePath: String, exportTarget: JsAny) {
26+
val sourceHandle = wasmOpfsGetFileHandle(sourcePath, create = false).awaitJsAny()
27+
val sourceFile = wasmGetFileFromHandle(sourceHandle).awaitJsAny()
28+
val sourceBytes = wasmArrayBuffer(sourceFile).awaitJsAny()
29+
val writableStream = wasmCreateWritable(exportTarget).awaitJsAny()
30+
try {
31+
wasmWritableWrite(writableStream, sourceBytes).await<JsAny?>()
32+
} finally {
33+
wasmWritableClose(writableStream).await<JsAny?>()
34+
}
735
}
36+
37+
private suspend fun importFromSource(importSource: JsAny, destinationPath: String) {
38+
val sourceFile = wasmResolveImportFile(importSource).awaitJsAny()
39+
val sourceBytes = wasmArrayBuffer(sourceFile).awaitJsAny()
40+
val destinationHandle = wasmOpfsGetFileHandle(destinationPath, create = true).awaitJsAny()
41+
val writableStream = wasmCreateWritable(destinationHandle).awaitJsAny()
42+
try {
43+
wasmWritableWrite(writableStream, sourceBytes).await<JsAny?>()
44+
} finally {
45+
wasmWritableClose(writableStream).await<JsAny?>()
46+
}
47+
}
48+
49+
private suspend fun Promise<JsAny?>.awaitJsAny(): JsAny = requireNotNull(await())
850
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
@file:OptIn(ExperimentalWasmJsInterop::class)
2+
3+
package com.softartdev.notedelight.repository
4+
5+
import kotlin.js.JsAny
6+
import kotlin.js.Promise
7+
8+
@JsFun(
9+
"() => typeof window !== 'undefined' && " +
10+
"(typeof window.showDirectoryPicker === 'function' || typeof window.showSaveFilePicker === 'function')"
11+
)
12+
external fun wasmSupportsExportPicker(): Boolean
13+
14+
@JsFun("() => typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function'")
15+
external fun wasmSupportsOpenFilePicker(): Boolean
16+
17+
@JsFun(
18+
"""
19+
(defaultFileName) => {
20+
const exportPickerId = "notedelight-db-export-save-v4";
21+
const exportDirectoryPickerId = "notedelight-db-export-dir-v4";
22+
const savePickerOptions = [
23+
{
24+
id: exportPickerId,
25+
suggestedName: defaultFileName,
26+
startIn: "downloads",
27+
types: [
28+
{
29+
description: "SQLite database",
30+
accept: { "application/octet-stream": [".db"] },
31+
},
32+
],
33+
},
34+
{
35+
id: exportPickerId,
36+
suggestedName: defaultFileName,
37+
types: [
38+
{
39+
description: "SQLite database",
40+
accept: { "application/octet-stream": [".db"] },
41+
},
42+
],
43+
},
44+
{ id: exportPickerId, suggestedName: defaultFileName },
45+
];
46+
const pickerOptions = [
47+
{ id: exportDirectoryPickerId, mode: "readwrite", startIn: "downloads" },
48+
{ id: exportDirectoryPickerId, mode: "readwrite", startIn: "documents" },
49+
{ mode: "readwrite" },
50+
undefined,
51+
];
52+
const shouldRetry = (error) => {
53+
const errorName = error && typeof error.name === "string" ? error.name : "";
54+
return errorName !== "AbortError" && errorName !== "NotAllowedError";
55+
};
56+
const pickWithSaveDialog = (index) => {
57+
const options = savePickerOptions[index];
58+
try {
59+
console.info("[NoteDelight] export picker: trying showSaveFilePicker", index, options);
60+
return window.showSaveFilePicker(options).catch((error) => {
61+
if (!shouldRetry(error) || index >= savePickerOptions.length - 1) {
62+
throw error;
63+
}
64+
return pickWithSaveDialog(index + 1);
65+
});
66+
} catch (error) {
67+
if (!shouldRetry(error) || index >= savePickerOptions.length - 1) {
68+
throw error;
69+
}
70+
return pickWithSaveDialog(index + 1);
71+
}
72+
};
73+
const pickFromDirectory = (index) => {
74+
const options = pickerOptions[index];
75+
try {
76+
console.info("[NoteDelight] export picker: trying showDirectoryPicker", index, options);
77+
return window.showDirectoryPicker(options)
78+
.then((directoryHandle) => directoryHandle.getFileHandle(defaultFileName, { create: true }))
79+
.catch((error) => {
80+
if (!shouldRetry(error) || index >= pickerOptions.length - 1) {
81+
throw error;
82+
}
83+
return pickFromDirectory(index + 1);
84+
});
85+
} catch (error) {
86+
if (!shouldRetry(error) || index >= pickerOptions.length - 1) {
87+
throw error;
88+
}
89+
return pickFromDirectory(index + 1);
90+
}
91+
};
92+
const savePickerSupported = typeof window.showSaveFilePicker === "function";
93+
const directoryPickerSupported = typeof window.showDirectoryPicker === "function";
94+
if (savePickerSupported) {
95+
console.info("[NoteDelight] export picker: using save-file dialog");
96+
return pickWithSaveDialog(0);
97+
}
98+
if (directoryPickerSupported) {
99+
console.info("[NoteDelight] export picker: save-file unavailable, using directory picker");
100+
return pickFromDirectory(0);
101+
}
102+
console.info("[NoteDelight] export picker: no supported API");
103+
return Promise.reject(new Error("Export picker is not supported"));
104+
}
105+
"""
106+
)
107+
external fun wasmPickExportFileHandle(defaultFileName: String): Promise<JsAny?>
108+
109+
@JsFun("() => window.showOpenFilePicker()")
110+
external fun wasmShowOpenFilePicker(): Promise<JsAny?>
111+
112+
@JsFun("(items) => Array.isArray(items) && items.length > 0 ? items[0] : null")
113+
external fun wasmFirstArrayItem(items: JsAny?): JsAny?
114+
115+
@JsFun("(handle) => (handle && typeof handle.name === 'string') ? handle.name : ''")
116+
external fun wasmFileHandleName(handle: JsAny?): String
117+
118+
@JsFun("(fileName, create) => navigator.storage.getDirectory().then((root) => root.getFileHandle(fileName, { create }))")
119+
external fun wasmOpfsGetFileHandle(fileName: String, create: Boolean): Promise<JsAny?>
120+
121+
@JsFun("(fileHandle) => fileHandle.getFile()")
122+
external fun wasmGetFileFromHandle(fileHandle: JsAny): Promise<JsAny?>
123+
124+
@JsFun("(source) => (source && typeof source.getFile === 'function') ? source.getFile() : Promise.resolve(source)")
125+
external fun wasmResolveImportFile(source: JsAny): Promise<JsAny?>
126+
127+
@JsFun("(file) => file.arrayBuffer()")
128+
external fun wasmArrayBuffer(file: JsAny): Promise<JsAny?>
129+
130+
@JsFun("(fileHandle) => fileHandle.createWritable()")
131+
external fun wasmCreateWritable(fileHandle: JsAny): Promise<JsAny?>
132+
133+
@JsFun("(writable, data) => writable.write(data)")
134+
external fun wasmWritableWrite(writable: JsAny, data: JsAny): Promise<JsAny?>
135+
136+
@JsFun("(writable) => writable.close()")
137+
external fun wasmWritableClose(writable: JsAny): Promise<JsAny?>
138+
139+
object WasmDatabaseTransferRegistry {
140+
private var generatedPathIndex: Int = 0
141+
private val exportTargetsByPath: MutableMap<String, JsAny> = mutableMapOf()
142+
private val importSourcesByPath: MutableMap<String, JsAny> = mutableMapOf()
143+
144+
fun registerExportTarget(pathHint: String, fileHandle: JsAny): String {
145+
val normalizedPath = normalizePathHint(pathHint)
146+
exportTargetsByPath[normalizedPath] = fileHandle
147+
return normalizedPath
148+
}
149+
150+
fun registerImportSource(pathHint: String, source: JsAny): String {
151+
val normalizedPath = normalizePathHint(pathHint)
152+
importSourcesByPath[normalizedPath] = source
153+
return normalizedPath
154+
}
155+
156+
internal fun consumeExportTarget(path: String): JsAny? = exportTargetsByPath.remove(path)
157+
158+
internal fun consumeImportSource(path: String): JsAny? = importSourcesByPath.remove(path)
159+
160+
private fun normalizePathHint(pathHint: String): String {
161+
if (pathHint.isNotBlank()) return pathHint
162+
generatedPathIndex += 1
163+
return "backup-$generatedPathIndex.db"
164+
}
165+
}

feature/backup/ui/build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ kotlin {
5050
val jvmMain by getting
5151
val iosArm64Main by getting
5252
val iosSimulatorArm64Main by getting
53-
val wasmJsMain by getting
53+
val wasmJsMain by getting {
54+
dependencies {
55+
implementation(projects.feature.backup.domain)
56+
implementation(libs.coroutines.core)
57+
}
58+
}
5459
}
5560
compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes")
5661
}
Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,70 @@
1+
@file:OptIn(ExperimentalWasmJsInterop::class)
2+
13
package com.softartdev.notedelight.ui.settings.detail
24

35
import androidx.compose.runtime.Composable
46
import androidx.compose.runtime.remember
7+
import androidx.compose.runtime.rememberCoroutineScope
8+
import co.touchlab.kermit.Logger
9+
import com.softartdev.notedelight.repository.WasmDatabaseTransferRegistry
10+
import com.softartdev.notedelight.repository.wasmFileHandleName
11+
import com.softartdev.notedelight.repository.wasmFirstArrayItem
12+
import com.softartdev.notedelight.repository.wasmPickExportFileHandle
13+
import com.softartdev.notedelight.repository.wasmShowOpenFilePicker
14+
import com.softartdev.notedelight.repository.wasmSupportsExportPicker
15+
import com.softartdev.notedelight.repository.wasmSupportsOpenFilePicker
16+
import kotlinx.coroutines.CoroutineScope
17+
import kotlinx.coroutines.CoroutineStart
18+
import kotlinx.coroutines.await
19+
import kotlinx.coroutines.launch
20+
import kotlin.js.JsAny
521

622
@Composable
7-
actual fun rememberPlatformDatabaseFilePicker(): DatabaseFilePicker = remember {
8-
return@remember WasmDatabaseFilePicker()
23+
actual fun rememberPlatformDatabaseFilePicker(): DatabaseFilePicker {
24+
val coroutineScope = rememberCoroutineScope()
25+
return remember { WasmDatabaseFilePicker(coroutineScope = coroutineScope) }
926
}
1027

11-
class WasmDatabaseFilePicker : DatabaseFilePicker {
28+
class WasmDatabaseFilePicker(private val coroutineScope: CoroutineScope) : DatabaseFilePicker {
1229

1330
/**
1431
* Launches when the user clicks the export button on the settings screen.
1532
*/
16-
override fun launchExport(defaultFileName: String, onPicked: (String?) -> Unit) = onPicked(null)
33+
override fun launchExport(defaultFileName: String, onPicked: (String?) -> Unit) {
34+
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
35+
val pickedPath: String? = runCatching {
36+
if (!wasmSupportsExportPicker()) return@runCatching null
37+
val targetHandle = wasmPickExportFileHandle(defaultFileName).await<JsAny?>()
38+
?: return@runCatching null
39+
WasmDatabaseTransferRegistry.registerExportTarget(
40+
pathHint = defaultFileName,
41+
fileHandle = targetHandle
42+
)
43+
}.onFailure { throwable ->
44+
Logger.withTag("WasmDatabaseFilePicker").d(throwable) { "Export picker canceled or unavailable" }
45+
}.getOrNull()
46+
onPicked(pickedPath)
47+
}
48+
}
1749

1850
/**
1951
* Launches when the user clicks the import button on the settings screen.
2052
*/
21-
override fun launchImport(onPicked: (String?) -> Unit) = onPicked(null)
53+
override fun launchImport(onPicked: (String?) -> Unit) {
54+
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
55+
val pickedPath: String? = runCatching {
56+
if (!wasmSupportsOpenFilePicker()) return@runCatching null
57+
val pickedHandles = wasmShowOpenFilePicker().await<JsAny?>()
58+
val sourceHandle: JsAny = wasmFirstArrayItem(pickedHandles)
59+
?: return@runCatching null
60+
WasmDatabaseTransferRegistry.registerImportSource(
61+
pathHint = wasmFileHandleName(sourceHandle),
62+
source = sourceHandle
63+
)
64+
}.onFailure { throwable ->
65+
Logger.withTag("WasmDatabaseFilePicker").d(throwable) { "Import picker canceled or unavailable" }
66+
}.getOrNull()
67+
onPicked(pickedPath)
68+
}
69+
}
2270
}

0 commit comments

Comments
 (0)