pakstr 0.3.1 → 0.3.3

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.
@@ -1,13 +1,15 @@
1
1
  package com.pakstr.app
2
2
 
3
3
  import android.content.Context
4
+ import android.util.Log
5
+
4
6
  import android.webkit.MimeTypeMap
7
+
5
8
  import fi.iki.elonen.NanoHTTPD
6
9
  import java.io.IOException
7
- import java.util.Locale
8
-
9
10
  import java.net.HttpURLConnection
10
11
  import java.net.URL
12
+ import java.util.Locale
11
13
 
12
14
  class LocalServer(
13
15
 
@@ -17,158 +19,228 @@ class LocalServer(
17
19
 
18
20
  ) : NanoHTTPD(port) {
19
21
 
22
+ private val TAG = "LocalServer"
23
+
20
24
  override fun serve(session: IHTTPSession?): Response {
21
- // Fallback to root if URI is null
22
- val uri =
23
25
 
24
- (session?.uri ?: "/")
26
+ val uri = (session?.uri ?: "/").replace("..", "")
25
27
 
26
- .replace("..", "")
27
28
 
28
29
  if (uri.startsWith("/api/")) {
29
30
 
30
- return proxyApi(
31
+ if (!RuntimeConfig.apiEnabled(context)) {
31
32
 
32
- session,
33
+ return newFixedLengthResponse(
33
34
 
34
- uri
35
+ Response.Status.NOT_FOUND,
35
36
 
36
- )
37
+ "application/json",
38
+
39
+ """
40
+
41
+ {
42
+ "error":"api_disabled"
43
+ }
44
+ """.trimIndent()
45
+ )
46
+ }
47
+
48
+ return proxyApi(session, uri)
37
49
 
38
50
  }
39
51
 
40
- // Map the root URL to index.html, otherwise target the requested file in the assets folder
41
52
  val filePath = when {
42
53
 
43
54
  uri == "/" -> "www/index.html"
44
55
 
45
- uri.contains(".") -> "www$uri" // css, js, images
56
+ uri.contains(".") -> "www$uri"
46
57
 
47
- else -> "www/index.html" // SPA ROUTES FALLBACK
58
+ else -> "www/index.html"
48
59
 
49
60
  }
50
61
 
62
+
51
63
  return try {
52
- // Open the file directly from the app's assets
64
+
65
+ Log.d(
66
+ TAG, "Serving asset: $filePath"
67
+ )
68
+
53
69
  val inputStream = context.assets.open(filePath)
54
70
 
55
- // Stream the file chunk-by-chunk to optimize memory usage
56
- newChunkedResponse(Response.Status.OK, getMimeType(filePath), inputStream)
71
+ newChunkedResponse(
72
+ Response.Status.OK, getMimeType(filePath), inputStream
73
+ )
74
+
57
75
  } catch (e: IOException) {
58
- // Return clean 404 response if the file does not exist or cannot be read
59
- newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "404 Not Found")
76
+
77
+ Log.e(
78
+ TAG, "Failed loading asset: $filePath", e
79
+ )
80
+
81
+ newFixedLengthResponse(
82
+ Response.Status.NOT_FOUND, "text/plain", "404 Not Found"
83
+ )
60
84
  }
61
85
  }
62
86
 
63
- /**
64
- * Automatically detects the correct MIME type based on the file extension.
65
- */
66
87
  private fun getMimeType(
67
88
  file: String
68
89
  ): String {
69
90
 
70
- val extension =
71
- MimeTypeMap
72
- .getFileExtensionFromUrl(file)
73
- ?.lowercase(Locale.ROOT)
74
- ?: ""
91
+ val extension = MimeTypeMap.getFileExtensionFromUrl(file)
92
+ ?.lowercase(Locale.ROOT) ?: ""
93
+
75
94
 
76
95
  return when (extension) {
77
- "js" ->
78
- "application/javascript"
79
96
 
80
- "json" ->
81
- "application/json"
97
+ "js" -> "application/javascript"
82
98
 
83
- "css" ->
84
- "text/css"
99
+ "json" -> "application/json"
85
100
 
86
- "html" ->
87
- "text/html"
101
+ "css" -> "text/css"
88
102
 
89
- "svg" ->
90
- "image/svg+xml"
103
+ "html" -> "text/html"
91
104
 
92
- else ->
93
- MimeTypeMap
94
- .getSingleton()
95
- .getMimeTypeFromExtension(extension)
96
- ?: "application/octet-stream"
105
+ "svg" -> "image/svg+xml"
106
+
107
+ else -> MimeTypeMap.getSingleton()
108
+ .getMimeTypeFromExtension(extension) ?: "application/octet-stream"
97
109
  }
98
110
  }
99
111
 
112
+ override fun start() {
113
+
114
+ super.start()
115
+
116
+ Log.d(
117
+ TAG, "🚀 LocalServer started on port $listeningPort"
118
+ )
119
+ }
120
+
100
121
  private fun proxyApi(
101
- session: IHTTPSession?,
102
- uri: String
122
+ session: IHTTPSession?, uri: String
103
123
  ): Response {
104
124
 
105
- val apiBase =
106
-
107
- RuntimeConfig.load(context)
125
+ return try {
108
126
 
109
- .optString("apiBase", null)
127
+ val config = RuntimeConfig.load(context)
110
128
 
111
- ?: return newFixedLengthResponse(
129
+ val apiBase = config.optString("apiBase")
112
130
 
131
+ if (apiBase.isBlank()) {
132
+ return newFixedLengthResponse(
113
133
  Response.Status.INTERNAL_ERROR,
114
-
115
134
  "application/json",
116
-
117
135
  """{"error":"apiBase missing"}"""
118
-
119
136
  )
137
+ }
120
138
 
139
+ val finalUrl = apiBase.removeSuffix("/") + uri
121
140
 
122
- return try {
141
+ val connection = URL(finalUrl).openConnection() as HttpURLConnection
142
+ connection.doInput = true
143
+ connection.instanceFollowRedirects = false
144
+
145
+ val method = session?.method?.name ?: "GET"
146
+
147
+ connection.requestMethod = method
148
+
149
+ connection.connectTimeout = 15000
150
+ connection.readTimeout = 30000
123
151
 
124
- val url =
125
- URL(
126
- apiBase.removeSuffix("/") + uri
152
+ /*
153
+ * Forward headers
154
+ */
155
+
156
+ session?.headers?.forEach { (key, value) ->
157
+
158
+ val skip = setOf(
159
+ "host", "content-length", "connection", "accept-encoding"
127
160
  )
128
161
 
129
162
 
130
- val connection =
131
- url.openConnection() as HttpURLConnection
163
+ if (!skip.contains(key.lowercase())) {
132
164
 
165
+ connection.setRequestProperty(
166
+ key, value
167
+ )
168
+ }
169
+ }
170
+ session?.headers?.get("authorization")
171
+ ?.let { auth ->
172
+ connection.setRequestProperty(
173
+ "Authorization",
174
+ auth
175
+ )
176
+ }
133
177
 
134
- connection.requestMethod =
135
- session?.method?.name ?: "GET"
136
178
 
179
+ if (method == "POST" || method == "PUT" || method == "PATCH") {
137
180
 
138
- connection.connectTimeout = 10000
139
- connection.readTimeout = 10000
181
+ val files = HashMap<String, String>()
140
182
 
183
+ session?.parseBody(files)
141
184
 
142
- val code =
143
- connection.responseCode
185
+ val body = files["postData"] ?: ""
144
186
 
187
+ val bytes = body.toByteArray(Charsets.UTF_8)
145
188
 
146
- val stream =
147
- if (code >= 400)
148
- connection.errorStream
149
- else
150
- connection.inputStream
189
+ connection.doOutput = true
190
+ session?.headers?.get("content-type")?.let {
191
+ connection.setRequestProperty(
192
+ "Content-Type",
193
+ it
194
+ )
195
+ }
151
196
 
197
+ connection.outputStream.use { output ->
152
198
 
153
- newChunkedResponse(
154
- Response.Status.lookup(code)
155
- ?: Response.Status.OK,
156
- connection.contentType ?: "application/json",
157
- stream
158
- )
199
+ output.write(bytes)
200
+ output.flush()
201
+ }
202
+ }
203
+
204
+ val code = connection.responseCode
205
+
206
+ val stream = if (code >= 400) connection.errorStream
207
+ else connection.inputStream
208
+
209
+ val body = stream?.bufferedReader()
210
+ ?.use {
211
+ it.readText()
212
+ } ?: ""
159
213
 
214
+ val responseContentType =
215
+ connection.contentType ?: "application/json"
216
+
217
+
218
+ connection.disconnect()
219
+
220
+
221
+ return newFixedLengthResponse(
222
+
223
+ Response.Status.lookup(code) ?: Response.Status.OK,
224
+
225
+ responseContentType,
226
+
227
+ body
228
+
229
+ )
160
230
 
161
231
  } catch (e: Exception) {
232
+ Log.e(
233
+ TAG,
234
+ "Proxy failed: ${e.javaClass.simpleName}"
235
+ )
236
+ return newFixedLengthResponse(
162
237
 
163
- newFixedLengthResponse(
164
238
  Response.Status.INTERNAL_ERROR,
239
+
165
240
  "application/json",
166
- """
167
- {
168
- "error":"proxy_failed",
169
- "message":"${e.message}"
170
- }
171
- """.trimIndent()
241
+
242
+ """{"error":"proxy_failed"}""".trimIndent()
243
+
172
244
  )
173
245
  }
174
246
  }
@@ -18,17 +18,14 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
18
18
  import androidx.core.view.ViewCompat
19
19
  import androidx.core.view.WindowInsetsCompat
20
20
  import androidx.core.view.updatePadding
21
- import androidx.lifecycle.lifecycleScope
21
+
22
22
  import com.pakstr.app.permissions.PermissionManager
23
23
  import com.pakstr.app.permissions.interfaces.PermissionHandler
24
- import com.pakstr.app.crypto.CryptoInitializer
25
- import com.pakstr.app.debug.AppDebugLogger
26
24
 
27
25
  import com.pakstr.app.signer.SignerManager
28
26
  import com.pakstr.app.signer.SignerType
29
27
  import com.pakstr.app.utils.AppPreferences
30
28
  import com.pakstr.app.utils.SignerPickerBottomSheet
31
- import kotlinx.coroutines.launch
32
29
 
33
30
  class MainActivity : AppCompatActivity(), PermissionHandler {
34
31
 
@@ -41,6 +38,9 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
41
38
  private lateinit var amberLauncher:
42
39
  ActivityResultLauncher<Intent>
43
40
 
41
+
42
+ private val TAG = "ShellRuntime"
43
+
44
44
  override fun onCreate(
45
45
  savedInstanceState: Bundle?
46
46
  ) {
@@ -93,9 +93,11 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
93
93
  insets
94
94
  }
95
95
 
96
+
96
97
  setupRuntime()
97
98
  setupBackNavigation()
98
99
  showLoader(true)
100
+
99
101
  runtime.start()
100
102
 
101
103
  }
@@ -143,11 +145,14 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
143
145
  findViewById(R.id.mainContainer)
144
146
  webView =
145
147
  findViewById(R.id.webView)
148
+
146
149
  loader =
147
150
  findViewById(R.id.loader)
148
151
 
149
152
  permissionManager =
150
153
  PermissionManager(this)
154
+
155
+ WebView.setWebContentsDebuggingEnabled(true)
151
156
  }
152
157
 
153
158
  private fun handleSignerSetup() {
@@ -1,25 +1,30 @@
1
1
  package com.pakstr.app
2
2
 
3
3
  import android.content.Context
4
+ import android.util.Log
4
5
  import org.json.JSONObject
5
6
 
6
7
  object RuntimeConfig {
7
8
 
9
+ private const val TAG = "RuntimeConfig"
10
+
8
11
  private var config: JSONObject? = null
9
12
 
10
13
 
11
14
  fun load(context: Context): JSONObject {
12
15
 
13
- if (config != null) {
14
- return config!!
16
+ config?.let {
17
+ Log.d(TAG, "Returning cached config: $it")
18
+ return it
15
19
  }
16
20
 
17
21
 
18
22
  config = try {
19
23
 
20
24
  val input =
21
- context.assets.open("pakstr-runtime.json")
22
-
25
+ context.assets.open(
26
+ "www/pakstr-runtime.json"
27
+ )
23
28
 
24
29
  val json =
25
30
  input.bufferedReader()
@@ -28,24 +33,117 @@ object RuntimeConfig {
28
33
 
29
34
  JSONObject(json)
30
35
 
31
-
32
36
  } catch (e: Exception) {
33
37
 
38
+ Log.e(
39
+ TAG,
40
+ "Failed loading runtime config",
41
+ e
42
+ )
43
+
34
44
  JSONObject()
35
45
 
36
46
  }
37
47
 
38
48
 
49
+ Log.d(
50
+ TAG,
51
+ "FINAL CONFIG: $config"
52
+ )
53
+
54
+
39
55
  return config!!
40
56
  }
41
57
 
42
58
 
43
- fun apiBase(context: Context): String? {
59
+ /**
60
+ * Returns backend API URL.
61
+ *
62
+ * Empty means:
63
+ * - Nostr-only mode
64
+ * - no REST API proxy
65
+ */
66
+ fun apiBase(
67
+ context: Context
68
+ ): String? {
69
+
70
+ val value =
71
+ load(context)
72
+ .optString(
73
+ "apiBase",
74
+ ""
75
+ )
76
+
77
+
78
+ Log.d(
79
+ TAG,
80
+ "apiBase=$value"
81
+ )
82
+
83
+
84
+ return value.takeIf {
85
+ it.isNotBlank()
86
+ }
87
+ }
88
+
89
+
90
+ /**
91
+ * Determines if REST API proxy should be enabled.
92
+ */
93
+ fun apiEnabled(
94
+ context: Context
95
+ ): Boolean {
96
+
97
+ val enabled =
98
+ !apiBase(context).isNullOrBlank()
99
+
100
+
101
+ Log.d(
102
+ TAG,
103
+ "apiEnabled=$enabled"
104
+ )
105
+
106
+
107
+ return enabled
108
+ }
109
+
110
+
111
+ /**
112
+ * Nostr relay URL.
113
+ */
114
+ fun relayUrl(
115
+ context: Context
116
+ ): String {
117
+
118
+ val relay =
119
+ load(context)
120
+ .optString(
121
+ "relayUrl",
122
+ ""
123
+ )
124
+
125
+
126
+ Log.d(
127
+ TAG,
128
+ "relayUrl=$relay"
129
+ )
130
+
131
+
132
+ return relay
133
+ }
134
+
135
+
136
+ /**
137
+ * Clears cached config.
138
+ * Useful for testing after changing pakstr-runtime.json.
139
+ */
140
+ fun clearCache() {
141
+
142
+ config = null
44
143
 
45
- return load(context)
46
- .optString("apiBase")
47
- .takeIf {
48
- it.isNotBlank()
49
- }
144
+ Log.d(
145
+ TAG,
146
+ "Config cache cleared"
147
+ )
50
148
  }
51
149
  }
@@ -49,6 +49,7 @@ class WebViewController(
49
49
 
50
50
  fun setup() {
51
51
  showLoader()
52
+ setupCookies()
52
53
  setupWebView()
53
54
  loadApp()
54
55
  }
@@ -141,6 +142,24 @@ class WebViewController(
141
142
 
142
143
  }
143
144
 
145
+ private fun setupCookies() {
146
+
147
+ val cookieManager = CookieManager.getInstance()
148
+
149
+ cookieManager.setAcceptCookie(true)
150
+
151
+ cookieManager.setAcceptThirdPartyCookies(
152
+
153
+ webView,
154
+
155
+ true
156
+
157
+ )
158
+
159
+ cookieManager.flush()
160
+
161
+ }
162
+
144
163
  /**
145
164
  * Loads the local server URL with a minor delay to ensure NanoHTTPD is fully bound.
146
165
  */
@@ -34,15 +34,20 @@ object NostrEventHasher {
34
34
  event: JSONObject
35
35
  ): String {
36
36
 
37
+ val pubkey =
38
+ event.optString("pubkey")
39
+
40
+ require(pubkey.isNotBlank()) {
41
+ "Missing pubkey in nostr event: $event"
42
+ }
43
+
37
44
  val serialized =
38
45
  JSONArray()
39
46
  .apply {
40
47
 
41
48
  put(0)
42
49
 
43
- put(
44
- event.getString("pubkey")
45
- )
50
+ put(pubkey)
46
51
 
47
52
  put(
48
53
  event.getLong("created_at")
@@ -67,7 +72,6 @@ object NostrEventHasher {
67
72
  return sha256Hex(
68
73
  serialized.toByteArray()
69
74
  )
70
-
71
75
  }
72
76
 
73
77
  /**
@@ -118,79 +118,88 @@ class AmberSigner(
118
118
  override suspend fun signEvent(
119
119
  eventJson: String
120
120
  ): String =
121
-
122
121
  withTimeout(30_000) {
123
122
 
123
+ val event = JSONObject(eventJson)
124
+
125
+ val currentPubkey = event.optString("pubkey")
126
+
127
+ if (currentPubkey.isBlank()) {
128
+
129
+ val amberPubkey = getPublicKey()
130
+
131
+ require(amberPubkey.isNotBlank()) {
132
+
133
+ "Amber returned empty pubkey"
134
+
135
+ }
136
+
137
+ event.put(
138
+
139
+ "pubkey",
140
+
141
+ amberPubkey
142
+
143
+ )
144
+
145
+ }
146
+
124
147
  suspendCancellableCoroutine { continuation ->
125
148
 
126
- val event = JSONObject(eventJson)
149
+ AppDebugLogger.log(
150
+ "AMBER",
151
+ "Signing event request"
152
+ )
127
153
 
128
154
  pendingCallback = { signature ->
129
155
 
130
156
  if (signature.isEmpty()) {
131
157
 
132
- if (continuation.isActive) {
133
-
134
- continuation.resume("")
135
-
136
- }
158
+ continuation.resume("")
137
159
 
138
160
  } else {
139
161
 
140
162
  val signedEvent =
141
-
142
163
  NostrEventBuilder.addId(event)
143
164
  .apply {
144
-
145
165
  put(
146
- "sig", signature
166
+ "sig",
167
+ signature
147
168
  )
148
-
149
169
  }
150
- // Verify the signed event locally before returning it.
151
- // This ensures that the received signature matches the event data
152
- // and protects against invalid signer responses.
153
- val verified = NostrEventVerifier.verify(signedEvent)
154
-
155
- AppDebugLogger.log(
156
- "NOSTR_VERIFY", "Event verified=$verified"
157
- )
158
-
159
- if (continuation.isActive) {
160
- if (verified) {
161
- continuation.resume(
162
- signedEvent.toString()
163
- )
164
-
165
- } else {
166
- continuation.resume("")
167
- }
170
+
171
+
172
+ val verified =
173
+ NostrEventVerifier.verify(
174
+ signedEvent
175
+ )
176
+
177
+
178
+ if (verified) {
179
+ continuation.resume(
180
+ signedEvent.toString()
181
+ )
182
+ } else {
183
+ continuation.resume("")
168
184
  }
169
185
  }
170
186
  }
171
187
 
188
+
172
189
  val intent = Intent(
173
- Intent.ACTION_VIEW, Uri.parse(
190
+ Intent.ACTION_VIEW,
191
+ Uri.parse(
174
192
  "nostrsigner:${Uri.encode(event.toString())}"
175
193
  )
176
194
  ).apply {
177
195
  putExtra(
178
- "type", "sign_event"
196
+ "type",
197
+ "sign_event"
179
198
  )
180
199
  }
181
- try {
182
- launcher.launch(intent)
183
-
184
- } catch (_: ActivityNotFoundException) {
185
200
 
186
- if (continuation.isActive) {
187
- continuation.resume("")
188
- }
189
- }
190
201
 
191
- continuation.invokeOnCancellation {
192
- pendingCallback = null
193
- }
202
+ launcher.launch(intent)
194
203
  }
195
204
  }
196
205
 
@@ -434,13 +443,6 @@ class AmberSigner(
434
443
  )
435
444
  }
436
445
 
437
-
438
- AppDebugLogger.log(
439
- "NIP44",
440
- "decrypt sender=$pubkey current=$pubkey length=${ciphertext.length}"
441
- )
442
-
443
-
444
446
  try {
445
447
 
446
448
  launcher.launch(intent)
@@ -489,27 +491,12 @@ class AmberSigner(
489
491
  ?: result.data?.getStringExtra("content")
490
492
 
491
493
  ?: ""
492
-
493
494
  AppDebugLogger.log(
494
-
495
- "AMBER_RESULT",
496
-
497
- value
498
-
495
+ "AMBER",
496
+ "Operation completed"
499
497
  )
500
- AppDebugLogger.log(
501
-
502
- "AMBER_RESULT_KEYS",
503
-
504
- result.data?.extras?.keySet()?.joinToString()
505
-
506
- ?: "NO_KEYS"
507
498
 
508
- )
509
-
510
- callback?.invoke(
511
- value
512
- )
499
+ callback?.invoke(value)
513
500
  }
514
501
 
515
502
  private suspend fun getCachedPublicKey(): String {
@@ -133,7 +133,6 @@ class NostrBridge(
133
133
  val plaintext =
134
134
  json.getString("plaintext")
135
135
 
136
-
137
136
  val result =
138
137
  signerManager
139
138
  .getSigner()
@@ -171,7 +170,6 @@ class NostrBridge(
171
170
  }
172
171
  }
173
172
 
174
-
175
173
  "nip04_decrypt" -> {
176
174
 
177
175
  scope.launch {
@@ -182,7 +180,6 @@ class NostrBridge(
182
180
  "NIP04 decrypt payload is empty"
183
181
  }
184
182
 
185
-
186
183
  val json =
187
184
  JSONObject(payload)
188
185
 
@@ -192,7 +189,6 @@ class NostrBridge(
192
189
  val ciphertext =
193
190
  json.getString("ciphertext")
194
191
 
195
-
196
192
  val result =
197
193
  signerManager
198
194
  .getSigner()
@@ -207,7 +203,6 @@ class NostrBridge(
207
203
  result
208
204
  )
209
205
 
210
-
211
206
  } catch (e: SignerUnavailableException) {
212
207
 
213
208
  resolve(
@@ -253,7 +248,6 @@ class NostrBridge(
253
248
  result
254
249
  )
255
250
 
256
-
257
251
  } catch (e: Exception) {
258
252
 
259
253
  resolve(
@@ -289,7 +283,6 @@ class NostrBridge(
289
283
  result
290
284
  )
291
285
 
292
-
293
286
  } catch (e: Exception) {
294
287
 
295
288
  resolve(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",