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.
- package/README.md +12 -2
- 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/desktop-runtime/README.md +108 -0
- package/desktop-runtime/dist/main.js +22211 -0
- package/desktop-runtime/dist/preload.js +53 -0
- package/dist/cli.js +17 -3
- package/dist/commands/build.js +1 -0
- package/dist/commands/buildDesktop.js +140 -0
- package/dist/desktop/common/artifacts.js +16 -0
- package/dist/desktop/common/asar.js +60 -0
- package/dist/desktop/common/electron.js +50 -0
- package/dist/desktop/common/files.js +30 -0
- package/dist/desktop/common/fuses.js +62 -0
- package/dist/desktop/common/packager.js +46 -0
- package/dist/desktop/linux.js +425 -0
- package/dist/desktop/macos.js +125 -0
- package/dist/desktop/stage.js +117 -0
- package/dist/desktop/windows.js +384 -0
- package/dist/desktop/zip.js +204 -0
- package/dist/shared/nip46Pairing.js +278 -0
- package/package.json +14 -3
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ The CLI API and configuration format may change before 1.0.0.
|
|
|
19
19
|
|
|
20
20
|
## Requirements
|
|
21
21
|
|
|
22
|
-
* **Node.js** 22+
|
|
22
|
+
* **Node.js** 22.12+
|
|
23
23
|
* **Docker Desktop**
|
|
24
24
|
|
|
25
25
|
*Note: No Android SDK, Gradle, Java, or Android keystore setup is required locally.*
|
|
@@ -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
|
|
@@ -67,6 +67,16 @@ npx pakstr run
|
|
|
67
67
|
* `npx pakstr delete --publisher` — Inventory every event authored by the configured publish nsec; add `--execute` for the publisher-wide NIP-09 deletion request.
|
|
68
68
|
* `npx pakstr run` — Execute the full pipeline at once (**build** + **sign** + **publish**).
|
|
69
69
|
|
|
70
|
+
### Desktop (macOS arm64, Windows x64, Linux x64, experimental)
|
|
71
|
+
|
|
72
|
+
`pakstr build --target macos-arm64` packages the same `pakstr.yaml` app as `<appName>.app` in `<appId>-macos-arm64.zip` next to `build.out`. It runs on macOS only and downloads the pinned Electron release on first use. The app is ad-hoc signed and **not notarized**: Gatekeeper rejects it once downloaded, so it is for local testing only. App data lives in `~/Library/Application Support/<appId>`.
|
|
73
|
+
|
|
74
|
+
`pakstr build --target windows-x64` writes `<appId>-windows-x64-setup.exe`, a per-user installer (no admin rights; installs to `%LOCALAPPDATA%\Programs\<appId>` with a Start menu shortcut and an uninstaller), and `<appId>-windows-x64.zip`, a portable `<appName>\<appName>.exe` folder. It builds on any host with [NSIS 3](https://nsis.sourceforge.io/) (`makensis`) on `PATH` (`brew install makensis`, `apt install nsis`, or the Windows NSIS installer). Both are **unsigned**, so SmartScreen warns before the first run. The numeric part of `versionName` becomes the Windows file version. App data lives in `%APPDATA%\<appId>` and survives uninstall.
|
|
75
|
+
|
|
76
|
+
`pakstr build --target linux-x64` writes `<appId>-linux-x64.AppImage` and `<appId>-linux-x64.zip`, a portable `<appName>/<appId>` folder that keeps its executable bits. It builds on macOS or Linux (not Windows) with squashfs-tools 4.5+ (`mksquashfs`, `unsquashfs`) on `PATH` (`brew install squashfs`, `apt install squashfs-tools`), and downloads the pinned Electron release and AppImage runtime on first use. The icon comes from the same `logo.png` / `app.icon` lookup as Android. Running the AppImage needs FUSE 3, and Chromium's sandbox needs unprivileged user namespaces; Pakstr never turns the sandbox off, so distributions that restrict them (Ubuntu 23.10+) need an AppArmor profile or an administrator-installed package. App data lives in `~/.config/<appId>`.
|
|
77
|
+
|
|
78
|
+
Desktop publishing and other platforms are not supported yet; `sign`, `publish`, and `run` remain Android-only.
|
|
79
|
+
|
|
70
80
|
Delete commands are non-destructive by default. `--app` and `--publisher` are mutually exclusive, and Blossom HTTP deletion requires `--app --execute --delete-blobs`. NIP-09 is best-effort: relay acceptance does not prove physical erasure, and discovery is limited to events retained and returned by the configured relay.
|
|
71
81
|
|
|
72
82
|
---
|
|
@@ -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
|
+
}
|