@lucidaquarian/app-version-checker 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.
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/examples/capacitor-usage.js +149 -0
- package/examples/cordova-usage.js +75 -0
- package/package.json +48 -0
- package/src/index.d.ts +160 -0
- package/src/index.js +265 -0
- package/src/providers/capacitor.js +143 -0
- package/src/providers/cordova.js +143 -0
- package/src/semver.js +198 -0
- package/src/stores.js +201 -0
package/src/semver.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic version comparison utilities.
|
|
3
|
+
* Handles parsing and comparing version strings (e.g. "1.2.3", "2.0.0-beta.1").
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class SemVer {
|
|
7
|
+
/**
|
|
8
|
+
* Parse a version string into its components.
|
|
9
|
+
* @param {string} version - Version string (e.g., "1.2.3", "1.0.0-beta.1")
|
|
10
|
+
* @returns {{ major: number, minor: number, patch: number, prerelease: string }}
|
|
11
|
+
*/
|
|
12
|
+
static parse(version) {
|
|
13
|
+
if (!version || typeof version !== 'string') {
|
|
14
|
+
throw new Error(`Invalid version string: "${version}"`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cleaned = version.trim().replace(/^v/i, '');
|
|
18
|
+
|
|
19
|
+
// Build metadata (after '+') is ignored for precedence; drop it first.
|
|
20
|
+
const withoutBuild = cleaned.split('+', 1)[0];
|
|
21
|
+
|
|
22
|
+
// Separate the version core from the prerelease at the FIRST hyphen only,
|
|
23
|
+
// so hyphenated prerelease identifiers (e.g. "beta-2") stay intact.
|
|
24
|
+
const hyphenIndex = withoutBuild.indexOf('-');
|
|
25
|
+
const versionPart = hyphenIndex === -1 ? withoutBuild : withoutBuild.slice(0, hyphenIndex);
|
|
26
|
+
const prerelease = hyphenIndex === -1 ? '' : withoutBuild.slice(hyphenIndex + 1);
|
|
27
|
+
|
|
28
|
+
const parts = versionPart.split('.');
|
|
29
|
+
|
|
30
|
+
// Major must be present and numeric; minor/patch default to 0 when omitted.
|
|
31
|
+
// Non-numeric components are a hard error (previously they were silently
|
|
32
|
+
// coerced to 0, which let garbage like "abc" parse as 0.0.0).
|
|
33
|
+
const toComponent = (raw, required) => {
|
|
34
|
+
if (raw === undefined || raw === '') {
|
|
35
|
+
if (required) throw new Error(`Unable to parse version: "${version}"`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
if (!/^\d+$/.test(raw)) {
|
|
39
|
+
throw new Error(`Unable to parse version: "${version}"`);
|
|
40
|
+
}
|
|
41
|
+
return parseInt(raw, 10);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const major = toComponent(parts[0], true);
|
|
45
|
+
const minor = toComponent(parts[1], false);
|
|
46
|
+
const patch = toComponent(parts[2], false);
|
|
47
|
+
|
|
48
|
+
return { major, minor, patch, prerelease };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compare two prerelease strings per the SemVer precedence rules:
|
|
53
|
+
* dot-separated identifiers compared left to right, numeric identifiers
|
|
54
|
+
* compared numerically, numeric ranking lower than alphanumeric, and a
|
|
55
|
+
* larger set of identifiers ranking higher when all preceding are equal.
|
|
56
|
+
* @param {string} a - Non-empty prerelease string
|
|
57
|
+
* @param {string} b - Non-empty prerelease string
|
|
58
|
+
* @returns {number} -1, 0, or 1
|
|
59
|
+
* @private
|
|
60
|
+
*/
|
|
61
|
+
static _comparePrerelease(a, b) {
|
|
62
|
+
const aIds = a.split('.');
|
|
63
|
+
const bIds = b.split('.');
|
|
64
|
+
const len = Math.max(aIds.length, bIds.length);
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < len; i++) {
|
|
67
|
+
const aId = aIds[i];
|
|
68
|
+
const bId = bIds[i];
|
|
69
|
+
|
|
70
|
+
// A larger set of identifiers has higher precedence.
|
|
71
|
+
if (aId === undefined) return -1;
|
|
72
|
+
if (bId === undefined) return 1;
|
|
73
|
+
|
|
74
|
+
const aNum = /^\d+$/.test(aId);
|
|
75
|
+
const bNum = /^\d+$/.test(bId);
|
|
76
|
+
|
|
77
|
+
if (aNum && bNum) {
|
|
78
|
+
const diff = parseInt(aId, 10) - parseInt(bId, 10);
|
|
79
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
80
|
+
} else if (aNum !== bNum) {
|
|
81
|
+
// Numeric identifiers have lower precedence than alphanumeric ones.
|
|
82
|
+
return aNum ? -1 : 1;
|
|
83
|
+
} else if (aId !== bId) {
|
|
84
|
+
return aId < bId ? -1 : 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compare two version strings.
|
|
93
|
+
* @param {string} versionA
|
|
94
|
+
* @param {string} versionB
|
|
95
|
+
* @returns {number} -1 if A < B, 0 if A == B, 1 if A > B
|
|
96
|
+
*/
|
|
97
|
+
static compare(versionA, versionB) {
|
|
98
|
+
const a = SemVer.parse(versionA);
|
|
99
|
+
const b = SemVer.parse(versionB);
|
|
100
|
+
|
|
101
|
+
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
|
|
102
|
+
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
|
|
103
|
+
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
|
|
104
|
+
|
|
105
|
+
// Pre-release versions have lower precedence than the release version
|
|
106
|
+
if (a.prerelease && !b.prerelease) return -1;
|
|
107
|
+
if (!a.prerelease && b.prerelease) return 1;
|
|
108
|
+
if (a.prerelease && b.prerelease) {
|
|
109
|
+
return SemVer._comparePrerelease(a.prerelease, b.prerelease);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if versionA is greater than versionB.
|
|
117
|
+
* @param {string} versionA
|
|
118
|
+
* @param {string} versionB
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
static gt(versionA, versionB) {
|
|
122
|
+
return SemVer.compare(versionA, versionB) === 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if versionA is less than versionB.
|
|
127
|
+
* @param {string} versionA
|
|
128
|
+
* @param {string} versionB
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
static lt(versionA, versionB) {
|
|
132
|
+
return SemVer.compare(versionA, versionB) === -1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if versionA equals versionB.
|
|
137
|
+
* @param {string} versionA
|
|
138
|
+
* @param {string} versionB
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
static eq(versionA, versionB) {
|
|
142
|
+
return SemVer.compare(versionA, versionB) === 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if versionA >= versionB.
|
|
147
|
+
* @param {string} versionA
|
|
148
|
+
* @param {string} versionB
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
static gte(versionA, versionB) {
|
|
152
|
+
return SemVer.compare(versionA, versionB) >= 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if versionA <= versionB.
|
|
157
|
+
* @param {string} versionA
|
|
158
|
+
* @param {string} versionB
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
static lte(versionA, versionB) {
|
|
162
|
+
return SemVer.compare(versionA, versionB) <= 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Determine the type of update between two versions.
|
|
167
|
+
* @param {string} currentVersion
|
|
168
|
+
* @param {string} latestVersion
|
|
169
|
+
* @returns {'major' | 'minor' | 'patch' | 'prerelease' | 'none'}
|
|
170
|
+
*/
|
|
171
|
+
static updateType(currentVersion, latestVersion) {
|
|
172
|
+
if (SemVer.gte(currentVersion, latestVersion)) return 'none';
|
|
173
|
+
|
|
174
|
+
const current = SemVer.parse(currentVersion);
|
|
175
|
+
const latest = SemVer.parse(latestVersion);
|
|
176
|
+
|
|
177
|
+
if (latest.major > current.major) return 'major';
|
|
178
|
+
if (latest.minor > current.minor) return 'minor';
|
|
179
|
+
if (latest.patch > current.patch) return 'patch';
|
|
180
|
+
return 'prerelease';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate whether a string is a valid semver.
|
|
185
|
+
* @param {string} version
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
static isValid(version) {
|
|
189
|
+
try {
|
|
190
|
+
SemVer.parse(version);
|
|
191
|
+
return true;
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = SemVer;
|
package/src/stores.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store lookup strategies.
|
|
3
|
+
* Fetch the latest published version from the App Store, Google Play, or a custom endpoint.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Default network timeout (ms) applied to every store request. */
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `fetch` wrapper that aborts the request after `timeoutMs`, so a hung
|
|
11
|
+
* connection can never make a version check hang indefinitely. Any caller
|
|
12
|
+
* supplied `options.signal` is still honored (aborting it aborts the request).
|
|
13
|
+
*
|
|
14
|
+
* @param {string} url
|
|
15
|
+
* @param {object} [options] - Standard fetch options; may include a `signal`.
|
|
16
|
+
* @param {number} [timeoutMs] - Timeout in ms. 0 or negative disables it.
|
|
17
|
+
* @returns {Promise<Response>}
|
|
18
|
+
*/
|
|
19
|
+
async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
22
|
+
|
|
23
|
+
// Chain a caller-provided signal to our controller so either can abort.
|
|
24
|
+
const callerSignal = options.signal;
|
|
25
|
+
if (callerSignal) {
|
|
26
|
+
if (callerSignal.aborted) controller.abort();
|
|
27
|
+
else callerSignal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
// Distinguish our timeout from a caller-initiated abort or a real error.
|
|
34
|
+
if (err && err.name === 'AbortError' && !(callerSignal && callerSignal.aborted)) {
|
|
35
|
+
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
} finally {
|
|
39
|
+
if (timer) clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch the latest version from Apple App Store using the iTunes Lookup API.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} bundleId - The iOS bundle identifier (e.g. "com.example.app")
|
|
47
|
+
* @param {string} [country='us'] - Two-letter ISO country code
|
|
48
|
+
* @param {number} [timeoutMs] - Request timeout in ms
|
|
49
|
+
* @returns {Promise<{ version: string, releaseNotes: string, storeUrl: string, releaseDate: string, minimumOsVersion: string }>}
|
|
50
|
+
*/
|
|
51
|
+
async function fetchAppStoreVersion(bundleId, country = 'us', timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
52
|
+
if (!bundleId) throw new Error('bundleId is required for App Store lookup');
|
|
53
|
+
|
|
54
|
+
const url = `https://itunes.apple.com/lookup?bundleId=${encodeURIComponent(bundleId)}&country=${encodeURIComponent(country)}`;
|
|
55
|
+
|
|
56
|
+
const response = await fetchWithTimeout(url, {}, timeoutMs);
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`App Store lookup failed: HTTP ${response.status}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const data = await response.json();
|
|
62
|
+
|
|
63
|
+
if (!data.results || data.results.length === 0) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`App not found on the App Store with bundleId "${bundleId}" in country "${country}".`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = data.results[0];
|
|
70
|
+
|
|
71
|
+
if (!result.version) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`App Store result for bundleId "${bundleId}" did not include a version.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
version: result.version,
|
|
79
|
+
releaseNotes: result.releaseNotes || '',
|
|
80
|
+
storeUrl: result.trackViewUrl || '',
|
|
81
|
+
releaseDate: result.currentVersionReleaseDate || '',
|
|
82
|
+
minimumOsVersion: result.minimumOsVersion || '',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fetch the latest version from Google Play Store.
|
|
88
|
+
* NOTE: Google does not provide an official public API for this.
|
|
89
|
+
* This uses a lightweight HTML-scraping approach as a fallback.
|
|
90
|
+
* For production use, prefer a custom server endpoint or the Google Play Developer API.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} packageName - The Android package name (e.g. "com.example.app")
|
|
93
|
+
* @param {number} [timeoutMs] - Request timeout in ms
|
|
94
|
+
* @returns {Promise<{ version: string, storeUrl: string }>}
|
|
95
|
+
*/
|
|
96
|
+
async function fetchPlayStoreVersion(packageName, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
97
|
+
if (!packageName) throw new Error('packageName is required for Play Store lookup');
|
|
98
|
+
|
|
99
|
+
const storeUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageName)}&hl=en`;
|
|
100
|
+
|
|
101
|
+
const response = await fetchWithTimeout(storeUrl, {}, timeoutMs);
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
throw new Error(`Play Store lookup failed: HTTP ${response.status}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const html = await response.text();
|
|
107
|
+
|
|
108
|
+
// Attempt to extract the version from the page content. This is fragile and
|
|
109
|
+
// may break if Google changes their markup, so several known shapes are
|
|
110
|
+
// tried before giving up. Prefer a custom endpoint for production use.
|
|
111
|
+
const patterns = [
|
|
112
|
+
/\[\[\["(\d+(?:\.\d+)+)"\]\]/, // primary: [[["1.2.3"]]]
|
|
113
|
+
/"version"\s*:\s*"(\d+(?:\.\d+)+)"/i, // JSON-ish "version":"1.2.3"
|
|
114
|
+
/Current Version[\s\S]{0,80}?(\d+(?:\.\d+)+)/i, // legacy label markup
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
let version = null;
|
|
118
|
+
for (const pattern of patterns) {
|
|
119
|
+
const match = html.match(pattern);
|
|
120
|
+
if (match) {
|
|
121
|
+
version = match[1];
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!version) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'Could not extract version from Play Store page. ' +
|
|
129
|
+
'Consider using a custom endpoint instead.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
version,
|
|
135
|
+
storeUrl,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch the latest version from a custom remote endpoint.
|
|
141
|
+
* The endpoint should return JSON with at least a `version` field.
|
|
142
|
+
*
|
|
143
|
+
* Expected response format:
|
|
144
|
+
* {
|
|
145
|
+
* "version": "2.1.0",
|
|
146
|
+
* "minVersion": "1.5.0", // optional: minimum supported version
|
|
147
|
+
* "releaseNotes": "Bug fixes", // optional
|
|
148
|
+
* "storeUrl": "https://...", // optional: link to download/update
|
|
149
|
+
* "forceUpdate": false // optional: whether update is mandatory
|
|
150
|
+
* }
|
|
151
|
+
*
|
|
152
|
+
* @param {string} url - The endpoint URL
|
|
153
|
+
* @param {object} [options] - Fetch options (headers, etc.)
|
|
154
|
+
* @param {number} [timeoutMs] - Request timeout in ms
|
|
155
|
+
* @returns {Promise<object>}
|
|
156
|
+
*/
|
|
157
|
+
async function fetchCustomEndpoint(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
158
|
+
if (!url) throw new Error('URL is required for custom endpoint lookup');
|
|
159
|
+
|
|
160
|
+
const response = await fetchWithTimeout(
|
|
161
|
+
url,
|
|
162
|
+
{
|
|
163
|
+
method: 'GET',
|
|
164
|
+
...options,
|
|
165
|
+
headers: {
|
|
166
|
+
'Accept': 'application/json',
|
|
167
|
+
...(options.headers || {}),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
timeoutMs
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(`Custom endpoint lookup failed: HTTP ${response.status}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const data = await response.json();
|
|
178
|
+
|
|
179
|
+
if (!data.version) {
|
|
180
|
+
throw new Error('Custom endpoint response must include a "version" field.');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Spread the raw payload first so pass-through fields are preserved, then
|
|
184
|
+
// apply the normalized fields last so their defaults are not clobbered by
|
|
185
|
+
// falsy/absent values from the endpoint.
|
|
186
|
+
return {
|
|
187
|
+
...data,
|
|
188
|
+
version: data.version,
|
|
189
|
+
minVersion: data.minVersion || null,
|
|
190
|
+
releaseNotes: data.releaseNotes || '',
|
|
191
|
+
storeUrl: data.storeUrl || '',
|
|
192
|
+
forceUpdate: data.forceUpdate || false,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
fetchAppStoreVersion,
|
|
198
|
+
fetchPlayStoreVersion,
|
|
199
|
+
fetchCustomEndpoint,
|
|
200
|
+
DEFAULT_TIMEOUT_MS,
|
|
201
|
+
};
|