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
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ build:
|
|
|
49
49
|
# apiBase: https://api.example.com # Optional packaged /api/* proxy target.
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
`runtime.apiBase` must be an absolute HTTPS URL. It is the immutable packaged/default proxy target and public APK metadata, not a secret. The effective target precedence is persistent runtime override, then packaged default, then disabled.
|
|
52
|
+
`runtime.apiBase` must be an absolute HTTPS URL. It is the immutable packaged/default proxy target and public APK metadata, not a secret. The effective target precedence is persistent runtime override, then packaged default, then disabled. On Android, a web app may call `await window.pakstr.setApiBaseUrl(url)` to validate and persist an HTTPS override (it resolves `true` on success) and `await window.pakstr.getApiBaseUrl()` to read the effective URL; `window.pakstr` exists only in the app's top-level page. Developer Tools uses the same setting. Reset removes the override and restores the packaged default; without a packaged default it disables `/api/*`. Changes affect the next proxy request without an app restart, LocalServer restart, or WebView reload.
|
|
53
53
|
|
|
54
54
|
### 3. Build, sign, and publish
|
|
55
55
|
```bash
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import android.os.SystemClock
|
|
4
|
+
import android.webkit.WebView
|
|
5
|
+
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
6
|
+
import androidx.test.platform.app.InstrumentationRegistry
|
|
7
|
+
import kotlinx.coroutines.CoroutineScope
|
|
8
|
+
import kotlinx.coroutines.Dispatchers
|
|
9
|
+
import kotlinx.coroutines.SupervisorJob
|
|
10
|
+
import kotlinx.coroutines.cancel
|
|
11
|
+
import org.junit.After
|
|
12
|
+
import org.junit.Assert.assertEquals
|
|
13
|
+
import org.junit.Assume.assumeTrue
|
|
14
|
+
import org.junit.Before
|
|
15
|
+
import org.junit.Test
|
|
16
|
+
import org.junit.runner.RunWith
|
|
17
|
+
import java.util.concurrent.CountDownLatch
|
|
18
|
+
import java.util.concurrent.TimeUnit
|
|
19
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
20
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Exercises the real androidx.webkit bridge: origin scoping, document-start
|
|
24
|
+
* injection, subframe rejection, and a full request/reply round trip.
|
|
25
|
+
* Pages are loaded with loadDataWithBaseURL so no LocalServer is needed.
|
|
26
|
+
*/
|
|
27
|
+
@RunWith(AndroidJUnit4::class)
|
|
28
|
+
class PakstrWebBridgeTest {
|
|
29
|
+
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
|
30
|
+
private val handled = AtomicInteger()
|
|
31
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
32
|
+
private lateinit var webView: WebView
|
|
33
|
+
|
|
34
|
+
@Before
|
|
35
|
+
fun setUp() {
|
|
36
|
+
assumeTrue("WebView lacks the required androidx.webkit features", PakstrWebBridge.isSupported())
|
|
37
|
+
val providerScript = instrumentation.targetContext.assets.open("nip07.js")
|
|
38
|
+
.bufferedReader()
|
|
39
|
+
.use { it.readText() }
|
|
40
|
+
val installed = AtomicReference<Boolean>()
|
|
41
|
+
instrumentation.runOnMainSync {
|
|
42
|
+
webView = WebView(instrumentation.targetContext)
|
|
43
|
+
webView.settings.javaScriptEnabled = true
|
|
44
|
+
val router = WebMessageRouter(
|
|
45
|
+
handler = { method, _ ->
|
|
46
|
+
handled.incrementAndGet()
|
|
47
|
+
if (method == "runtime.getNip55CallbackUrl") CALLBACK else null
|
|
48
|
+
},
|
|
49
|
+
scope = scope,
|
|
50
|
+
runOnMain = { block -> webView.post { block() } }
|
|
51
|
+
)
|
|
52
|
+
installed.set(PakstrWebBridge.install(webView, router, providerScript) {})
|
|
53
|
+
}
|
|
54
|
+
assertEquals(true, installed.get())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@After
|
|
58
|
+
fun tearDown() {
|
|
59
|
+
scope.cancel()
|
|
60
|
+
if (::webView.isInitialized) {
|
|
61
|
+
instrumentation.runOnMainSync { webView.destroy() }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Test
|
|
66
|
+
fun providerExistsBeforePageScriptsOnTheAppOrigin() {
|
|
67
|
+
load(
|
|
68
|
+
"${AppOrigin.ORIGIN}/",
|
|
69
|
+
"<script>document.title = [typeof window.nostr, typeof window.pakstr, " +
|
|
70
|
+
"typeof window.AndroidBridge, typeof window.PakstrBridge].join(',')</script>"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assertEquals("object,object,undefined,undefined", awaitTitle())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
fun foreignOriginsGetNeitherTheChannelNorTheProvider() {
|
|
78
|
+
for (base in listOf("https://example.com", "http://127.0.0.1:${Config.PORT + 1}", "http://localhost:${Config.PORT}")) {
|
|
79
|
+
// The origin in the title ties each result to the page that produced it.
|
|
80
|
+
load(
|
|
81
|
+
"$base/",
|
|
82
|
+
"<script>document.title = location.origin + '|' + [typeof window.nostr, typeof window.pakstr, " +
|
|
83
|
+
"typeof window.${PakstrWebBridge.JS_OBJECT_NAME}].join(',')</script>"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assertEquals("$base|undefined,undefined,undefined", awaitTitle { it.startsWith("$base|") })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@Test
|
|
91
|
+
fun sameOriginSubframesGetNoProviderAndCannotReachNativeCode() {
|
|
92
|
+
val frame = "<script>" +
|
|
93
|
+
"const c = window.${PakstrWebBridge.JS_OBJECT_NAME};" +
|
|
94
|
+
"if (c) c.postMessage(JSON.stringify({ v: 1, id: 'frame', method: 'runtime.getNip55CallbackUrl' }));" +
|
|
95
|
+
"parent.document.title = 'frame:' + typeof window.nostr + ',' + typeof window.pakstr;" +
|
|
96
|
+
"</script>"
|
|
97
|
+
load("${AppOrigin.ORIGIN}/", "<iframe srcdoc=\"$frame\"></iframe>")
|
|
98
|
+
|
|
99
|
+
assertEquals("frame:undefined,undefined", awaitTitle())
|
|
100
|
+
SystemClock.sleep(1_000)
|
|
101
|
+
assertEquals(0, handled.get())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@Test
|
|
105
|
+
fun appTopFrameRequestsRoundTripThroughTheRouter() {
|
|
106
|
+
load(
|
|
107
|
+
"${AppOrigin.ORIGIN}/",
|
|
108
|
+
"<script>window.pakstr.getNip55CallbackUrl().then(" +
|
|
109
|
+
"v => document.title = 'ok:' + v, e => document.title = 'err:' + e.error)</script>"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assertEquals("ok:$CALLBACK", awaitTitle())
|
|
113
|
+
assertEquals(1, handled.get())
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun load(baseUrl: String, body: String) {
|
|
117
|
+
instrumentation.runOnMainSync {
|
|
118
|
+
webView.loadDataWithBaseURL(
|
|
119
|
+
baseUrl,
|
|
120
|
+
"<!doctype html><html><head><title></title></head><body>$body</body></html>",
|
|
121
|
+
"text/html",
|
|
122
|
+
"utf-8",
|
|
123
|
+
null
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun awaitTitle(
|
|
129
|
+
timeoutMillis: Long = 10_000,
|
|
130
|
+
accept: (String) -> Boolean = { true }
|
|
131
|
+
): String {
|
|
132
|
+
val deadline = SystemClock.uptimeMillis() + timeoutMillis
|
|
133
|
+
while (SystemClock.uptimeMillis() < deadline) {
|
|
134
|
+
val latch = CountDownLatch(1)
|
|
135
|
+
val title = AtomicReference<String>()
|
|
136
|
+
instrumentation.runOnMainSync {
|
|
137
|
+
webView.evaluateJavascript("document.title") { value ->
|
|
138
|
+
title.set(value?.trim('"').orEmpty())
|
|
139
|
+
latch.countDown()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
latch.await(1, TimeUnit.SECONDS)
|
|
143
|
+
val current = title.get()
|
|
144
|
+
if (!current.isNullOrEmpty() && current != "null" && accept(current)) return current
|
|
145
|
+
SystemClock.sleep(100)
|
|
146
|
+
}
|
|
147
|
+
throw AssertionError("page did not set document.title in time")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private companion object {
|
|
151
|
+
const val CALLBACK = "pakstr-test://nip55-callback#nip55_event="
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -1,116 +1,166 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Installed by PakstrWebBridge as a document-start script for the app
|
|
4
|
+
// origin only, so it runs before any page script. The native side also
|
|
5
|
+
// rejects messages from subframes; only the top-level app document gets
|
|
6
|
+
// window.nostr and window.pakstr.
|
|
7
|
+
if (window.top !== window) return;
|
|
4
8
|
|
|
9
|
+
// Same limit as WebMessageRouter.MAX_MESSAGE_BYTES.
|
|
10
|
+
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
// Injected by addWebMessageListener before this script runs.
|
|
13
|
+
const channel = window.__pakstrNative;
|
|
5
14
|
const pending = new Map();
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
if (channel) {
|
|
17
|
+
channel.addEventListener("message", (event) => {
|
|
18
|
+
let response;
|
|
19
|
+
try {
|
|
20
|
+
response = JSON.parse(event.data);
|
|
21
|
+
} catch (_) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (!response || response.v !== 1 || typeof response.id !== "string") return;
|
|
25
|
+
|
|
26
|
+
const p = pending.get(response.id);
|
|
27
|
+
if (!p) return;
|
|
28
|
+
|
|
29
|
+
pending.delete(response.id);
|
|
30
|
+
|
|
31
|
+
if (response.ok === true) {
|
|
32
|
+
p.resolve(response.result);
|
|
33
|
+
} else {
|
|
34
|
+
p.reject(response.error ?? {
|
|
35
|
+
error: "SIGNER_ERROR",
|
|
36
|
+
message: "Unknown signer error"
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function callNative(method, params) {
|
|
8
43
|
return new Promise((resolve, reject) => {
|
|
9
44
|
|
|
45
|
+
if (!channel) {
|
|
46
|
+
reject({
|
|
47
|
+
error: "SIGNER_UNAVAILABLE",
|
|
48
|
+
message: "Pakstr bridge is unavailable"
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
10
53
|
const id = crypto.randomUUID();
|
|
54
|
+
let message;
|
|
55
|
+
try {
|
|
56
|
+
message = JSON.stringify({ v: 1, id, method, params });
|
|
57
|
+
} catch (_) {
|
|
58
|
+
reject({ error: "SIGNER_ERROR", message: "Invalid request" });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (new TextEncoder().encode(message).length > MAX_MESSAGE_BYTES) {
|
|
62
|
+
reject({ error: "SIGNER_ERROR", message: "Request is too large" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
11
65
|
|
|
12
66
|
pending.set(id, {
|
|
13
67
|
resolve,
|
|
14
68
|
reject
|
|
15
69
|
});
|
|
16
70
|
|
|
17
|
-
|
|
18
|
-
method,
|
|
19
|
-
id,
|
|
20
|
-
payload ? JSON.stringify(payload) : ""
|
|
21
|
-
);
|
|
71
|
+
channel.postMessage(message);
|
|
22
72
|
});
|
|
23
73
|
}
|
|
24
74
|
|
|
25
|
-
window.
|
|
75
|
+
if (!window.nostr) {
|
|
76
|
+
window.nostr = {
|
|
26
77
|
|
|
27
|
-
|
|
28
|
-
|
|
78
|
+
getPublicKey: async () => {
|
|
79
|
+
return await callNative("nostr.getPublicKey");
|
|
80
|
+
},
|
|
29
81
|
|
|
30
|
-
|
|
82
|
+
signEvent: async (event) => {
|
|
31
83
|
|
|
32
|
-
|
|
33
|
-
|
|
84
|
+
const result = await callNative(
|
|
85
|
+
"nostr.signEvent",
|
|
86
|
+
event
|
|
87
|
+
);
|
|
34
88
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
89
|
+
return JSON.parse(result);
|
|
90
|
+
},
|
|
39
91
|
|
|
40
|
-
|
|
41
|
-
// Normal string response
|
|
42
|
-
}
|
|
92
|
+
nip04: {
|
|
43
93
|
|
|
44
|
-
|
|
45
|
-
};
|
|
94
|
+
encrypt: async (pubkey, plaintext) => {
|
|
46
95
|
|
|
47
|
-
|
|
96
|
+
return await callNative(
|
|
97
|
+
"nostr.nip04_encrypt",
|
|
98
|
+
{
|
|
99
|
+
pubkey,
|
|
100
|
+
plaintext
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
},
|
|
48
104
|
|
|
49
|
-
|
|
50
|
-
return await callNative("getPublicKey");
|
|
51
|
-
},
|
|
105
|
+
decrypt: async (pubkey, ciphertext) => {
|
|
52
106
|
|
|
53
|
-
|
|
107
|
+
return await callNative(
|
|
108
|
+
"nostr.nip04_decrypt",
|
|
109
|
+
{
|
|
110
|
+
pubkey,
|
|
111
|
+
ciphertext
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
}
|
|
54
115
|
|
|
55
|
-
|
|
56
|
-
"signEvent",
|
|
57
|
-
event
|
|
58
|
-
);
|
|
116
|
+
},
|
|
59
117
|
|
|
60
|
-
|
|
61
|
-
},
|
|
118
|
+
nip44: {
|
|
62
119
|
|
|
63
|
-
|
|
120
|
+
encrypt: async (pubkey, plaintext) => {
|
|
64
121
|
|
|
65
|
-
|
|
122
|
+
return await callNative(
|
|
123
|
+
"nostr.nip44_encrypt",
|
|
124
|
+
{
|
|
125
|
+
pubkey,
|
|
126
|
+
plaintext
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
},
|
|
66
130
|
|
|
67
|
-
|
|
68
|
-
"nip04_encrypt",
|
|
69
|
-
{
|
|
70
|
-
pubkey,
|
|
71
|
-
plaintext
|
|
72
|
-
}
|
|
73
|
-
);
|
|
74
|
-
},
|
|
131
|
+
decrypt: async (pubkey, ciphertext) => {
|
|
75
132
|
|
|
76
|
-
|
|
133
|
+
return await callNative(
|
|
134
|
+
"nostr.nip44_decrypt",
|
|
135
|
+
{
|
|
136
|
+
pubkey,
|
|
137
|
+
ciphertext
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
}
|
|
77
141
|
|
|
78
|
-
return await callNative(
|
|
79
|
-
"nip04_decrypt",
|
|
80
|
-
{
|
|
81
|
-
pubkey,
|
|
82
|
-
ciphertext
|
|
83
|
-
}
|
|
84
|
-
);
|
|
85
142
|
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
86
145
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
nip44: {
|
|
146
|
+
if (!window.pakstr) {
|
|
147
|
+
window.pakstr = {
|
|
90
148
|
|
|
91
|
-
|
|
149
|
+
version: 1,
|
|
92
150
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
{
|
|
96
|
-
pubkey,
|
|
97
|
-
plaintext
|
|
98
|
-
}
|
|
99
|
-
);
|
|
151
|
+
getApiBaseUrl: async () => {
|
|
152
|
+
return await callNative("runtime.getApiBaseUrl");
|
|
100
153
|
},
|
|
101
154
|
|
|
102
|
-
|
|
155
|
+
setApiBaseUrl: async (url) => {
|
|
156
|
+
if (typeof url !== "string") return false;
|
|
157
|
+
return await callNative("runtime.setApiBaseUrl", { url });
|
|
158
|
+
},
|
|
103
159
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
{
|
|
107
|
-
pubkey,
|
|
108
|
-
ciphertext
|
|
109
|
-
}
|
|
110
|
-
);
|
|
160
|
+
getNip55CallbackUrl: async () => {
|
|
161
|
+
return await callNative("runtime.getNip55CallbackUrl");
|
|
111
162
|
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
112
165
|
|
|
113
|
-
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
})();
|
|
166
|
+
})();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The web app's only trusted origin: the bundled LocalServer. The JavaScript
|
|
5
|
+
* bridge and the NIP-07 provider exist only in this origin's top-level frame.
|
|
6
|
+
*
|
|
7
|
+
* Checks take URI components (from android.net.Uri at the call site) so they
|
|
8
|
+
* stay plain Kotlin, and fail closed on anything unexpected.
|
|
9
|
+
*/
|
|
10
|
+
internal object AppOrigin {
|
|
11
|
+
private const val SCHEME = "http"
|
|
12
|
+
private const val HOST = "127.0.0.1"
|
|
13
|
+
|
|
14
|
+
/** Origin rule for androidx.webkit (exact scheme, host, and port; never a wildcard). */
|
|
15
|
+
val ORIGIN: String = "$SCHEME://$HOST:${Config.PORT}"
|
|
16
|
+
|
|
17
|
+
fun isTrustedOrigin(
|
|
18
|
+
scheme: String?,
|
|
19
|
+
host: String?,
|
|
20
|
+
port: Int,
|
|
21
|
+
userInfo: String?,
|
|
22
|
+
path: String?
|
|
23
|
+
): Boolean =
|
|
24
|
+
isAppAuthority(scheme, host, port, userInfo) &&
|
|
25
|
+
(path.isNullOrEmpty() || path == "/")
|
|
26
|
+
|
|
27
|
+
/** Top-level navigation may only stay on the app origin (any path, query, or fragment). */
|
|
28
|
+
fun isNavigationAllowed(
|
|
29
|
+
scheme: String?,
|
|
30
|
+
host: String?,
|
|
31
|
+
port: Int,
|
|
32
|
+
userInfo: String?
|
|
33
|
+
): Boolean = isAppAuthority(scheme, host, port, userInfo)
|
|
34
|
+
|
|
35
|
+
private fun isAppAuthority(
|
|
36
|
+
scheme: String?,
|
|
37
|
+
host: String?,
|
|
38
|
+
port: Int,
|
|
39
|
+
userInfo: String?
|
|
40
|
+
): Boolean =
|
|
41
|
+
scheme == SCHEME &&
|
|
42
|
+
host == HOST &&
|
|
43
|
+
port == Config.PORT &&
|
|
44
|
+
userInfo == null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
internal enum class NavigationDecision {
|
|
48
|
+
ALLOW,
|
|
49
|
+
BLOCK,
|
|
50
|
+
NIP55_SIGN_REQUEST,
|
|
51
|
+
REJECT_SUBFRAME_NIP55
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* shouldOverrideUrlLoading policy. `nostrsigner:` requests reach the signer
|
|
56
|
+
* only from the main frame; everything else must stay on the app origin.
|
|
57
|
+
*/
|
|
58
|
+
internal fun navigationDecision(
|
|
59
|
+
scheme: String?,
|
|
60
|
+
host: String?,
|
|
61
|
+
port: Int,
|
|
62
|
+
userInfo: String?,
|
|
63
|
+
isForMainFrame: Boolean
|
|
64
|
+
): NavigationDecision {
|
|
65
|
+
if (scheme.equals("nostrsigner", ignoreCase = true)) {
|
|
66
|
+
return if (isForMainFrame) {
|
|
67
|
+
NavigationDecision.NIP55_SIGN_REQUEST
|
|
68
|
+
} else {
|
|
69
|
+
NavigationDecision.REJECT_SUBFRAME_NIP55
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return if (AppOrigin.isNavigationAllowed(scheme, host, port, userInfo)) {
|
|
73
|
+
NavigationDecision.ALLOW
|
|
74
|
+
} else {
|
|
75
|
+
NavigationDecision.BLOCK
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
package com.pakstr.app
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Native side of the `window.pakstr` runtime methods. Not exposed to
|
|
5
|
+
* JavaScript directly: WebMessageRouter calls it only for messages from the
|
|
6
|
+
* app origin's top-level frame.
|
|
7
|
+
*/
|
|
5
8
|
class PakstrBridge(
|
|
6
9
|
private val runtimeApiBaseSettings: RuntimeApiBaseSettings
|
|
7
10
|
) {
|
|
8
11
|
|
|
9
|
-
@JavascriptInterface
|
|
10
12
|
fun getNip55CallbackUrl(): String {
|
|
11
13
|
return Nip55Callback.callbackPrefix
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
@JavascriptInterface
|
|
15
16
|
fun setApiBaseUrl(url: String): Boolean {
|
|
16
17
|
return runtimeApiBaseSettings.setOverride(url)
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
@JavascriptInterface
|
|
20
20
|
fun getApiBaseUrl(): String? {
|
|
21
21
|
return runtimeApiBaseSettings.current().url
|
|
22
22
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import android.webkit.WebView
|
|
4
|
+
import androidx.annotation.UiThread
|
|
5
|
+
import androidx.webkit.WebMessageCompat
|
|
6
|
+
import androidx.webkit.WebViewCompat
|
|
7
|
+
import androidx.webkit.WebViewFeature
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Installs the JavaScript bridge with androidx.webkit, scoped to
|
|
11
|
+
* [AppOrigin.ORIGIN]: the message channel object exists only in frames of
|
|
12
|
+
* that origin, and the NIP-07 provider script runs there at document start,
|
|
13
|
+
* before any page script. WebMessageRouter additionally drops messages from
|
|
14
|
+
* subframes and other origins.
|
|
15
|
+
*
|
|
16
|
+
* Fails closed: if the WebView lacks either feature, or installation fails,
|
|
17
|
+
* nothing is exposed and the page has no `window.nostr` or `window.pakstr`.
|
|
18
|
+
*/
|
|
19
|
+
internal object PakstrWebBridge {
|
|
20
|
+
const val JS_OBJECT_NAME = "__pakstrNative"
|
|
21
|
+
|
|
22
|
+
fun isSupported(): Boolean =
|
|
23
|
+
WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) &&
|
|
24
|
+
WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
|
|
25
|
+
|
|
26
|
+
/** Must run before the page loads. Returns true only if both parts were installed. */
|
|
27
|
+
@UiThread
|
|
28
|
+
fun install(
|
|
29
|
+
webView: WebView,
|
|
30
|
+
router: WebMessageRouter,
|
|
31
|
+
providerScript: String,
|
|
32
|
+
logError: (String) -> Unit
|
|
33
|
+
): Boolean {
|
|
34
|
+
// Nested, positive checks: lint accepts feature-gated calls only inside such guards.
|
|
35
|
+
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
|
36
|
+
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
|
37
|
+
val allowedOrigins = setOf(AppOrigin.ORIGIN)
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
WebViewCompat.addWebMessageListener(
|
|
41
|
+
webView,
|
|
42
|
+
JS_OBJECT_NAME,
|
|
43
|
+
allowedOrigins
|
|
44
|
+
) { _, message, sourceOrigin, isMainFrame, replyProxy ->
|
|
45
|
+
router.onMessage(
|
|
46
|
+
MessageSource(
|
|
47
|
+
scheme = sourceOrigin.scheme,
|
|
48
|
+
host = sourceOrigin.host,
|
|
49
|
+
port = sourceOrigin.port,
|
|
50
|
+
userInfo = sourceOrigin.userInfo,
|
|
51
|
+
path = sourceOrigin.path,
|
|
52
|
+
isMainFrame = isMainFrame
|
|
53
|
+
),
|
|
54
|
+
if (message.type == WebMessageCompat.TYPE_STRING) message.data else null
|
|
55
|
+
) { response ->
|
|
56
|
+
try {
|
|
57
|
+
replyProxy.postMessage(response)
|
|
58
|
+
} catch (_: Exception) {
|
|
59
|
+
// The frame is gone; nobody is waiting for this reply.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (error: Exception) {
|
|
64
|
+
logError("Unable to install bridge channel: ${error.javaClass.simpleName}")
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
WebViewCompat.addDocumentStartJavaScript(
|
|
70
|
+
webView,
|
|
71
|
+
providerScript,
|
|
72
|
+
allowedOrigins
|
|
73
|
+
)
|
|
74
|
+
} catch (error: Exception) {
|
|
75
|
+
try {
|
|
76
|
+
WebViewCompat.removeWebMessageListener(webView, JS_OBJECT_NAME)
|
|
77
|
+
} catch (_: Exception) {
|
|
78
|
+
// Best effort; the WebView is not loaded yet either way.
|
|
79
|
+
}
|
|
80
|
+
logError("Unable to install NIP-07 provider: ${error.javaClass.simpleName}")
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
logError("WebView lacks WEB_MESSAGE_LISTENER or DOCUMENT_START_SCRIPT; bridge disabled")
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|