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,279 @@
1
+ package com.pakstr.app
2
+
3
+ import com.pakstr.app.signer.NostrBridge
4
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
5
+ import com.pakstr.app.signer.interfaces.NostrSigner
6
+ import kotlinx.coroutines.CoroutineScope
7
+ import kotlinx.coroutines.Dispatchers
8
+ import org.json.JSONObject
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertFalse
11
+ import org.junit.Assert.assertNull
12
+ import org.junit.Assert.assertTrue
13
+ import org.junit.Test
14
+
15
+ class WebMessageRouterTest {
16
+ private val trusted = MessageSource("http", "127.0.0.1", Config.PORT, null, null, isMainFrame = true)
17
+
18
+ @Test
19
+ fun requestFromTheAppTopFrameIsAnsweredToTheSender() {
20
+ val bridge = Harness()
21
+
22
+ val replies = bridge.send(trusted, request("id-1", "nostr.getPublicKey"))
23
+
24
+ assertEquals(1, replies.size)
25
+ val reply = JSONObject(replies.single())
26
+ assertEquals(1, reply.getInt("v"))
27
+ assertEquals("id-1", reply.getString("id"))
28
+ assertTrue(reply.getBoolean("ok"))
29
+ assertEquals(PUBKEY, reply.getString("result"))
30
+ assertEquals(listOf("getPublicKey"), bridge.signer.calls)
31
+ }
32
+
33
+ @Test
34
+ fun subframesAreRejectedWithoutReachingTheSigner() {
35
+ val bridge = Harness()
36
+
37
+ val replies = bridge.send(trusted.copy(isMainFrame = false), request("id-1", "nostr.signEvent", EVENT))
38
+
39
+ assertTrue(replies.isEmpty())
40
+ assertTrue(bridge.signer.calls.isEmpty())
41
+ assertEquals(0, bridge.handled)
42
+ }
43
+
44
+ @Test
45
+ fun untrustedOriginsAreRejectedWithoutReachingAnyHandler() {
46
+ val untrusted = listOf(
47
+ trusted.copy(scheme = "https"),
48
+ trusted.copy(port = Config.PORT + 1),
49
+ trusted.copy(host = "localhost"),
50
+ trusted.copy(host = "evil.example"),
51
+ trusted.copy(userInfo = "user"),
52
+ trusted.copy(path = "/index.html"),
53
+ MessageSource(null, null, -1, null, null, isMainFrame = true)
54
+ )
55
+ for (source in untrusted) {
56
+ val bridge = Harness()
57
+ for (method in listOf("nostr.nip44_decrypt", "runtime.setApiBaseUrl", "runtime.getApiBaseUrl")) {
58
+ val replies = bridge.send(
59
+ source,
60
+ request("id-1", method, JSONObject().put("url", "https://evil.example"))
61
+ )
62
+ assertTrue("$source $method", replies.isEmpty())
63
+ }
64
+ assertEquals(source.toString(), 0, bridge.handled)
65
+ assertNull(bridge.preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY])
66
+ }
67
+ }
68
+
69
+ @Test
70
+ fun nonStringOversizedAndMalformedMessagesAreDropped() {
71
+ val bridge = Harness()
72
+ val oversized = request(
73
+ "id-1",
74
+ "nostr.nip44_encrypt",
75
+ JSONObject().put("pubkey", PUBKEY).put("plaintext", "x".repeat(WebMessageRouter.MAX_MESSAGE_BYTES))
76
+ )
77
+ val dropped = listOf(
78
+ null,
79
+ oversized,
80
+ "",
81
+ "not json",
82
+ "[1,2,3]",
83
+ JSONObject().put("id", "id-1").put("method", "nostr.getPublicKey").toString(),
84
+ request("id-1", "nostr.getPublicKey").replace("\"v\":1", "\"v\":2"),
85
+ request("id-1", "nostr.getPublicKey").replace("\"v\":1", "\"v\":\"1\""),
86
+ JSONObject().put("v", 1).put("method", "nostr.getPublicKey").toString(),
87
+ request("bad id", "nostr.getPublicKey"),
88
+ request("x".repeat(65), "nostr.getPublicKey"),
89
+ JSONObject().put("v", 1).put("id", 7).put("method", "nostr.getPublicKey").toString()
90
+ )
91
+ for (message in dropped) {
92
+ assertTrue(message?.take(80).toString(), bridge.send(trusted, message).isEmpty())
93
+ }
94
+ assertEquals(0, bridge.handled)
95
+ }
96
+
97
+ @Test
98
+ fun multiByteMessagesAreMeasuredInUtf8Bytes() {
99
+ val bridge = Harness()
100
+ // Under the limit in UTF-16 characters, over it in UTF-8 bytes.
101
+ val plaintext = "é".repeat(WebMessageRouter.MAX_MESSAGE_BYTES / 2)
102
+ val message = request("id-1", "nostr.nip44_encrypt", JSONObject().put("pubkey", PUBKEY).put("plaintext", plaintext))
103
+
104
+ assertTrue(message.length <= WebMessageRouter.MAX_MESSAGE_BYTES)
105
+ assertTrue(bridge.send(trusted, message).isEmpty())
106
+ }
107
+
108
+ @Test
109
+ fun invalidRequestsWithAValidIdGetAnErrorReply() {
110
+ val bridge = Harness()
111
+ val noMethod = JSONObject().put("v", 1).put("id", "id-1").toString()
112
+ val arrayParams = JSONObject().put("v", 1).put("id", "id-2").put("method", "nostr.signEvent")
113
+ .put("params", org.json.JSONArray().put(1)).toString()
114
+
115
+ for ((message, id) in listOf(noMethod to "id-1", arrayParams to "id-2")) {
116
+ val reply = JSONObject(bridge.send(trusted, message).single())
117
+ assertEquals(id, reply.getString("id"))
118
+ assertFalse(reply.getBoolean("ok"))
119
+ assertEquals("SIGNER_ERROR", reply.getJSONObject("error").getString("error"))
120
+ assertEquals("Invalid request", reply.getJSONObject("error").getString("message"))
121
+ }
122
+ assertEquals(0, bridge.handled)
123
+ }
124
+
125
+ @Test
126
+ fun unknownMethodsAreRejected() {
127
+ val bridge = Harness()
128
+ for (method in listOf("nostr.unknown", "nostr.", "runtime.reset", "AndroidBridge.call", "debug.unknown")) {
129
+ val reply = JSONObject(bridge.send(trusted, request("id-1", method)).single())
130
+ assertFalse(method, reply.getBoolean("ok"))
131
+ assertEquals("SIGNER_ERROR", reply.getJSONObject("error").getString("error"))
132
+ assertEquals("Unsupported method", reply.getJSONObject("error").getString("message"))
133
+ }
134
+ assertTrue(bridge.signer.calls.isEmpty())
135
+ }
136
+
137
+ @Test
138
+ fun signerFailuresKeepTheirErrorCodes() {
139
+ val unavailable = Harness(FakeSigner(failure = SignerUnavailableException("No signer is paired")))
140
+ val unavailableReply = JSONObject(unavailable.send(trusted, request("id-1", "nostr.getPublicKey")).single())
141
+ assertEquals("SIGNER_UNAVAILABLE", unavailableReply.getJSONObject("error").getString("error"))
142
+ assertEquals("No signer is paired", unavailableReply.getJSONObject("error").getString("message"))
143
+
144
+ val failing = Harness(FakeSigner(failure = IllegalStateException("rejected")))
145
+ val failingReply = JSONObject(failing.send(trusted, request("id-1", "nostr.signEvent", EVENT)).single())
146
+ assertFalse(failingReply.getBoolean("ok"))
147
+ assertEquals("SIGNER_ERROR", failingReply.getJSONObject("error").getString("error"))
148
+ assertEquals("rejected", failingReply.getJSONObject("error").getString("message"))
149
+ }
150
+
151
+ @Test
152
+ fun plaintextThatLooksLikeAnErrorObjectIsStillAResult() {
153
+ val plaintext = """{"error":"x","message":"y"}"""
154
+ val bridge = Harness(FakeSigner(decrypted = plaintext))
155
+
156
+ val reply = JSONObject(
157
+ bridge.send(
158
+ trusted,
159
+ request("id-1", "nostr.nip44_decrypt", JSONObject().put("pubkey", PUBKEY).put("ciphertext", "c"))
160
+ ).single()
161
+ )
162
+
163
+ assertTrue(reply.getBoolean("ok"))
164
+ assertEquals(plaintext, reply.getString("result"))
165
+ }
166
+
167
+ @Test
168
+ fun runtimeMethodsUseTheRuntimeApiBaseSettings() {
169
+ val bridge = Harness()
170
+
171
+ val set = JSONObject(
172
+ bridge.send(trusted, request("id-1", "runtime.setApiBaseUrl", JSONObject().put("url", "https://api.example.com/"))).single()
173
+ )
174
+ assertTrue(set.getBoolean("result"))
175
+ val get = JSONObject(bridge.send(trusted, request("id-2", "runtime.getApiBaseUrl")).single())
176
+ assertEquals("https://api.example.com", get.getString("result"))
177
+
178
+ for (params in listOf(JSONObject().put("url", "http://insecure.example.com"), JSONObject().put("url", 5), null)) {
179
+ val rejected = JSONObject(bridge.send(trusted, request("id-3", "runtime.setApiBaseUrl", params)).single())
180
+ assertTrue(rejected.getBoolean("ok"))
181
+ assertFalse(rejected.getBoolean("result"))
182
+ }
183
+ assertEquals("https://api.example.com", bridge.preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY])
184
+
185
+ val callback = JSONObject(bridge.send(trusted, request("id-4", "runtime.getNip55CallbackUrl")).single())
186
+ assertEquals(Nip55Callback.callbackPrefix, callback.getString("result"))
187
+ }
188
+
189
+ @Test
190
+ fun debugLogIsANotificationWithoutAReply() {
191
+ val bridge = Harness()
192
+ val message = JSONObject().put("v", 1).put("method", WebMessageRouter.DEBUG_LOG_METHOD)
193
+ .put("params", JSONObject().put("level", "WARN").put("message", "hello")).toString()
194
+
195
+ assertTrue(bridge.send(trusted, message).isEmpty())
196
+ assertEquals(listOf("WARN" to "hello"), bridge.debugLogs)
197
+
198
+ assertTrue(bridge.send(trusted.copy(isMainFrame = false), message).isEmpty())
199
+ assertEquals(1, bridge.debugLogs.size)
200
+ }
201
+
202
+ @Test
203
+ fun repliesArePostedThroughTheMainThreadHook() {
204
+ var posted = 0
205
+ val bridge = Harness(runOnMain = { block -> posted++; block() })
206
+
207
+ bridge.send(trusted, request("id-1", "nostr.getPublicKey"))
208
+ bridge.send(trusted, JSONObject().put("v", 1).put("id", "id-2").toString())
209
+
210
+ assertEquals(2, posted)
211
+ }
212
+
213
+ private fun request(id: String, method: String, params: JSONObject? = null): String =
214
+ JSONObject().put("v", 1).put("id", id).put("method", method)
215
+ .apply { if (params != null) put("params", params) }
216
+ .toString()
217
+
218
+ private class Harness(
219
+ val signer: FakeSigner = FakeSigner(),
220
+ runOnMain: (() -> Unit) -> Unit = { block -> block() }
221
+ ) {
222
+ val preferences = FakePreferences()
223
+ val debugLogs = mutableListOf<Pair<String, String>>()
224
+ var handled = 0
225
+ private val methods = PakstrBridgeMethods(
226
+ nostr = NostrBridge(signerProvider = { signer }),
227
+ runtime = PakstrBridge(RuntimeApiBaseSettings.createForTesting(preferences) { null }),
228
+ debugLog = { level, message -> debugLogs += level to message }
229
+ )
230
+ private val router = WebMessageRouter(
231
+ handler = { method, params -> handled++; methods.handle(method, params) },
232
+ scope = CoroutineScope(Dispatchers.Unconfined),
233
+ runOnMain = runOnMain
234
+ )
235
+
236
+ fun send(source: MessageSource, data: String?): List<String> {
237
+ val replies = mutableListOf<String>()
238
+ router.onMessage(source, data) { replies += it }
239
+ return replies
240
+ }
241
+ }
242
+
243
+ private class FakeSigner(
244
+ private val failure: Exception? = null,
245
+ private val decrypted: String = "plaintext"
246
+ ) : NostrSigner {
247
+ val calls = mutableListOf<String>()
248
+
249
+ private fun record(name: String) {
250
+ calls += name
251
+ failure?.let { throw it }
252
+ }
253
+
254
+ override suspend fun getPublicKey(): String = PUBKEY.also { record("getPublicKey") }
255
+ override suspend fun signEvent(eventJson: String): String = eventJson.also { record("signEvent") }
256
+ override suspend fun nip04Encrypt(pubkey: String, plaintext: String) = "c04".also { record("nip04Encrypt") }
257
+ override suspend fun nip04Decrypt(pubkey: String, ciphertext: String) = decrypted.also { record("nip04Decrypt") }
258
+ override suspend fun nip44Encrypt(pubkey: String, plaintext: String) = "c44".also { record("nip44Encrypt") }
259
+ override suspend fun nip44Decrypt(pubkey: String, ciphertext: String) = decrypted.also { record("nip44Decrypt") }
260
+ }
261
+
262
+ private class FakePreferences : RuntimeApiBaseSettings.Preferences {
263
+ val values = mutableMapOf<String, String>()
264
+ override fun getString(key: String): String? = values[key]
265
+ override fun putString(key: String, value: String): Boolean {
266
+ values[key] = value
267
+ return true
268
+ }
269
+ override fun remove(key: String): Boolean {
270
+ values.remove(key)
271
+ return true
272
+ }
273
+ }
274
+
275
+ private companion object {
276
+ val PUBKEY = "a".repeat(64)
277
+ val EVENT: JSONObject = JSONObject().put("kind", 1).put("created_at", 1).put("tags", org.json.JSONArray()).put("content", "hi")
278
+ }
279
+ }
@@ -0,0 +1,113 @@
1
+ package com.pakstr.app.signer
2
+
3
+ import com.pakstr.app.signer.interfaces.NostrSigner
4
+ import kotlinx.coroutines.runBlocking
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertThrows
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class NostrBridgeDispatchTest {
13
+ private val pubkey = "b".repeat(64)
14
+
15
+ @Test
16
+ fun each_method_reaches_the_matching_signer_call_with_its_arguments() {
17
+ val signer = RecordingSigner()
18
+ val bridge = NostrBridge(signerProvider = { signer })
19
+ val event = JSONObject()
20
+ .put("kind", 1)
21
+ .put("created_at", 1_700_000_000)
22
+ .put("tags", JSONArray().put(JSONArray().put("t").put("x")))
23
+ .put("content", "hi")
24
+
25
+ runBlocking {
26
+ assertEquals("pk", bridge.handle("getPublicKey", null))
27
+ assertEquals("signed", bridge.handle("signEvent", event))
28
+ assertEquals("c04", bridge.handle("nip04_encrypt", JSONObject().put("pubkey", pubkey).put("plaintext", "p")))
29
+ assertEquals("p04", bridge.handle("nip04_decrypt", JSONObject().put("pubkey", pubkey).put("ciphertext", "c")))
30
+ assertEquals("c44", bridge.handle("nip44_encrypt", JSONObject().put("pubkey", pubkey).put("plaintext", "p")))
31
+ assertEquals("p44", bridge.handle("nip44_decrypt", JSONObject().put("pubkey", pubkey).put("ciphertext", "c")))
32
+ }
33
+
34
+ assertEquals(
35
+ listOf(
36
+ listOf("getPublicKey"),
37
+ listOf("signEvent", event.toString()),
38
+ listOf("nip04Encrypt", pubkey, "p"),
39
+ listOf("nip04Decrypt", pubkey, "c"),
40
+ listOf("nip44Encrypt", pubkey, "p"),
41
+ listOf("nip44Decrypt", pubkey, "c")
42
+ ),
43
+ signer.calls
44
+ )
45
+ assertEquals(event.toString(), JSONObject(signer.calls[1][1]).toString())
46
+ }
47
+
48
+ @Test
49
+ fun invalid_arguments_fail_before_the_signer_is_requested() {
50
+ var requested = 0
51
+ val bridge = NostrBridge(signerProvider = { requested++; RecordingSigner() })
52
+
53
+ val cases = listOf(
54
+ Triple("signEvent", null, "Event payload is empty"),
55
+ Triple("nip04_encrypt", null, "NIP04 encrypt payload is empty"),
56
+ Triple("nip04_decrypt", null, "NIP04 decrypt payload is empty"),
57
+ Triple("nip04_encrypt", JSONObject().put("pubkey", pubkey), null),
58
+ Triple("nip44_encrypt", null, null),
59
+ Triple("nip44_decrypt", JSONObject().put("ciphertext", "c"), null)
60
+ )
61
+ for ((method, params, message) in cases) {
62
+ val error = assertThrows(Exception::class.java) {
63
+ runBlocking { bridge.handle(method, params) }
64
+ }
65
+ if (message != null) assertEquals(method, message, error.message)
66
+ }
67
+ assertEquals(0, requested)
68
+ }
69
+
70
+ @Test
71
+ fun unsupported_methods_are_rejected_without_a_signer() {
72
+ var requested = 0
73
+ val bridge = NostrBridge(signerProvider = { requested++; RecordingSigner() })
74
+
75
+ for (method in listOf("", "call", "debugLog", "getPublicKey ", "nip04Encrypt", "__proto__")) {
76
+ val error = assertThrows(UnsupportedBridgeMethodException::class.java) {
77
+ runBlocking { bridge.handle(method, null) }
78
+ }
79
+ assertEquals("Unsupported method", error.message)
80
+ }
81
+ assertEquals(0, requested)
82
+ }
83
+
84
+ @Test
85
+ fun failures_are_logged_and_rethrown_without_payloads() {
86
+ val logs = mutableListOf<String>()
87
+ val bridge = NostrBridge(
88
+ signerProvider = { throw IllegalStateException("boom") },
89
+ log = { logs += it },
90
+ logError = { message, _ -> logs += message }
91
+ )
92
+
93
+ assertThrows(IllegalStateException::class.java) {
94
+ runBlocking {
95
+ bridge.handle("nip44_encrypt", JSONObject().put("pubkey", pubkey).put("plaintext", "secret text"))
96
+ }
97
+ }
98
+
99
+ assertEquals(listOf("Request: nip44_encrypt", "nip44_encrypt failed"), logs)
100
+ assertTrue(logs.none { "secret text" in it })
101
+ }
102
+
103
+ private class RecordingSigner : NostrSigner {
104
+ val calls = mutableListOf<List<String>>()
105
+
106
+ override suspend fun getPublicKey(): String = "pk".also { calls += listOf("getPublicKey") }
107
+ override suspend fun signEvent(eventJson: String): String = "signed".also { calls += listOf("signEvent", eventJson) }
108
+ override suspend fun nip04Encrypt(pubkey: String, plaintext: String) = "c04".also { calls += listOf("nip04Encrypt", pubkey, plaintext) }
109
+ override suspend fun nip04Decrypt(pubkey: String, ciphertext: String) = "p04".also { calls += listOf("nip04Decrypt", pubkey, ciphertext) }
110
+ override suspend fun nip44Encrypt(pubkey: String, plaintext: String) = "c44".also { calls += listOf("nip44Encrypt", pubkey, plaintext) }
111
+ override suspend fun nip44Decrypt(pubkey: String, ciphertext: String) = "p44".also { calls += listOf("nip44Decrypt", pubkey, ciphertext) }
112
+ }
113
+ }
@@ -17,6 +17,7 @@ json = "20250517"
17
17
  okhttp = "4.12.0"
