pakstr 0.18.2 → 0.19.1

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 CHANGED
@@ -44,8 +44,13 @@ build:
44
44
  web: ./dist
45
45
  out: ./build/app.apk
46
46
  builder: docker
47
+
48
+ # runtime:
49
+ # apiBase: https://api.example.com # Optional packaged /api/* proxy target.
47
50
  ```
48
51
 
52
+ `runtime.apiBase` must be an absolute HTTPS URL. It is public APK metadata, not a secret. When omitted, Pakstr generates an empty runtime config and keeps `/api/*` disabled; when set, `/api/x` forwards to `<apiBase>/api/x`.
53
+
49
54
  ### 3. Build, sign, and publish
50
55
  ```bash
51
56
  npx pakstr run
@@ -39,6 +39,23 @@ android {
39
39
  }
40
40
  }
41
41
 
42
+ androidResources {
43
+ ignoreAssetsPatterns.clear()
44
+ ignoreAssetsPatterns.addAll(
45
+ listOf(
46
+ "!.svn",
47
+ "!.git",
48
+ "!.ds_store",
49
+ "!*.scc",
50
+ ".*",
51
+ "!CVS",
52
+ "!thumbs.db",
53
+ "!picasa.ini",
54
+ "!*~"
55
+ )
56
+ )
57
+ }
58
+
42
59
  defaultConfig {
43
60
  applicationId = "com.pakstr.app"
44
61
  minSdk = 28
@@ -53,9 +70,23 @@ android {
53
70
  debug {
54
71
  applicationIdSuffix = ".debug"
55
72
  versionNameSuffix = "-debug"
73
+ manifestPlaceholders["pakstrNip55CallbackScheme"] =
74
+ "pakstr-${providers.gradleProperty("pakstrDebugCallbackHash").get()}"
75
+ buildConfigField(
76
+ "String",
77
+ "PAKSTR_NIP55_CALLBACK_SCHEME",
78
+ "\"pakstr-${providers.gradleProperty("pakstrDebugCallbackHash").get()}\""
79
+ )
56
80
  }
57
81
 
58
82
  release {
83
+ manifestPlaceholders["pakstrNip55CallbackScheme"] =
84
+ "pakstr-${providers.gradleProperty("pakstrReleaseCallbackHash").get()}"
85
+ buildConfigField(
86
+ "String",
87
+ "PAKSTR_NIP55_CALLBACK_SCHEME",
88
+ "\"pakstr-${providers.gradleProperty("pakstrReleaseCallbackHash").get()}\""
89
+ )
59
90
  // Only attach the signing config when a keystore was provided;
60
91
  // otherwise emit an unsigned release APK for pakstr sign to sign.
61
92
  val keystorePath = System.getenv("PAKSTR_ANDROID_KEYSTORE_PATH")
@@ -0,0 +1,106 @@
1
+ package com.pakstr.app
2
+
3
+ import android.content.Intent
4
+ import android.net.Uri
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertNull
7
+ import org.junit.Test
8
+
9
+ class Nip55CallbackTest {
10
+
11
+ @Test
12
+ fun accepts_fragment_callback_and_preserves_logical_event() {
13
+ val event = "{\"kind\":27235,\"content\":\"hello & goodbye / 100%\"}"
14
+ val intent = callbackIntent(
15
+ "${Nip55Callback.PARAM}=${Uri.encode(event)}"
16
+ )
17
+
18
+ val result = Nip55Callback.result(intent)
19
+
20
+ assertEquals(event, result)
21
+ assertEquals(
22
+ event,
23
+ Uri.parse(Nip55Callback.localUrl(result!!))
24
+ .getQueryParameter(Nip55Callback.PARAM)
25
+ )
26
+ }
27
+
28
+ @Test
29
+ fun rejects_query_callback() {
30
+ val url = Uri.Builder()
31
+ .scheme(BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME)
32
+ .authority("nip55-callback")
33
+ .appendQueryParameter(Nip55Callback.PARAM, "value")
34
+ .build()
35
+
36
+ assertNull(Nip55Callback.result(Intent(Intent.ACTION_VIEW, url)))
37
+ }
38
+
39
+ @Test
40
+ fun rejects_missing_or_empty_fragment_result() {
41
+ assertNull(Nip55Callback.result(callbackIntent("other=value")))
42
+ assertNull(Nip55Callback.result(callbackIntent("${Nip55Callback.PARAM}=")))
43
+ assertNull(Nip55Callback.result(callbackIntent(null)))
44
+ }
45
+
46
+ @Test
47
+ fun rejects_unexpected_fragment_structure() {
48
+ assertNull(
49
+ Nip55Callback.result(
50
+ callbackIntent("${Nip55Callback.PARAM}=value&other=value")
51
+ )
52
+ )
53
+ assertNull(
54
+ Nip55Callback.result(
55
+ callbackIntent("prefix-${Nip55Callback.PARAM}=value")
56
+ )
57
+ )
58
+ assertNull(
59
+ Nip55Callback.result(
60
+ callbackIntent("${Nip55Callback.PARAM}=%not-encoded")
61
+ )
62
+ )
63
+ }
64
+
65
+ @Test
66
+ fun rejects_untrusted_callback_location_or_action() {
67
+ val validScheme = BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME
68
+ val encodedFragment = "${Nip55Callback.PARAM}=value"
69
+ val urls = listOf(
70
+ "https://nip55-callback#$encodedFragment",
71
+ "$validScheme://other#$encodedFragment",
72
+ "$validScheme://nip55-callback/path#$encodedFragment",
73
+ "$validScheme://nip55-callback:123#$encodedFragment"
74
+ )
75
+
76
+ urls.forEach { url ->
77
+ assertNull(
78
+ url,
79
+ Nip55Callback.result(
80
+ Intent(Intent.ACTION_VIEW, Uri.parse(url))
81
+ )
82
+ )
83
+ }
84
+ assertNull(
85
+ Nip55Callback.result(
86
+ Intent(
87
+ Intent.ACTION_SEND,
88
+ Uri.parse("$validScheme://nip55-callback#$encodedFragment")
89
+ )
90
+ )
91
+ )
92
+ }
93
+
94
+ private fun callbackIntent(encodedFragment: String?): Intent {
95
+ val url = Uri.Builder()
96
+ .scheme(BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME)
97
+ .authority("nip55-callback")
98
+ .apply {
99
+ if (encodedFragment != null) {
100
+ encodedFragment(encodedFragment)
101
+ }
102
+ }
103
+ .build()
104
+ return Intent(Intent.ACTION_VIEW, url)
105
+ }
106
+ }
@@ -37,6 +37,7 @@
37
37
  <activity
38
38
  android:name=".MainActivity"
39
39
  android:exported="true"
40
+ android:launchMode="singleTask"
40
41
  android:theme="@style/Theme.App.Starting">
41
42
 
42
43
  <intent-filter>
@@ -44,6 +45,15 @@
44
45
  <category android:name="android.intent.category.LAUNCHER" />
45
46
  </intent-filter>
46
47
 
48
+ <intent-filter>
49
+ <action android:name="android.intent.action.VIEW" />
50
+ <category android:name="android.intent.category.DEFAULT" />
51
+ <category android:name="android.intent.category.BROWSABLE" />
52
+ <data
53
+ android:scheme="${pakstrNip55CallbackScheme}"
54
+ android:host="nip55-callback" />
55
+ </intent-filter>
56
+
47
57
  </activity>
48
58
 
49
59
  <provider
@@ -160,9 +160,16 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
160
160
  showLoader(true)
161
161
 
162
162
  runtime.start()
163
+ Nip55Callback.result(intent)?.let(runtime::handleNip55Callback)
163
164
 
164
165
  }
165
166
 
167
+ override fun onNewIntent(intent: Intent) {
168
+ super.onNewIntent(intent)
169
+ setIntent(intent)
170
+ Nip55Callback.result(intent)?.let(runtime::handleNip55Callback)
171
+ }
172
+
166
173
  private fun showDebugFab() {
167
174
  diagnosticsHandler.removeCallbacks(diagnosticsExpiryCheck)
168
175
 
@@ -0,0 +1,55 @@
1
+ package com.pakstr.app
2
+
3
+ import android.content.Intent
4
+ import android.net.Uri
5
+
6
+ internal object Nip55Callback {
7
+
8
+ const val PARAM = "nip55_event"
9
+ private const val HOST = "nip55-callback"
10
+ private const val FRAGMENT_PREFIX = "$PARAM="
11
+ private const val MAX_RESULT_LENGTH = 128 * 1024
12
+
13
+ fun result(intent: Intent?): String? {
14
+ if (intent?.action != Intent.ACTION_VIEW) {
15
+ return null
16
+ }
17
+
18
+ val url = intent.data ?: return null
19
+ if (url.scheme != BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME ||
20
+ url.host != HOST ||
21
+ (url.path != null && url.path != "") ||
22
+ url.port != -1 ||
23
+ url.query != null
24
+ ) {
25
+ return null
26
+ }
27
+
28
+ val encodedFragment = url.encodedFragment ?: return null
29
+ if (!encodedFragment.startsWith(FRAGMENT_PREFIX)) {
30
+ return null
31
+ }
32
+
33
+ val encodedResult = encodedFragment.removePrefix(FRAGMENT_PREFIX)
34
+ if (encodedResult.isEmpty()) {
35
+ return null
36
+ }
37
+
38
+ val result = Uri.decode(encodedResult)
39
+ return result.takeIf {
40
+ it.isNotEmpty() &&
41
+ it.length <= MAX_RESULT_LENGTH &&
42
+ Uri.encode(it) == encodedResult
43
+ }
44
+ }
45
+
46
+ fun localUrl(result: String): String {
47
+ return Uri.Builder()
48
+ .scheme("http")
49
+ .encodedAuthority("127.0.0.1:${Config.PORT}")
50
+ .path("/")
51
+ .appendQueryParameter(PARAM, result)
52
+ .build()
53
+ .toString()
54
+ }
55
+ }
@@ -0,0 +1,11 @@
1
+ package com.pakstr.app
2
+
3
+ import android.webkit.JavascriptInterface
4
+
5
+ class PakstrBridge {
6
+
7
+ @JavascriptInterface
8
+ fun getNip55CallbackUrl(): String {
9
+ return "${BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME}://nip55-callback#nip55_event="
10
+ }
11
+ }
@@ -198,6 +198,10 @@ class ShellRuntime(
198
198
 
199
199
  }
200
200
 
201
+ fun handleNip55Callback(result: String) {
202
+ controller.handleNip55Callback(result)
203
+ }
204
+
201
205
  fun getWebview(): WebView {
202
206
 
203
207
  return webView
@@ -1,7 +1,10 @@
1
1
  package com.pakstr.app
2
2
 
3
3
  import android.app.Activity
4
+ import android.content.ActivityNotFoundException
4
5
  import android.content.Context
6
+ import android.content.Intent
7
+ import android.provider.Browser
5
8
  import android.view.View
6
9
  import android.webkit.CookieManager
7
10
  import android.webkit.PermissionRequest
@@ -39,9 +42,15 @@ class WebViewController(
39
42
  private val permissionHandler: PermissionHandler
40
43
 
41
44
  ) {
45
+ private var pendingNip55Result: String? = null
46
+ private var isSetup = false
47
+
42
48
  // Keeps a reference to the runnable to prevent memory leaks if destroyed early
43
49
  private val loadRunnable = Runnable {
44
- webView.loadUrl("http://127.0.0.1:$port/")
50
+ val url = pendingNip55Result?.let(Nip55Callback::localUrl)
51
+ ?: "http://127.0.0.1:$port/"
52
+ pendingNip55Result = null
53
+ webView.loadUrl(url)
45
54
  //webView.loadUrl("https://sigit.io")
46
55
  //webView.loadUrl("http://127.0.0.1:$port/not-found.html")
47
56
  }
@@ -52,9 +61,22 @@ class WebViewController(
52
61
  showLoader()
53
62
  setupCookies()
54
63
  setupWebView()
64
+ isSetup = true
55
65
  loadApp()
56
66
  }
57
67
 
68
+ fun handleNip55Callback(result: String) {
69
+ if (!isSetup) {
70
+ pendingNip55Result = result
71
+ return
72
+ }
73
+
74
+ webView.removeCallbacks(loadRunnable)
75
+ webView.post {
76
+ webView.loadUrl(Nip55Callback.localUrl(result))
77
+ }
78
+ }
79
+
58
80
  private fun setupWebView() {
59
81
  webView.apply {
60
82
  // Idiomatically configure WebView settings using scoping functions
@@ -205,6 +227,25 @@ class WebViewController(
205
227
  view: WebView?, request: WebResourceRequest?
206
228
  ): Boolean {
207
229
  val url = request?.url ?: return true
230
+ if (url.scheme.equals("nostrsigner", ignoreCase = true)) {
231
+ try {
232
+ val intent = Intent(Intent.ACTION_VIEW, url).apply {
233
+ putExtra(
234
+ Browser.EXTRA_APPLICATION_ID,
235
+ context.packageName
236
+ )
237
+ }
238
+ context.startActivity(intent)
239
+ } catch (error: ActivityNotFoundException) {
240
+ AppDebugLogger.error(
241
+ context,
242
+ "WEBVIEW",
243
+ "No app can handle nostrsigner URL",
244
+ error
245
+ )
246
+ }
247
+ return true
248
+ }
208
249
  return url.host != "127.0.0.1"
209
250
  }
210
251
 
@@ -274,6 +315,10 @@ class WebViewController(
274
315
  "AndroidBridge"
275
316
 
276
317
  )
318
+ webView.addJavascriptInterface(
319
+ PakstrBridge(),
320
+ "PakstrBridge"
321
+ )
277
322
 
278
323
  }
279
324
 
@@ -320,6 +365,7 @@ class WebViewController(
320
365
 
321
366
  webView.loadUrl("about:blank")
322
367
  webView.removeCallbacks(loadRunnable)
368
+ pendingNip55Result = null
323
369
  webView.onPause()
324
370
 
325
371
 
@@ -12,4 +12,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
12
12
  # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13
13
  # org.gradle.parallel=true
14
14
  # Kotlin code style for this project: "official" or "obsolete":
15
- kotlin.code.style=official
15
+ kotlin.code.style=official
16
+ pakstrReleaseCallbackHash=e4f4806f2885cc1a66691b4798000f54f5109b8b0fe5eb2e25d6f4e6bd879024
17
+ pakstrDebugCallbackHash=693aba441df6e40093bc62f2bf8eb9591cc5d9178729d604cccf6b45a4c6521d
@@ -4,7 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.patchGradle = patchGradle;
7
+ exports.callbackScheme = callbackScheme;
7
8
  exports.readAndroidGradleMetadata = readAndroidGradleMetadata;
9
+ const crypto_1 = __importDefault(require("crypto"));
8
10
  const fs_1 = __importDefault(require("fs"));
9
11
  const path_1 = __importDefault(require("path"));
10
12
  const ASSIGNMENTS = {
@@ -20,6 +22,7 @@ function patchGradle(androidRoot, manifest) {
20
22
  content = replaceSingleAssignment(content, "versionCode", ASSIGNMENTS.versionCode, `versionCode = ${manifest.versionCode}`);
21
23
  content = replaceSingleAssignment(content, "versionName", ASSIGNMENTS.versionName, `versionName = ${quoteKotlinString(manifest.versionName)}`);
22
24
  fs_1.default.writeFileSync(gradlePath, content);
25
+ patchCallbackHashes(androidRoot, manifest.appId);
23
26
  const metadata = readAndroidGradleMetadata(gradlePath);
24
27
  if (metadata.applicationId !== manifest.appId) {
25
28
  throw new Error("Generated Gradle applicationId does not match manifest.appId");
@@ -33,6 +36,25 @@ function patchGradle(androidRoot, manifest) {
33
36
  console.log("⚙️ Gradle identity and version patched");
34
37
  return metadata;
35
38
  }
39
+ function callbackScheme(applicationId) {
40
+ const hash = crypto_1.default.createHash("sha256").update(applicationId, "utf8").digest("hex");
41
+ return `pakstr-${hash}`;
42
+ }
43
+ function patchCallbackHashes(androidRoot, applicationId) {
44
+ const propertiesPath = path_1.default.join(androidRoot, "gradle.properties");
45
+ let content = fs_1.default.readFileSync(propertiesPath, "utf8");
46
+ content = replaceSingleProperty(content, "pakstrReleaseCallbackHash", callbackScheme(applicationId).slice("pakstr-".length));
47
+ content = replaceSingleProperty(content, "pakstrDebugCallbackHash", callbackScheme(`${applicationId}.debug`).slice("pakstr-".length));
48
+ fs_1.default.writeFileSync(propertiesPath, content);
49
+ }
50
+ function replaceSingleProperty(content, name, value) {
51
+ const pattern = new RegExp(`^${name}=.*$`, "gm");
52
+ const matches = content.match(pattern) ?? [];
53
+ if (matches.length !== 1) {
54
+ throw new Error(`Expected exactly one Gradle property ${name}, found ${matches.length}`);
55
+ }
56
+ return content.replace(pattern, `${name}=${value}`);
57
+ }
36
58
  function readAndroidGradleMetadata(gradlePath) {
37
59
  const content = fs_1.default.readFileSync(gradlePath, "utf8");
38
60
  return {
@@ -36,6 +36,7 @@ async function buildCommand(configPath) {
36
36
  androidRoot,
37
37
  webDir: config.build.web,
38
38
  app: config.app,
39
+ runtime: config.runtime,
39
40
  configDir: config.configDir,
40
41
  });
41
42
  console.log("\n⚙️ Running build runner...");
@@ -183,6 +183,9 @@ build:
183
183
  out: ${c.out} # OPTIONAL. Default ./build/<appId>.apk
184
184
  builder: docker # Only "docker" is specified.
185
185
 
186
+ # runtime:
187
+ # apiBase: https://api.example.com # OPTIONAL. Enables the packaged /api/* proxy.
188
+
186
189
  publish:
187
190
  upload:
188
191
  provider: blossom
@@ -21,7 +21,7 @@ const DEFAULT_ICON_PATH = path_1.default.resolve(__dirname, "../../assets/logo.p
21
21
  * icon, splash, and permissions, then verifies the generated project.
22
22
  */
