pakstr 0.22.0 → 0.24.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 +1 -1
- package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +12 -28
- package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +13 -1
- package/android-template/app/src/main/java/com/pakstr/app/RuntimeApiBaseSettings.kt +98 -0
- package/android-template/app/src/main/java/com/pakstr/app/RuntimeApiBaseValidator.kt +30 -0
- package/android-template/app/src/main/java/com/pakstr/app/RuntimeConfig.kt +7 -12
- package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +1 -1
- package/android-template/app/src/main/java/com/pakstr/app/debug/DeveloperToolsActivity.kt +70 -4
- package/android-template/app/src/main/res/layout/activity_developer_tools.xml +257 -66
- package/android-template/app/src/main/res/values/strings.xml +24 -5
- package/android-template/app/src/test/java/com/pakstr/app/PakstrBridgeRuntimeApiBaseTest.kt +52 -0
- package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseProxyTest.kt +49 -0
- package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseSettingsTest.kt +107 -0
- package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseValidatorTest.kt +50 -0
- package/dist/commands/publish.js +22 -0
- package/dist/core/zapStore.js +2 -0
- package/dist/core/zapstoreConfig.js +19 -1
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertFalse
|
|
5
|
+
import org.junit.Assert.assertTrue
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
class PakstrBridgeRuntimeApiBaseTest {
|
|
9
|
+
@Test
|
|
10
|
+
fun setterPersistsValidatedOverrideAndGetterReturnsEffectiveValue() {
|
|
11
|
+
val preferences = FakePreferences()
|
|
12
|
+
val bridge = PakstrBridge(
|
|
13
|
+
RuntimeApiBaseSettings.createForTesting(preferences) {
|
|
14
|
+
"https://default.example.com"
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
assertTrue(bridge.setApiBaseUrl("https://override.example.com/"))
|
|
19
|
+
assertEquals("https://override.example.com", bridge.getApiBaseUrl())
|
|
20
|
+
assertFalse(bridge.setApiBaseUrl("http://unsafe.example.com"))
|
|
21
|
+
assertEquals("https://override.example.com", bridge.getApiBaseUrl())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test
|
|
25
|
+
fun setterReportsPersistenceFailure() {
|
|
26
|
+
val bridge = PakstrBridge(
|
|
27
|
+
RuntimeApiBaseSettings.createForTesting(FakePreferences(commitResult = false)) {
|
|
28
|
+
null
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
assertFalse(bridge.setApiBaseUrl("https://api.example.com"))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private class FakePreferences(
|
|
36
|
+
private val commitResult: Boolean = true
|
|
37
|
+
) : RuntimeApiBaseSettings.Preferences {
|
|
38
|
+
private val values = mutableMapOf<String, String>()
|
|
39
|
+
|
|
40
|
+
override fun getString(key: String): String? = values[key]
|
|
41
|
+
|
|
42
|
+
override fun putString(key: String, value: String): Boolean {
|
|
43
|
+
if (commitResult) values[key] = value
|
|
44
|
+
return commitResult
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override fun remove(key: String): Boolean {
|
|
48
|
+
if (commitResult) values.remove(key)
|
|
49
|
+
return commitResult
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertTrue
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class RuntimeApiBaseProxyTest {
|
|
8
|
+
@Test
|
|
9
|
+
fun proxyUsesUpdatedEffectiveValueOnSubsequentRequest() {
|
|
10
|
+
val preferences = FakePreferences()
|
|
11
|
+
val settings = RuntimeApiBaseSettings.createForTesting(preferences) {
|
|
12
|
+
"https://default.example.com"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
val firstRequestApiBase = settings.current().url!!
|
|
16
|
+
assertEquals(
|
|
17
|
+
"https://default.example.com/api/items",
|
|
18
|
+
proxyApiUrl(firstRequestApiBase, "/api/items").toString()
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
assertTrue(settings.setOverride("https://updated.example.com/base/"))
|
|
22
|
+
|
|
23
|
+
val secondRequestApiBase = settings.current().url!!
|
|
24
|
+
assertEquals(
|
|
25
|
+
"https://updated.example.com/base/api/items",
|
|
26
|
+
proxyApiUrl(secondRequestApiBase, "/api/items").toString()
|
|
27
|
+
)
|
|
28
|
+
assertEquals(
|
|
29
|
+
"https://default.example.com/api/items",
|
|
30
|
+
proxyApiUrl(firstRequestApiBase, "/api/items").toString()
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private class FakePreferences : RuntimeApiBaseSettings.Preferences {
|
|
35
|
+
private val values = mutableMapOf<String, String>()
|
|
36
|
+
|
|
37
|
+
override fun getString(key: String): String? = values[key]
|
|
38
|
+
|
|
39
|
+
override fun putString(key: String, value: String): Boolean {
|
|
40
|
+
values[key] = value
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override fun remove(key: String): Boolean {
|
|
45
|
+
values.remove(key)
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertFalse
|
|
5
|
+
import org.junit.Assert.assertNull
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
class RuntimeApiBaseSettingsTest {
|
|
10
|
+
@Test
|
|
11
|
+
fun persistedOverrideTakesPrecedenceOverPackagedDefault() {
|
|
12
|
+
val preferences = FakePreferences()
|
|
13
|
+
val settings = settings(preferences, "https://default.example.com")
|
|
14
|
+
|
|
15
|
+
assertTrue(settings.setOverride("https://override.example.com/"))
|
|
16
|
+
assertEquals(
|
|
17
|
+
"https://override.example.com",
|
|
18
|
+
preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY]
|
|
19
|
+
)
|
|
20
|
+
assertEquals(
|
|
21
|
+
RuntimeApiBaseSettings.Value(
|
|
22
|
+
"https://override.example.com",
|
|
23
|
+
RuntimeApiBaseSettings.Source.RUNTIME_OVERRIDE
|
|
24
|
+
),
|
|
25
|
+
settings.current()
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun resetRemovesOverrideAndRestoresPackagedDefault() {
|
|
31
|
+
val preferences = FakePreferences()
|
|
32
|
+
val settings = settings(preferences, "https://default.example.com/")
|
|
33
|
+
settings.setOverride("https://override.example.com")
|
|
34
|
+
|
|
35
|
+
assertTrue(settings.reset())
|
|
36
|
+
assertFalse(preferences.values.containsKey(RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY))
|
|
37
|
+
assertEquals(
|
|
38
|
+
RuntimeApiBaseSettings.Value(
|
|
39
|
+
"https://default.example.com",
|
|
40
|
+
RuntimeApiBaseSettings.Source.PACKAGED_DEFAULT
|
|
41
|
+
),
|
|
42
|
+
settings.current()
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
fun noConfiguredDefaultDisablesApiProxy() {
|
|
48
|
+
val settings = settings(FakePreferences(), null)
|
|
49
|
+
|
|
50
|
+
assertEquals(
|
|
51
|
+
RuntimeApiBaseSettings.Value(
|
|
52
|
+
null,
|
|
53
|
+
RuntimeApiBaseSettings.Source.DISABLED
|
|
54
|
+
),
|
|
55
|
+
settings.current()
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Test
|
|
60
|
+
fun invalidPersistedValueIsIgnored() {
|
|
61
|
+
val preferences = FakePreferences(
|
|
62
|
+
mutableMapOf(
|
|
63
|
+
RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY to "http://unsafe.example.com"
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assertEquals(
|
|
68
|
+
"https://default.example.com",
|
|
69
|
+
settings(preferences, "https://default.example.com").current().url
|
|
70
|
+
)
|
|
71
|
+
assertNull(settings(preferences, null).current().url)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Test
|
|
75
|
+
fun invalidOverrideDoesNotPersist() {
|
|
76
|
+
val preferences = FakePreferences()
|
|
77
|
+
val settings = settings(preferences, "https://default.example.com")
|
|
78
|
+
|
|
79
|
+
assertFalse(settings.setOverride("https://user@example.com"))
|
|
80
|
+
assertTrue(preferences.values.isEmpty())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun settings(
|
|
84
|
+
preferences: FakePreferences,
|
|
85
|
+
packagedDefault: String?
|
|
86
|
+
): RuntimeApiBaseSettings = RuntimeApiBaseSettings.createForTesting(
|
|
87
|
+
preferences
|
|
88
|
+
) {
|
|
89
|
+
packagedDefault
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private class FakePreferences(
|
|
93
|
+
val values: MutableMap<String, String> = mutableMapOf()
|
|
94
|
+
) : RuntimeApiBaseSettings.Preferences {
|
|
95
|
+
override fun getString(key: String): String? = values[key]
|
|
96
|
+
|
|
97
|
+
override fun putString(key: String, value: String): Boolean {
|
|
98
|
+
values[key] = value
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override fun remove(key: String): Boolean {
|
|
103
|
+
values.remove(key)
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertNull
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class RuntimeApiBaseValidatorTest {
|
|
8
|
+
@Test
|
|
9
|
+
fun acceptsAbsoluteHttpsUrlWithHostname() {
|
|
10
|
+
assertEquals(
|
|
11
|
+
"https://api.example.com/v1",
|
|
12
|
+
RuntimeApiBaseValidator.validateAndCanonicalize(
|
|
13
|
+
"https://api.example.com/v1"
|
|
14
|
+
)
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Test
|
|
19
|
+
fun normalizesTrailingSlashesAndHostCase() {
|
|
20
|
+
assertEquals(
|
|
21
|
+
"https://api.example.com/v1",
|
|
22
|
+
RuntimeApiBaseValidator.validateAndCanonicalize(
|
|
23
|
+
"HTTPS://API.Example.COM/v1///"
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
assertEquals(
|
|
27
|
+
"https://api.example.com",
|
|
28
|
+
RuntimeApiBaseValidator.validateAndCanonicalize(
|
|
29
|
+
"https://api.example.com/"
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test
|
|
35
|
+
fun rejectsInvalidOrUnsafeUrls() {
|
|
36
|
+
listOf(
|
|
37
|
+
"",
|
|
38
|
+
" https://api.example.com",
|
|
39
|
+
"http://api.example.com",
|
|
40
|
+
"api.example.com",
|
|
41
|
+
"https:///v1",
|
|
42
|
+
"https://user:password@api.example.com",
|
|
43
|
+
"https://api.example.com?v=1",
|
|
44
|
+
"https://api.example.com#fragment",
|
|
45
|
+
"https://api.example.com/${"a".repeat(RuntimeApiBaseValidator.MAX_LENGTH)}"
|
|
46
|
+
).forEach { value ->
|
|
47
|
+
assertNull(value, RuntimeApiBaseValidator.validateAndCanonicalize(value))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -15,6 +15,18 @@ const pakstrConfig_1 = require("../core/pakstrConfig");
|
|
|
15
15
|
const nsite_1 = require("../core/nsite");
|
|
16
16
|
const zapStore_1 = require("../core/zapStore");
|
|
17
17
|
const zapstoreConfig_1 = require("../core/zapstoreConfig");
|
|
18
|
+
function imageContentType(filePath) {
|
|
19
|
+
switch (path_1.default.extname(filePath).toLowerCase()) {
|
|
20
|
+
case ".png": return "image/png";
|
|
21
|
+
case ".jpg":
|
|
22
|
+
case ".jpeg": return "image/jpeg";
|
|
23
|
+
case ".gif": return "image/gif";
|
|
24
|
+
case ".webp": return "image/webp";
|
|
25
|
+
case ".svg": return "image/svg+xml";
|
|
26
|
+
case ".ico": return "image/x-icon";
|
|
27
|
+
default: return "application/octet-stream";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
18
30
|
/** `pakstr publish` — optionally upload the signed APK and publish its Zapstore events. */
|
|
19
31
|
async function publishCommand(configPath, options = {}) {
|
|
20
32
|
if (options.blossomFetch && options.fetchImpl) {
|
|
@@ -108,6 +120,15 @@ async function publishCommand(configPath, options = {}) {
|
|
|
108
120
|
console.log("Publishing as:", publishNsec.envVar);
|
|
109
121
|
console.log("Publisher npub:", npub);
|
|
110
122
|
console.log("Relay:", config.publish.relay);
|
|
123
|
+
const iconUrl = zapstoreConfig?.icon
|
|
124
|
+
? (await (0, blossom_1.uploadToBlossom)({
|
|
125
|
+
serverUrl: config.publish.blossom,
|
|
126
|
+
filePath: zapstoreConfig.icon,
|
|
127
|
+
secret: publishNsec.bytes,
|
|
128
|
+
contentType: imageContentType(zapstoreConfig.icon),
|
|
129
|
+
fetchImpl,
|
|
130
|
+
})).url
|
|
131
|
+
: undefined;
|
|
111
132
|
app = {
|
|
112
133
|
appId: config.app.appId,
|
|
113
134
|
appName: zapstoreConfig?.name ?? config.app.appName,
|
|
@@ -118,6 +139,7 @@ async function publishCommand(configPath, options = {}) {
|
|
|
118
139
|
tags: zapstoreConfig?.tags,
|
|
119
140
|
license: zapstoreConfig?.license,
|
|
120
141
|
website: zapstoreConfig?.website,
|
|
142
|
+
icon: iconUrl,
|
|
121
143
|
releaseNotes: zapstoreConfig?.releaseNotes,
|
|
122
144
|
repository: zapstoreConfig?.repository ?? (0, zapstoreConfig_1.loadZapstoreRepository)(config.configDir),
|
|
123
145
|
};
|
package/dist/core/zapStore.js
CHANGED
|
@@ -77,6 +77,7 @@ async function buildAppMetadataEvent(input) {
|
|
|
77
77
|
const npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
|
|
78
78
|
const communities = input.communities?.length ? input.communities : [exports.DEFAULT_COMMUNITY];
|
|
79
79
|
const summaryTags = input.app.summary !== undefined ? [["summary", input.app.summary]] : [];
|
|
80
|
+
const iconTags = input.app.icon !== undefined ? [["icon", input.app.icon]] : [];
|
|
80
81
|
const topicTags = (input.app.tags ?? []).map(tag => ["t", tag]);
|
|
81
82
|
const licenseTags = input.app.license !== undefined ? [["license", input.app.license]] : [];
|
|
82
83
|
const websiteTags = input.app.website !== undefined ? [["url", input.app.website]] : [];
|
|
@@ -88,6 +89,7 @@ async function buildAppMetadataEvent(input) {
|
|
|
88
89
|
["d", input.app.appId],
|
|
89
90
|
["name", input.app.appName],
|
|
90
91
|
...summaryTags,
|
|
92
|
+
...iconTags,
|
|
91
93
|
["f", PLATFORM],
|
|
92
94
|
...communities.map(c => ["h", c]),
|
|
93
95
|
...topicTags,
|
|
@@ -58,6 +58,10 @@ function loadZapstorePublisherConfig(configDir) {
|
|
|
58
58
|
const tags = optionalTags(config.tags, configPath);
|
|
59
59
|
const license = optionalString(config.license, "license", configPath);
|
|
60
60
|
const website = optionalString(config.website, "website", configPath);
|
|
61
|
+
const iconPath = optionalString(config.icon, "icon", configPath);
|
|
62
|
+
const icon = iconPath === undefined
|
|
63
|
+
? undefined
|
|
64
|
+
: resolveIconPath(iconPath, configDir, configPath);
|
|
61
65
|
const releaseNotesPath = optionalString(config.release_notes, "release_notes", configPath);
|
|
62
66
|
const releaseNotes = releaseNotesPath === undefined
|
|
63
67
|
? undefined
|
|
@@ -79,6 +83,7 @@ function loadZapstorePublisherConfig(configDir) {
|
|
|
79
83
|
tags,
|
|
80
84
|
license,
|
|
81
85
|
website,
|
|
86
|
+
icon,
|
|
82
87
|
releaseNotes,
|
|
83
88
|
};
|
|
84
89
|
}
|
|
@@ -94,7 +99,7 @@ function optionalString(value, field, configPath, trim = true) {
|
|
|
94
99
|
if (typeof value !== "string") {
|
|
95
100
|
throw new ZapstoreConfigError(`${exports.ZAPSTORE_CONFIG_FILENAME} field ${field} must be a string`, configPath);
|
|
96
101
|
}
|
|
97
|
-
if (value.trim().length === 0 && field === "release_notes") {
|
|
102
|
+
if (value.trim().length === 0 && (field === "release_notes" || field === "icon")) {
|
|
98
103
|
throw new ZapstoreConfigError(`${exports.ZAPSTORE_CONFIG_FILENAME} field ${field} must not be empty`, configPath);
|
|
99
104
|
}
|
|
100
105
|
return trim ? value.trim() : value;
|
|
@@ -112,6 +117,19 @@ function optionalTags(value, configPath) {
|
|
|
112
117
|
return tag.trim();
|
|
113
118
|
});
|
|
114
119
|
}
|
|
120
|
+
function resolveIconPath(iconPath, configDir, configPath) {
|
|
121
|
+
const resolved = path_1.default.resolve(configDir, iconPath);
|
|
122
|
+
if (!fs_1.default.existsSync(resolved) || !fs_1.default.lstatSync(resolved).isFile()) {
|
|
123
|
+
throw new ZapstoreConfigError(`${exports.ZAPSTORE_CONFIG_FILENAME} icon file not found: ${resolved}`, configPath);
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
fs_1.default.accessSync(resolved, fs_1.default.constants.R_OK);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
throw new ZapstoreConfigError(`Could not read ${exports.ZAPSTORE_CONFIG_FILENAME} icon file ${resolved}: ${error instanceof Error ? error.message : String(error)}`, configPath);
|
|
130
|
+
}
|
|
131
|
+
return resolved;
|
|
132
|
+
}
|
|
115
133
|
function readReleaseNotes(releaseNotesPath, configDir, configPath) {
|
|
116
134
|
const resolved = path_1.default.resolve(configDir, releaseNotesPath);
|
|
117
135
|
if (!fs_1.default.existsSync(resolved) || !fs_1.default.lstatSync(resolved).isFile()) {
|