@forthtilliath/expo-release-updates 0.4.7 → 0.5.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 +16 -7
- package/dist/downloadAndInstallApk.d.ts +4 -3
- package/dist/downloadAndInstallApk.d.ts.map +1 -1
- package/dist/downloadAndInstallApk.js +6 -1
- package/dist/githubReleases.d.ts +15 -6
- package/dist/githubReleases.d.ts.map +1 -1
- package/dist/githubReleases.js +53 -14
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -76,9 +76,13 @@ if (release && isUpdateAvailable(currentVersion, release.version)) {
|
|
|
76
76
|
}
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
### `fetchLatestRelease({ owner, repo, token? })`
|
|
79
|
+
### `fetchLatestRelease({ owner, repo, token?, timeoutMs?, signal? })`
|
|
80
80
|
|
|
81
|
-
Fetches the latest GitHub release and its `.apk` asset. Returns `null` if the latest release has no `.apk` attached; throws if the GitHub API request fails. Pass `token`
|
|
81
|
+
Fetches the latest GitHub release and its `.apk` asset. Returns `null` if the latest release has no `.apk` attached; throws if the GitHub API request fails. Pass `token` for private repos, or to raise the API rate limit from 60 to 5000 requests/hour on public ones.
|
|
82
|
+
|
|
83
|
+
The request gives up after `timeoutMs` (default `15000`) instead of hanging on a bad network, and can be cancelled with `signal` (an `AbortSignal`) — both also apply to `fetchReleaseHistory`.
|
|
84
|
+
|
|
85
|
+
> **Security — the token ships inside your app.** Anything bundled in an APK can be extracted from it, so treat the token as public: use a [fine-grained token](https://github.com/settings/personal-access-tokens) with **read-only "Contents" access to this one repository** and nothing else, never a classic token or one with write scopes. For a public repo, prefer no token at all.
|
|
82
86
|
|
|
83
87
|
```ts
|
|
84
88
|
const release = await fetchLatestRelease({ owner: "acme", repo: "app" });
|
|
@@ -87,9 +91,9 @@ if (release && isUpdateAvailable(currentVersion, release.version)) {
|
|
|
87
91
|
}
|
|
88
92
|
```
|
|
89
93
|
|
|
90
|
-
### `fetchReleaseHistory({ owner, repo, limit?, token? })`
|
|
94
|
+
### `fetchReleaseHistory({ owner, repo, limit?, token?, timeoutMs?, signal? })`
|
|
91
95
|
|
|
92
|
-
Fetches the most recent releases (version, notes, publish date), most recent first. `limit` defaults to `10` — useful for a "release history" / "what's new" screen.
|
|
96
|
+
Fetches the most recent published releases (version, notes, publish date), most recent first — draft releases, visible with a token, are left out. `limit` defaults to `10` (clamped to GitHub's 1–100) — useful for a "release history" / "what's new" screen.
|
|
93
97
|
|
|
94
98
|
```ts
|
|
95
99
|
const history = await fetchReleaseHistory({
|
|
@@ -102,20 +106,25 @@ const history = await fetchReleaseHistory({
|
|
|
102
106
|
|
|
103
107
|
### `downloadAndInstallApk({ apkUrl, fileName, onProgress?, expectedMd5? })`
|
|
104
108
|
|
|
105
|
-
Downloads an APK to the app's cache directory and triggers the Android install-package intent. **Android only** — there is no iOS equivalent (sideloading isn't possible there). Pass `expectedMd5` to verify the downloaded file
|
|
109
|
+
Downloads an APK to the app's cache directory and triggers the Android install-package intent. **Android only** — there is no iOS equivalent (sideloading isn't possible there). Pass `expectedMd5` to verify the downloaded file before installing — a mismatch deletes the file and throws instead. Publish the checksum alongside the release (e.g. in the release notes or a `.md5` asset) and read it yourself: `fetchLatestRelease` doesn't return it.
|
|
110
|
+
|
|
111
|
+
What `expectedMd5` does and doesn't protect against:
|
|
112
|
+
|
|
113
|
+
- ✅ a corrupted or truncated download;
|
|
114
|
+
- ❌ a malicious release — whoever can publish the APK can publish its checksum too. The real safeguard there is Android itself: it refuses to install an update that isn't signed with the same key as the installed app. **Keep that signing key out of the repo and CI logs**, and restrict who can publish releases.
|
|
106
115
|
|
|
107
116
|
```ts
|
|
108
117
|
await downloadAndInstallApk({
|
|
109
118
|
apkUrl: release.apkUrl,
|
|
110
119
|
fileName: "myapp-update.apk",
|
|
111
120
|
onProgress: (fraction) => setProgress(fraction),
|
|
112
|
-
expectedMd5
|
|
121
|
+
expectedMd5, // read from the release notes or a .md5 asset
|
|
113
122
|
});
|
|
114
123
|
```
|
|
115
124
|
|
|
116
125
|
### `parseChangelogNotes(notes)`
|
|
117
126
|
|
|
118
|
-
Parses a small subset of Markdown (`### heading`, `- item`, `**bold**`) commonly found in GitHub release notes into a list of typed blocks (`heading` / `item` / `text`, each with `bold`-aware segments), ready for a UI layer to render without a full Markdown dependency. Pair it with `@forthtilliath/
|
|
127
|
+
Parses a small subset of Markdown (`### heading`, `- item`, `**bold**`) commonly found in GitHub release notes into a list of typed blocks (`heading` / `item` / `text`, each with `bold`-aware segments), ready for a UI layer to render without a full Markdown dependency. Pair it with `@forthtilliath/expo-release-updates-ui`'s `ChangelogNotes` component to render the result directly.
|
|
119
128
|
|
|
120
129
|
```ts
|
|
121
130
|
parseChangelogNotes("### Added\n- **Auto-backup**: saves every 5 minutes.");
|
|
@@ -6,9 +6,10 @@ export interface DownloadAndInstallApkOptions {
|
|
|
6
6
|
/**
|
|
7
7
|
* MD5 checksum the downloaded file is expected to match (case-insensitive),
|
|
8
8
|
* e.g. one published alongside the release. When set, a mismatch deletes
|
|
9
|
-
* the file and rejects instead of installing it
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* the file and rejects instead of installing it. This catches a corrupted
|
|
10
|
+
* or truncated download — not a malicious release: whoever can publish the
|
|
11
|
+
* APK can publish its checksum too. Android's own check that an update is
|
|
12
|
+
* signed with the same key as the installed app is what guards against that.
|
|
12
13
|
*/
|
|
13
14
|
expectedMd5?: string;
|
|
14
15
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"downloadAndInstallApk.d.ts","sourceRoot":"","sources":["../src/downloadAndInstallApk.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"downloadAndInstallApk.d.ts","sourceRoot":"","sources":["../src/downloadAndInstallApk.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CAAC,EAC1C,MAAM,EACN,QAAQ,EACR,UAAU,EACV,WAAW,GACZ,EAAE,4BAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B9C"}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { File, Paths } from "expo-file-system";
|
|
2
2
|
import * as IntentLauncher from "expo-intent-launcher";
|
|
3
|
+
/**
|
|
4
|
+
* Android's `Intent.FLAG_GRANT_READ_URI_PERMISSION`: lets the package
|
|
5
|
+
* installer read the APK through its `content://` URI.
|
|
6
|
+
*/
|
|
7
|
+
const FLAG_GRANT_READ_URI_PERMISSION = 1;
|
|
3
8
|
/**
|
|
4
9
|
* Downloads an APK to the cache directory and triggers the Android
|
|
5
10
|
* install-package intent. Android only — there is no equivalent flow on iOS,
|
|
@@ -30,7 +35,7 @@ export async function downloadAndInstallApk({ apkUrl, fileName, onProgress, expe
|
|
|
30
35
|
}
|
|
31
36
|
await IntentLauncher.startActivityAsync("android.intent.action.VIEW", {
|
|
32
37
|
data: file.contentUri,
|
|
33
|
-
flags:
|
|
38
|
+
flags: FLAG_GRANT_READ_URI_PERMISSION,
|
|
34
39
|
type: "application/vnd.android.package-archive",
|
|
35
40
|
});
|
|
36
41
|
}
|
package/dist/githubReleases.d.ts
CHANGED
|
@@ -2,11 +2,19 @@ export interface GithubRepoRef {
|
|
|
2
2
|
owner: string;
|
|
3
3
|
repo: string;
|
|
4
4
|
/**
|
|
5
|
-
* A GitHub
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A GitHub token, sent as a `Bearer` token. Required for private repos;
|
|
6
|
+
* also raises the API rate limit from 60 to 5000 requests/hour for public
|
|
7
|
+
* ones. **Anything bundled in an app can be extracted from the APK**: only
|
|
8
|
+
* ever ship a fine-grained, read-only token scoped to this one repo.
|
|
8
9
|
*/
|
|
9
10
|
token?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Gives up (rejects) if GitHub hasn't answered within this delay, instead
|
|
13
|
+
* of leaving the check hanging on a bad network. Defaults to 15 s.
|
|
14
|
+
*/
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
/** Aborts the request (e.g. when the screen that asked for it unmounts). */
|
|
17
|
+
signal?: AbortSignal;
|
|
10
18
|
}
|
|
11
19
|
export interface LatestRelease {
|
|
12
20
|
version: string;
|
|
@@ -22,12 +30,13 @@ export interface ReleaseHistoryEntry {
|
|
|
22
30
|
* Fetches the latest GitHub release for a repo, along with its `.apk` asset.
|
|
23
31
|
*
|
|
24
32
|
* @returns `null` if the latest release has no `.apk` asset attached.
|
|
25
|
-
* @throws if the GitHub API request fails.
|
|
33
|
+
* @throws if the GitHub API request fails or times out.
|
|
26
34
|
*/
|
|
27
35
|
export declare function fetchLatestRelease(ref: GithubRepoRef): Promise<LatestRelease | null>;
|
|
28
36
|
/**
|
|
29
|
-
* Fetches the most recent releases for a repo (version, notes,
|
|
30
|
-
* date), most recent first — useful for a "release history" screen.
|
|
37
|
+
* Fetches the most recent published releases for a repo (version, notes,
|
|
38
|
+
* publish date), most recent first — useful for a "release history" screen.
|
|
39
|
+
* Draft releases (only visible with a token) are left out.
|
|
31
40
|
*/
|
|
32
41
|
export declare function fetchReleaseHistory(ref: GithubRepoRef & {
|
|
33
42
|
limit?: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"githubReleases.d.ts","sourceRoot":"","sources":["../src/githubReleases.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb
|
|
1
|
+
{"version":3,"file":"githubReleases.d.ts","sourceRoot":"","sources":["../src/githubReleases.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAqED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAe/B;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,aAAa,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtC,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAchC"}
|
package/dist/githubReleases.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
2
|
+
/** GitHub's own max for `per_page`. */
|
|
3
|
+
const MAX_PER_PAGE = 100;
|
|
1
4
|
function releasesUrl({ owner, repo }) {
|
|
2
5
|
return `https://api.github.com/repos/${owner}/${repo}/releases`;
|
|
3
6
|
}
|
|
@@ -7,19 +10,54 @@ function githubHeaders({ token }) {
|
|
|
7
10
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
8
11
|
};
|
|
9
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* GETs a GitHub API URL and parses its JSON body, rejecting on an error
|
|
15
|
+
* status or once `timeoutMs` has elapsed (body download included).
|
|
16
|
+
*/
|
|
17
|
+
async function fetchGithubJson(url, ref) {
|
|
18
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, signal } = ref;
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
// An object rather than `abort(reason)`: some React Native AbortController
|
|
21
|
+
// polyfills drop the reason.
|
|
22
|
+
const timeout = { expired: false };
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
timeout.expired = true;
|
|
25
|
+
controller.abort();
|
|
26
|
+
}, timeoutMs);
|
|
27
|
+
const abortFromCaller = () => {
|
|
28
|
+
controller.abort();
|
|
29
|
+
};
|
|
30
|
+
if (signal?.aborted)
|
|
31
|
+
controller.abort();
|
|
32
|
+
signal?.addEventListener("abort", abortFromCaller);
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(url, {
|
|
35
|
+
headers: githubHeaders(ref),
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
throw new Error(`GitHub responded with ${String(response.status)}`);
|
|
40
|
+
return (await response.json());
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (timeout.expired) {
|
|
44
|
+
throw new Error(`GitHub did not respond within ${String(timeoutMs)} ms`);
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
signal?.removeEventListener("abort", abortFromCaller);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
10
53
|
/**
|
|
11
54
|
* Fetches the latest GitHub release for a repo, along with its `.apk` asset.
|
|
12
55
|
*
|
|
13
56
|
* @returns `null` if the latest release has no `.apk` asset attached.
|
|
14
|
-
* @throws if the GitHub API request fails.
|
|
57
|
+
* @throws if the GitHub API request fails or times out.
|
|
15
58
|
*/
|
|
16
59
|
export async function fetchLatestRelease(ref) {
|
|
17
|
-
const
|
|
18
|
-
headers: githubHeaders(ref),
|
|
19
|
-
});
|
|
20
|
-
if (!response.ok)
|
|
21
|
-
throw new Error(`GitHub responded with ${String(response.status)}`);
|
|
22
|
-
const data = (await response.json());
|
|
60
|
+
const data = await fetchGithubJson(`${releasesUrl(ref)}/latest`, ref);
|
|
23
61
|
const apkAsset = (data.assets ?? []).find((asset) => asset.name.endsWith(".apk"));
|
|
24
62
|
if (!apkAsset)
|
|
25
63
|
return null;
|
|
@@ -30,16 +68,17 @@ export async function fetchLatestRelease(ref) {
|
|
|
30
68
|
};
|
|
31
69
|
}
|
|
32
70
|
/**
|
|
33
|
-
* Fetches the most recent releases for a repo (version, notes,
|
|
34
|
-
* date), most recent first — useful for a "release history" screen.
|
|
71
|
+
* Fetches the most recent published releases for a repo (version, notes,
|
|
72
|
+
* publish date), most recent first — useful for a "release history" screen.
|
|
73
|
+
* Draft releases (only visible with a token) are left out.
|
|
35
74
|
*/
|
|
36
75
|
export async function fetchReleaseHistory(ref) {
|
|
37
76
|
const { limit = 10 } = ref;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
77
|
+
const perPage = Math.min(Math.max(Math.trunc(limit), 1), MAX_PER_PAGE);
|
|
78
|
+
const data = await fetchGithubJson(`${releasesUrl(ref)}?per_page=${String(perPage)}`, ref);
|
|
79
|
+
return data
|
|
80
|
+
.filter((release) => !release.draft)
|
|
81
|
+
.map((release) => ({
|
|
43
82
|
version: (release.tag_name ?? "").replace(/^v/, ""),
|
|
44
83
|
notes: release.body ?? "",
|
|
45
84
|
publishedAt: release.published_at ?? "",
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forthtilliath/expo-release-updates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"publishConfig": {
|
|
7
8
|
"access": "public"
|
|
@@ -25,23 +26,23 @@
|
|
|
25
26
|
"expo-intent-launcher": ">=12.0.0"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"@forthtilliath/ts-kit": "0.
|
|
29
|
+
"@forthtilliath/ts-kit": "^0.11.0"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
|
-
"@vitest/coverage-v8": "
|
|
32
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
32
33
|
"eslint": "10.7.0",
|
|
33
34
|
"expo-file-system": "~57.0.2",
|
|
34
35
|
"expo-intent-launcher": "~57.0.1",
|
|
35
36
|
"typescript": "^5.9.3",
|
|
36
|
-
"vitest": "^
|
|
37
|
-
"@forthtilliath/eslint-config": "0.
|
|
38
|
-
"@forthtilliath/test-kit": "0.
|
|
37
|
+
"vitest": "^5.0.2",
|
|
38
|
+
"@forthtilliath/eslint-config": "0.8.0",
|
|
39
|
+
"@forthtilliath/test-kit": "0.3.0",
|
|
39
40
|
"@forthtilliath/typescript-config": "0.1.1"
|
|
40
41
|
},
|
|
41
42
|
"scripts": {
|
|
42
43
|
"lint": "eslint . --max-warnings 0",
|
|
43
44
|
"check-types": "tsc --noEmit",
|
|
44
|
-
"clean": "
|
|
45
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
45
46
|
"dev": "tsc --watch -p tsconfig.build.json",
|
|
46
47
|
"build": "pnpm run clean && tsc -p tsconfig.build.json",
|
|
47
48
|
"test": "vitest run",
|