23
23
  async function prepareAndroidProject(opts) {
24
- const { androidRoot, webDir, app, configDir } = opts;
24
+ const { androidRoot, webDir, app, runtime = {}, configDir } = opts;
25
25
  const assetsTarget = path_1.default.join(androidRoot, "app/src/main/assets/www");
26
26
  if (!fs_1.default.existsSync(webDir)) {
27
27
  throw new Error(`Web assets not found: ${webDir}`);
@@ -29,7 +29,7 @@ async function prepareAndroidProject(opts) {
29
29
  fs_1.default.rmSync(assetsTarget, { recursive: true, force: true });
30
30
  fs_1.default.mkdirSync(assetsTarget, { recursive: true });
31
31
  copyFolder(webDir, assetsTarget);
32
- generateRuntimeConfig(assetsTarget);
32
+ generateRuntimeConfig(assetsTarget, runtime);
33
33
  (0, gradle_1.patchGradle)(androidRoot, app);
34
34
  (0, branding_1.patchAppName)(androidRoot, app.appName);
35
35
  const icon = await (0, iconResolver_1.resolveAppIcon)({
@@ -50,16 +50,12 @@ async function prepareAndroidProject(opts) {
50
50
  appLabel: app.appName,
51
51
  });
52
52
  }
53
- /**
54
- * Generate the runtime config written into the packaged web assets.
55
- * Pakstr's config model has no runtime/debug fields, so this emits an empty
56
- * config (kept for template compatibility).
57
- */
58
- function generateRuntimeConfig(webRoot) {
59
- const runtime = {};
60
- const jsContent = `window.PAKSTR_CONFIG = ${JSON.stringify(runtime, null, 2)};\n`;
61
- fs_1.default.writeFileSync(path_1.default.join(webRoot, "pakstr-config.js"), jsContent.trim());
62
- fs_1.default.writeFileSync(path_1.default.join(webRoot, "pakstr-runtime.json"), JSON.stringify(runtime, null, 2));
53
+ /** Generate runtime config files in the packaged web assets. */
54
+ function generateRuntimeConfig(webRoot, runtime = {}) {
55
+ const serialized = JSON.stringify(runtime, null, 2);
56
+ const jsContent = `window.PAKSTR_CONFIG = ${serialized};`;
57
+ fs_1.default.writeFileSync(path_1.default.join(webRoot, "pakstr-config.js"), jsContent);
58
+ fs_1.default.writeFileSync(path_1.default.join(webRoot, "pakstr-runtime.json"), serialized);
63
59
  }
64
60
  function copyFolder(src, dest) {
65
61
  for (const entry of fs_1.default.readdirSync(src, { withFileTypes: true })) {
@@ -178,6 +178,20 @@ function resolveAndValidate(config, configPath) {
178
178
  versionName: resolvedVersion.versionName,
179
179
  versionCode: resolvedVersion.versionCode,
180
180
  };
181
+ // runtime section
182
+ const runtime = config.runtime;
183
+ const resolvedRuntime = {};
184
+ if (runtime !== undefined) {
185
+ if (runtime === null || typeof runtime !== "object" || Array.isArray(runtime)) {
186
+ fail(`${where("runtime")} must be a mapping`, configPath);
187
+ }
188
+ if (runtime.apiBase !== undefined) {
189
+ if (typeof runtime.apiBase !== "string" || runtime.apiBase.length === 0) {
190
+ fail(`${where("runtime.apiBase")} must be a non-empty string`, configPath);
191
+ }
192
+ resolvedRuntime.apiBase = validateRuntimeApiBase(runtime.apiBase, "runtime.apiBase", configPath);
193
+ }
194
+ }
181
195
  // publish section
182
196
  const publish = config.publish;
183
197
  let zapstoreEnabled = true;
@@ -277,6 +291,7 @@ function resolveAndValidate(config, configPath) {
277
291
  configPath,
278
292
  app: resolvedApp,
279
293
  build: { web: webAbs, out, builder },
294
+ runtime: resolvedRuntime,
280
295
  publish: { zapstoreEnabled, zapstoreSource, upload, publishKey, relay, blossom },
281
296
  };
282
297
  }
@@ -311,6 +326,25 @@ function validatePublicUrl(value, field, configPath) {
311
326
  }
312
327
  return parsed.toString().replace(/\/$/, value.endsWith("/") ? "/" : "");
313
328
  }
329
+ function validateRuntimeApiBase(value, field, configPath) {
330
+ let parsed;
331
+ try {
332
+ parsed = new URL(value);
333
+ }
334
+ catch {
335
+ fail(`${field} must be an absolute HTTPS URL`, configPath);
336
+ }
337
+ if (parsed.protocol !== "https:" || !parsed.hostname) {
338
+ fail(`${field} must be an absolute HTTPS URL`, configPath);
339
+ }
340
+ if (parsed.username || parsed.password) {
341
+ fail(`${field} must not contain credentials`, configPath);
342
+ }
343
+ if (parsed.search || parsed.hash) {
344
+ fail(`${field} must not contain a query string or fragment`, configPath);
345
+ }
346
+ return parsed.toString().replace(/\/$/, "");
347
+ }
314
348
  function requireSafeSegment(value, field, configPath) {
315
349
  requireString(value, field, configPath);
316
350
  if (value === "." || value === ".." || /[\\/\x00-\x1f\x7f]/.test(value)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.18.2",
3
+ "version": "0.19.1",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",