pakstr 0.30.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.
Files changed (33) hide show
  1. package/README.md +12 -2
  2. package/android-template/app/build.gradle.kts +1 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/PakstrWebBridgeTest.kt +153 -0
  4. package/android-template/app/src/main/assets/nip07.js +124 -74
  5. package/android-template/app/src/main/java/com/pakstr/app/AppOrigin.kt +77 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +5 -5
  7. package/android-template/app/src/main/java/com/pakstr/app/PakstrWebBridge.kt +89 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/WebMessageRouter.kt +186 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +65 -49
  10. package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +69 -489
  11. package/android-template/app/src/test/java/com/pakstr/app/AppOriginTest.kt +114 -0
  12. package/android-template/app/src/test/java/com/pakstr/app/WebMessageRouterTest.kt +279 -0
  13. package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeDispatchTest.kt +113 -0
  14. package/android-template/gradle/libs.versions.toml +2 -0
  15. package/desktop-runtime/README.md +108 -0
  16. package/desktop-runtime/dist/main.js +22211 -0
  17. package/desktop-runtime/dist/preload.js +53 -0
  18. package/dist/cli.js +17 -3
  19. package/dist/commands/build.js +1 -0
  20. package/dist/commands/buildDesktop.js +140 -0
  21. package/dist/desktop/common/artifacts.js +16 -0
  22. package/dist/desktop/common/asar.js +60 -0
  23. package/dist/desktop/common/electron.js +50 -0
  24. package/dist/desktop/common/files.js +30 -0
  25. package/dist/desktop/common/fuses.js +62 -0
  26. package/dist/desktop/common/packager.js +46 -0
  27. package/dist/desktop/linux.js +425 -0
  28. package/dist/desktop/macos.js +125 -0
  29. package/dist/desktop/stage.js +117 -0
  30. package/dist/desktop/windows.js +384 -0
  31. package/dist/desktop/zip.js +204 -0
  32. package/dist/shared/nip46Pairing.js +278 -0
  33. package/package.json +14 -3
@@ -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
- * - Inject NIP-07 JavaScript provider
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
- if (url.scheme.equals("nostrsigner", ignoreCase = true)) {
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 url.host != "127.0.0.1"
277
+ return decision != NavigationDecision.ALLOW
273
278
  }
274
279
 
275
280
  }
@@ -323,25 +328,7 @@ class WebViewController(
323
328
  }
324
329
  }
325
330
 
326
- // Exposes native Nostr signer APIs to the WebView.
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
- private fun injectNip07(): Boolean {
426
-
427
- return try {
428
- val js = context.assets.open("nip07.js")
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
- if (AppDebugLogger.isWebViewLoggingEnabled(context)) {
438
- AppDebugLogger.log(
439
- context, "WebViewController - WEBVIEW", "Failed loading nip07.js: ${e.message}"
440
- )
441
- }
442
- false
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
- if (!window.AndroidBridge) {
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
- window.AndroidBridge.debugLog(
479
- level,
480
- message
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){