18
18
  securityCrypto = "1.1.0"
19
19
  zxingCore = "3.5.3"
20
+ webkit = "1.18.0"
20
21
 
21
22
  [libraries]
22
23
  androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@@ -42,6 +43,7 @@ json = { module = "org.json:json", version.ref = "json" }
42
43
  okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
43
44
  androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
44
45
  zxing-core = { module = "com.google.zxing:core", version.ref = "zxingCore" }
46
+ androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
45
47
 
46
48
  [plugins]
47
49
  android-application = { id = "com.android.application", version.ref = "agp" }
@@ -0,0 +1,108 @@
1
+ # Pakstr desktop runtime
2
+
3
+ Electron runtime that hosts a Pakstr web app on desktop, mirroring the Android
4
+ shell's contracts. `pakstr build --target macos-arm64|windows-x64|linux-x64`
5
+ stages this bundle with the app (`src/desktop/` in the CLI); the bundle ships
6
+ in the CLI's npm package. All targets package the same bundle; only the menu
7
+ differs (`src/appMenu.js`).
8
+
9
+ ## Build
10
+
11
+ ```sh
12
+ npm --prefix packages/cli ci
13
+ npm --prefix packages/cli run build # tsc, then bundles dist/main.js and dist/preload.js
14
+ ```
15
+
16
+ `npm --prefix packages/cli/desktop-runtime install` is only needed to run the
17
+ runtime unpackaged with the `electron` dev binary.
18
+
19
+ `dist/main.js` and `dist/preload.js` are self-contained: only `electron` and
20
+ Node built-ins are left external (the build fails otherwise), so a generated
21
+ app needs no `node_modules`. Third-party code and `src/shared` come from the
22
+ CLI package. The `debug` package is compiled out because applesauce logs
23
+ NIP-46 request params through it.
24
+
25
+ ## Generated app layout
26
+
27
+ ```
28
+ <appRoot>/package.json "main" -> the bundled main.js (preload.js beside it)
29
+ <appRoot>/pakstr-desktop.json { "appId": "com.example.app", "appName": "Example" }
30
+ <appRoot>/web/ staged web assets, incl. pakstr-runtime.json
31
+ ```
32
+
33
+ ## Behaviour
34
+
35
+ - The app is served from `pakstr://app` (standard, secure scheme) with the
36
+ Android SPA fallback. `/api/*` is proxied to the effective API base
37
+ (persisted override, then packaged `apiBase`, then disabled). API cookies
38
+ live in the session cookie jar and never reach the renderer, and neither
39
+ do `Set-Cookie` or `Location`. Redirects are not followed.
40
+ - NIP-98: `X-Forwarded-Host: app` / `X-Forwarded-Proto: pakstr` are added
41
+ only when the event's unique `u` tag is exactly the local request URL.
42
+ - `window.nostr` (plain and writable, like `nip07.js`) and the synchronous
43
+ `window.PakstrBridge.getApiBaseUrl()` / `setApiBaseUrl(url)` exist only in
44
+ the main window's `pakstr://app` top frame. The first `window.nostr` call
45
+ without a signer opens a `nostrconnect://` pairing prompt that uses the
46
+ strict `src/shared/nip46Pairing` module.
47
+ - All renderers are sandboxed with context isolation. Top-level navigation
48
+ stays on `pakstr://app`, `window.open` is denied, and web permissions are
49
+ denied except clipboard write and fullscreen. DevTools are available only
50
+ when unpackaged, and a packaged app exits if launched with a
51
+ remote-debugging or certificate-bypass switch. The runtime never disables
52
+ Chromium's sandbox.
53
+ - All data (cookies, storage, API base override) lives in
54
+ `<appData>/<appId>`. The Electron app name is also the appId, because it
55
+ names the keychain item holding the cookie-encryption key ("<name> Safe
56
+ Storage"): renaming `appName` keeps sessions. The macOS menu shows `appName`.
57
+
58
+ ## Windows: not yet verified on a Windows machine
59
+
60
+ Windows packages are cross-built and checked structurally (executable
61
+ metadata, fuses, asar integrity, portable ZIP contents, NSIS output), but
62
+ nothing here runs them. Before relying on `windows-x64`, check on real Windows
63
+ 10/11 x64:
64
+
65
+ - The installer runs without admin rights. The Start menu shortcut, the
66
+ Apps & features entry, and the finish-page launch work, and uninstalling
67
+ removes everything except `%APPDATA%\<appId>`.
68
+ - Reinstalling or upgrading over a running app prompts to close it, and an
69
+ upgrade that renames `appName` leaves no old `.exe` or shortcut behind.
70
+ - The portable ZIP runs after extraction, including from a path with spaces
71
+ or non-ASCII characters.
72
+ - The app starts with the asar-integrity and `OnlyLoadAppFromAsar` fuses on.
73
+ `pakstr://app` loads, `/api` proxying, cookies (DPAPI), `window.nostr`,
74
+ NIP-46 pairing, and signer auth links work, and a second launch focuses the
75
+ first window.
76
+ - Taskbar pinning: Electron's default AppUserModelID may not match the
77
+ installer's shortcut (it has no AUMID), so a pin from the Start menu can
78
+ show as a separate taskbar button.
79
+ - SmartScreen shows the unsigned-publisher warning; there is no Authenticode
80
+ signing yet.
81
+
82
+ ## Linux: not yet verified on a Linux machine
83
+
84
+ Linux packages are cross-built and checked structurally (pinned AppImage
85
+ runtime, root-owned squashfs with exact modes, desktop entry, icon, portable
86
+ ZIP modes, fuses, asar hashes), but nothing here runs them. Electron enforces
87
+ embedded asar integrity on macOS and Windows only, so on Linux the read-only
88
+ AppImage filesystem and the build-time checks are what protect `app.asar`.
89
+ Before relying on `linux-x64`, check on real x64 distributions (e.g. Ubuntu
90
+ 22.04 and 24.04, Fedora, Debian 12):
91
+
92
+ - The AppImage starts with FUSE 3 (`fusermount3`), and with
93
+ `--appimage-extract-and-run` without FUSE; the portable ZIP runs after
94
+ `unzip`, including from a path with spaces.
95
+ - Chromium's sandbox: with unprivileged user namespaces available the app
96
+ starts sandboxed. Where they are restricted (Ubuntu 23.10+ AppArmor) it
97
+ is expected to abort rather than run unsandboxed; record the message. The
98
+ runtime never passes `--no-sandbox`, and `chrome-sandbox` is not setuid.
99
+ - `pakstr://app` loads, `/api` proxying, `window.nostr`, NIP-46 pairing, and
100
+ signer auth links (`xdg-open`) work, and a second launch focuses the first
101
+ window.
102
+ - Cookies are encrypted with the desktop keyring (Secret Service / KWallet);
103
+ without one, Chromium falls back to a fixed key, so check which backend is
104
+ used.
105
+ - The window class / Wayland app id is the appId, so desktop integration
106
+ (AppImageLauncher, appimaged) groups the window with the desktop entry and
107
+ shows the icon. Required system libraries (GTK 3, NSS, libgbm, ALSA, ...)
108
+ are those of any Chromium build and are not bundled.