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 +5 -0
- package/android-template/app/build.gradle.kts +31 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/Nip55CallbackTest.kt +106 -0
- package/android-template/app/src/main/AndroidManifest.xml +10 -0
- package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +7 -0
- package/android-template/app/src/main/java/com/pakstr/app/Nip55Callback.kt +55 -0
- package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +11 -0
- package/android-template/app/src/main/java/com/pakstr/app/ShellRuntime.kt +4 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +47 -1
- package/android-template/gradle.properties +3 -1
- package/dist/android/gradle.js +22 -0
- package/dist/commands/build.js +1 -0
- package/dist/commands/init.js +3 -0
- package/dist/core/androidProject.js +8 -12
- package/dist/core/pakstrConfig.js +34 -0
- package/package.json +1 -1
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
package/dist/android/gradle.js
CHANGED
|
@@ -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 {
|
package/dist/commands/build.js
CHANGED
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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)) {
|