pakstr 0.31.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android-template/app/build.gradle.kts +1 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/PakstrWebBridgeTest.kt +153 -0
- package/android-template/app/src/main/assets/nip07.js +124 -74
- package/android-template/app/src/main/java/com/pakstr/app/AppOrigin.kt +77 -0
- package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +5 -5
- package/android-template/app/src/main/java/com/pakstr/app/PakstrWebBridge.kt +89 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebMessageRouter.kt +186 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +65 -49
- package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +69 -489
- package/android-template/app/src/test/java/com/pakstr/app/AppOriginTest.kt +114 -0
- package/android-template/app/src/test/java/com/pakstr/app/WebMessageRouterTest.kt +279 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeDispatchTest.kt +113 -0
- package/android-template/gradle/libs.versions.toml +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.signer.NostrBridge
|
|
4
|
+
import com.pakstr.app.signer.UnsupportedBridgeMethodException
|
|
5
|
+
import kotlinx.coroutines.CancellationException
|
|
6
|
+
import kotlinx.coroutines.CoroutineScope
|
|
7
|
+
import kotlinx.coroutines.launch
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
/** Where a bridge message came from (android.net.Uri components plus the frame flag). */
|
|
11
|
+
internal data class MessageSource(
|
|
12
|
+
val scheme: String?,
|
|
13
|
+
val host: String?,
|
|
14
|
+
val port: Int,
|
|
15
|
+
val userInfo: String?,
|
|
16
|
+
val path: String?,
|
|
17
|
+
val isMainFrame: Boolean
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
internal fun interface BridgeHandler {
|
|
21
|
+
suspend fun handle(method: String, params: JSONObject?): Any?
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validates and dispatches messages posted to the injected bridge object.
|
|
26
|
+
*
|
|
27
|
+
* Fail closed: messages from subframes or other origins, non-string or
|
|
28
|
+
* oversized data, and malformed envelopes are dropped without a reply.
|
|
29
|
+
* A request `{ v: 1, id, method, params? }` is answered once, only to the
|
|
30
|
+
* frame that sent it, with `{ v: 1, id, ok: true, result }` or
|
|
31
|
+
* `{ v: 1, id, ok: false, error: { error, message } }`. Notifications
|
|
32
|
+
* (no reply) carry no id.
|
|
33
|
+
*/
|
|
34
|
+
internal class WebMessageRouter(
|
|
35
|
+
private val handler: BridgeHandler,
|
|
36
|
+
private val scope: CoroutineScope,
|
|
37
|
+
private val runOnMain: (() -> Unit) -> Unit,
|
|
38
|
+
private val log: (String) -> Unit = {}
|
|
39
|
+
) {
|
|
40
|
+
|
|
41
|
+
fun onMessage(
|
|
42
|
+
source: MessageSource,
|
|
43
|
+
data: String?,
|
|
44
|
+
reply: (String) -> Unit
|
|
45
|
+
) {
|
|
46
|
+
rejectionReason(source, data)?.let { reason ->
|
|
47
|
+
log("Bridge message rejected: $reason")
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
val message = try {
|
|
51
|
+
JSONObject(requireNotNull(data))
|
|
52
|
+
} catch (_: Exception) {
|
|
53
|
+
log("Bridge message rejected: malformed message")
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (message.opt("v") != PROTOCOL_VERSION) {
|
|
57
|
+
log("Bridge message rejected: unsupported protocol version")
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
val method = message.opt("method") as? String
|
|
62
|
+
val params = when (val value = message.opt("params")) {
|
|
63
|
+
null, JSONObject.NULL -> null
|
|
64
|
+
is JSONObject -> value
|
|
65
|
+
else -> INVALID_PARAMS
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (method in NOTIFICATIONS) {
|
|
69
|
+
if (params !== INVALID_PARAMS) {
|
|
70
|
+
scope.launch {
|
|
71
|
+
try {
|
|
72
|
+
handler.handle(requireNotNull(method), params)
|
|
73
|
+
} catch (error: CancellationException) {
|
|
74
|
+
throw error
|
|
75
|
+
} catch (_: Exception) {
|
|
76
|
+
// Notifications have no reply channel.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
val id = message.opt("id") as? String
|
|
84
|
+
if (id == null || !REQUEST_ID.matches(id)) {
|
|
85
|
+
log("Bridge message rejected: invalid request id")
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (method == null || params === INVALID_PARAMS) {
|
|
89
|
+
val response = failure(id, IllegalArgumentException("Invalid request"))
|
|
90
|
+
runOnMain { reply(response) }
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
scope.launch {
|
|
95
|
+
val response = try {
|
|
96
|
+
success(id, handler.handle(method, params))
|
|
97
|
+
} catch (error: CancellationException) {
|
|
98
|
+
throw error
|
|
99
|
+
} catch (error: Exception) {
|
|
100
|
+
failure(id, error)
|
|
101
|
+
}
|
|
102
|
+
runOnMain { reply(response) }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private fun rejectionReason(
|
|
107
|
+
source: MessageSource,
|
|
108
|
+
data: String?
|
|
109
|
+
): String? = when {
|
|
110
|
+
!source.isMainFrame -> "subframe"
|
|
111
|
+
!AppOrigin.isTrustedOrigin(
|
|
112
|
+
source.scheme,
|
|
113
|
+
source.host,
|
|
114
|
+
source.port,
|
|
115
|
+
source.userInfo,
|
|
116
|
+
source.path
|
|
117
|
+
) -> "untrusted origin"
|
|
118
|
+
data == null -> "non-string message"
|
|
119
|
+
data.length > MAX_MESSAGE_BYTES ||
|
|
120
|
+
data.toByteArray(Charsets.UTF_8).size > MAX_MESSAGE_BYTES -> "message too large"
|
|
121
|
+
else -> null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun success(id: String, result: Any?): String =
|
|
125
|
+
envelope(id)
|
|
126
|
+
.put("ok", true)
|
|
127
|
+
.put("result", result ?: JSONObject.NULL)
|
|
128
|
+
.toString()
|
|
129
|
+
|
|
130
|
+
private fun failure(id: String, error: Exception): String =
|
|
131
|
+
envelope(id)
|
|
132
|
+
.put("ok", false)
|
|
133
|
+
.put("error", JSONObject(NostrBridge.signerErrorResponse(error)))
|
|
134
|
+
.toString()
|
|
135
|
+
|
|
136
|
+
private fun envelope(id: String): JSONObject =
|
|
137
|
+
JSONObject()
|
|
138
|
+
.put("v", PROTOCOL_VERSION)
|
|
139
|
+
.put("id", id)
|
|
140
|
+
|
|
141
|
+
companion object {
|
|
142
|
+
const val PROTOCOL_VERSION = 1
|
|
143
|
+
|
|
144
|
+
/** Same limit as nip07.js, which rejects larger requests before posting them. */
|
|
145
|
+
const val MAX_MESSAGE_BYTES = 1024 * 1024
|
|
146
|
+
|
|
147
|
+
const val DEBUG_LOG_METHOD = "debug.log"
|
|
148
|
+
|
|
149
|
+
private val NOTIFICATIONS = setOf(DEBUG_LOG_METHOD)
|
|
150
|
+
private val REQUEST_ID = Regex("[A-Za-z0-9-]{1,64}")
|
|
151
|
+
private val INVALID_PARAMS = JSONObject()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Routes `nostr.*`, `runtime.*`, and `debug.log` to their native handlers. */
|
|
156
|
+
internal class PakstrBridgeMethods(
|
|
157
|
+
private val nostr: NostrBridge,
|
|
158
|
+
private val runtime: PakstrBridge,
|
|
159
|
+
private val debugLog: (level: String, message: String) -> Unit
|
|
160
|
+
) : BridgeHandler {
|
|
161
|
+
|
|
162
|
+
override suspend fun handle(method: String, params: JSONObject?): Any? =
|
|
163
|
+
when (method) {
|
|
164
|
+
"runtime.getApiBaseUrl" -> runtime.getApiBaseUrl()
|
|
165
|
+
"runtime.setApiBaseUrl" ->
|
|
166
|
+
(params?.opt("url") as? String)?.let(runtime::setApiBaseUrl) ?: false
|
|
167
|
+
"runtime.getNip55CallbackUrl" -> runtime.getNip55CallbackUrl()
|
|
168
|
+
WebMessageRouter.DEBUG_LOG_METHOD -> {
|
|
169
|
+
debugLog(
|
|
170
|
+
params?.opt("level") as? String ?: "LOG",
|
|
171
|
+
params?.opt("message") as? String ?: ""
|
|
172
|
+
)
|
|
173
|
+
null
|
|
174
|
+
}
|
|
175
|
+
else ->
|
|
176
|
+
if (method.startsWith(NOSTR_PREFIX)) {
|
|
177
|
+
nostr.handle(method.removePrefix(NOSTR_PREFIX), params)
|
|
178
|
+
} else {
|
|
179
|
+
throw UnsupportedBridgeMethodException()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private companion object {
|
|
184
|
+
const val NOSTR_PREFIX = "nostr."
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -31,8 +31,7 @@ import kotlinx.coroutines.withContext
|
|
|
31
31
|
* Responsibilities:
|
|
32
32
|
* - Configure secure WebView settings
|
|
33
33
|
* - Load local web application from NanoHTTPD server
|
|
34
|
-
* -
|
|
35
|
-
* - Expose Android signer bridge to JavaScript
|
|
34
|
+
* - Install the origin-checked JavaScript bridge and NIP-07 provider
|
|
36
35
|
* - Handle Web permissions
|
|
37
36
|
* - Cleanup WebView resources
|
|
38
37
|
*/
|
|
@@ -52,6 +51,7 @@ class WebViewController(
|
|
|
52
51
|
private var pendingNip55Result: String? = null
|
|
53
52
|
private var isSetup = false
|
|
54
53
|
private val nip55Scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
54
|
+
private val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
55
55
|
|
|
56
56
|
// Keeps a reference to the runnable to prevent memory leaks if destroyed early
|
|
57
57
|
private val loadRunnable = Runnable {
|
|
@@ -63,8 +63,6 @@ class WebViewController(
|
|
|
63
63
|
//webView.loadUrl("http://127.0.0.1:$port/not-found.html")
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
private var nip07Injected = false
|
|
67
|
-
|
|
68
66
|
fun setup() {
|
|
69
67
|
showLoader()
|
|
70
68
|
setupCookies()
|
|
@@ -121,10 +119,6 @@ class WebViewController(
|
|
|
121
119
|
|
|
122
120
|
hideLoader()
|
|
123
121
|
|
|
124
|
-
if (!nip07Injected) {
|
|
125
|
-
nip07Injected = injectNip07()
|
|
126
|
-
|
|
127
|
-
}
|
|
128
122
|
if (AppDebugLogger.isWebViewLoggingEnabled(context)) {
|
|
129
123
|
injectDebugLogger()
|
|
130
124
|
}
|
|
@@ -235,7 +229,18 @@ class WebViewController(
|
|
|
235
229
|
view: WebView?, request: WebResourceRequest?
|
|
236
230
|
): Boolean {
|
|
237
231
|
val url = request?.url ?: return true
|
|
238
|
-
|
|
232
|
+
val decision = navigationDecision(
|
|
233
|
+
url.scheme,
|
|
234
|
+
url.host,
|
|
235
|
+
url.port,
|
|
236
|
+
url.userInfo,
|
|
237
|
+
request.isForMainFrame
|
|
238
|
+
)
|
|
239
|
+
if (decision == NavigationDecision.REJECT_SUBFRAME_NIP55) {
|
|
240
|
+
logNip55Rejection("subframe request")
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
if (decision == NavigationDecision.NIP55_SIGN_REQUEST) {
|
|
239
244
|
dispatchNip55SignEvent(
|
|
240
245
|
rawUrl = url.toString(),
|
|
241
246
|
activeSignerType = signerManager.getActiveType(),
|
|
@@ -269,7 +274,7 @@ class WebViewController(
|
|
|
269
274
|
)
|
|
270
275
|
return true
|
|
271
276
|
}
|
|
272
|
-
return
|
|
277
|
+
return decision != NavigationDecision.ALLOW
|
|
273
278
|
}
|
|
274
279
|
|
|
275
280
|
}
|
|
@@ -323,25 +328,7 @@ class WebViewController(
|
|
|
323
328
|
}
|
|
324
329
|
}
|
|
325
330
|
|
|
326
|
-
|
|
327
|
-
// Available in JavaScript as window.AndroidBridge.
|
|
328
|
-
webView.addJavascriptInterface(
|
|
329
|
-
|
|
330
|
-
NostrBridge(
|
|
331
|
-
|
|
332
|
-
signerManager = signerManager,
|
|
333
|
-
|
|
334
|
-
webView = webView,
|
|
335
|
-
|
|
336
|
-
),
|
|
337
|
-
|
|
338
|
-
"AndroidBridge"
|
|
339
|
-
|
|
340
|
-
)
|
|
341
|
-
webView.addJavascriptInterface(
|
|
342
|
-
PakstrBridge(RuntimeApiBaseSettings.from(context)),
|
|
343
|
-
"PakstrBridge"
|
|
344
|
-
)
|
|
331
|
+
installBridge()
|
|
345
332
|
|
|
346
333
|
}
|
|
347
334
|
|
|
@@ -406,6 +393,7 @@ class WebViewController(
|
|
|
406
393
|
|
|
407
394
|
fun destroy() {
|
|
408
395
|
nip55Scope.cancel()
|
|
396
|
+
bridgeScope.cancel()
|
|
409
397
|
webView.stopLoading()
|
|
410
398
|
|
|
411
399
|
webView.clearHistory()
|
|
@@ -422,24 +410,48 @@ class WebViewController(
|
|
|
422
410
|
webView.destroy()
|
|
423
411
|
}
|
|
424
412
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Exposes window.nostr and window.pakstr to the app origin's top-level
|
|
415
|
+
* frame only (see PakstrWebBridge). Runs before the first load.
|
|
416
|
+
*/
|
|
417
|
+
private fun installBridge() {
|
|
418
|
+
val providerScript = try {
|
|
419
|
+
context.assets.open("nip07.js")
|
|
429
420
|
.bufferedReader()
|
|
430
|
-
.readText()
|
|
431
|
-
|
|
432
|
-
webView.evaluateJavascript(
|
|
433
|
-
js, null
|
|
434
|
-
)
|
|
435
|
-
true
|
|
421
|
+
.use { it.readText() }
|
|
436
422
|
} catch (e: Exception) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
423
|
+
AppDebugLogger.error(context, "BRIDGE", "Failed loading nip07.js", e)
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
val router = WebMessageRouter(
|
|
427
|
+
handler = PakstrBridgeMethods(
|
|
428
|
+
nostr = NostrBridge(
|
|
429
|
+
signerProvider = signerManager::getConfiguredSigner,
|
|
430
|
+
log = { message -> AppDebugLogger.log(context, "SIGNER", message) },
|
|
431
|
+
logError = { message, error ->
|
|
432
|
+
AppDebugLogger.error(context, "SIGNER", message, error)
|
|
433
|
+
}
|
|
434
|
+
),
|
|
435
|
+
runtime = PakstrBridge(RuntimeApiBaseSettings.from(context)),
|
|
436
|
+
debugLog = ::logWebConsole
|
|
437
|
+
),
|
|
438
|
+
scope = bridgeScope,
|
|
439
|
+
runOnMain = { block -> webView.post { block() } },
|
|
440
|
+
log = { message -> AppDebugLogger.log(context, "BRIDGE", message) }
|
|
441
|
+
)
|
|
442
|
+
PakstrWebBridge.install(webView, router, providerScript) { message ->
|
|
443
|
+
AppDebugLogger.error(context, "BRIDGE", message)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private fun logWebConsole(level: String, message: String) {
|
|
448
|
+
if (!AppDebugLogger.isWebViewLoggingEnabled(context)) {
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
when (level.uppercase()) {
|
|
452
|
+
"ERROR" -> AppDebugLogger.error(context, "WEB-JS", message)
|
|
453
|
+
"WARN" -> AppDebugLogger.log(context, "WEB-WARN", message)
|
|
454
|
+
else -> AppDebugLogger.log(context, "WEB", message)
|
|
443
455
|
}
|
|
444
456
|
}
|
|
445
457
|
|
|
@@ -448,7 +460,8 @@ class WebViewController(
|
|
|
448
460
|
val js = """
|
|
449
461
|
(function(){
|
|
450
462
|
|
|
451
|
-
|
|
463
|
+
const channel = window.__pakstrNative;
|
|
464
|
+
if (!channel) {
|
|
452
465
|
return;
|
|
453
466
|
}
|
|
454
467
|
|
|
@@ -475,9 +488,12 @@ class WebViewController(
|
|
|
475
488
|
})
|
|
476
489
|
.join(" ");
|
|
477
490
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
491
|
+
channel.postMessage(
|
|
492
|
+
JSON.stringify({
|
|
493
|
+
v: 1,
|
|
494
|
+
method: "debug.log",
|
|
495
|
+
params: { level, message }
|
|
496
|
+
})
|
|
481
497
|
);
|
|
482
498
|
|
|
483
499
|
} catch(e){
|