react-native-store-version 1.4.2 → 2.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/CHANGELOG.md +28 -0
- package/README.md +514 -43
- package/dist/checkVersion.d.ts +73 -0
- package/dist/checkVersion.d.ts.map +1 -0
- package/dist/checkVersion.js +193 -0
- package/dist/cli/check.js +38 -0
- package/dist/cli/checks/ids.js +111 -0
- package/dist/cli/checks/installed.js +51 -0
- package/dist/cli/checks/peers.js +31 -0
- package/dist/cli/checks/rollout.js +141 -0
- package/dist/cli/checks/store.js +71 -0
- package/dist/cli/checks/versions.js +83 -0
- package/dist/cli/index.js +43 -0
- package/dist/cli/main.js +182 -0
- package/dist/cli/output.js +77 -0
- package/dist/cli/packages.js +40 -0
- package/dist/cli/project/android.js +60 -0
- package/dist/cli/project/expo.js +194 -0
- package/dist/cli/project/files.js +42 -0
- package/dist/cli/project/gradle.js +145 -0
- package/dist/cli/project/index.js +10 -0
- package/dist/cli/project/ios.js +190 -0
- package/dist/cli/project/jsx.js +327 -0
- package/dist/cli/project/plist.js +166 -0
- package/dist/cli/project/sources.js +86 -0
- package/dist/cli/result.js +12 -0
- package/dist/compare.d.ts +31 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +68 -0
- package/dist/detect.d.ts +68 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +103 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +23 -0
- package/dist/fetchers/android.d.ts +53 -0
- package/dist/fetchers/android.d.ts.map +1 -0
- package/dist/fetchers/android.js +91 -0
- package/dist/fetchers/custom.d.ts +29 -0
- package/dist/fetchers/custom.d.ts.map +1 -0
- package/dist/fetchers/custom.js +41 -0
- package/dist/fetchers/ios.d.ts +61 -0
- package/dist/fetchers/ios.d.ts.map +1 -0
- package/dist/fetchers/ios.js +79 -0
- package/dist/index.d.ts +15 -14
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -58
- package/dist/openStore.d.ts +29 -0
- package/dist/openStore.d.ts.map +1 -0
- package/dist/openStore.js +46 -0
- package/dist/policy.d.ts +75 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +128 -0
- package/dist/react/DefaultForceModal.d.ts +26 -0
- package/dist/react/DefaultForceModal.d.ts.map +1 -0
- package/dist/react/DefaultForceModal.js +58 -0
- package/dist/react/DefaultWarningSheet.d.ts +26 -0
- package/dist/react/DefaultWarningSheet.d.ts.map +1 -0
- package/dist/react/DefaultWarningSheet.js +66 -0
- package/dist/react/StoreVersionModal.d.ts +52 -0
- package/dist/react/StoreVersionModal.d.ts.map +1 -0
- package/dist/react/StoreVersionModal.js +250 -0
- package/dist/react/StoreVersionProvider.d.ts +51 -0
- package/dist/react/StoreVersionProvider.d.ts.map +1 -0
- package/dist/react/StoreVersionProvider.js +69 -0
- package/dist/react/defaultUi.d.ts +72 -0
- package/dist/react/defaultUi.d.ts.map +1 -0
- package/dist/react/defaultUi.js +118 -0
- package/dist/react/engine.d.ts +146 -0
- package/dist/react/engine.d.ts.map +1 -0
- package/dist/react/engine.js +541 -0
- package/dist/react/texts.d.ts +43 -0
- package/dist/react/texts.d.ts.map +1 -0
- package/dist/react/texts.js +57 -0
- package/dist/react/useIsomorphicLayoutEffect.d.ts +3 -0
- package/dist/react/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/react/useIsomorphicLayoutEffect.js +6 -0
- package/dist/react/useStoreVersion.d.ts +121 -0
- package/dist/react/useStoreVersion.d.ts.map +1 -0
- package/dist/react/useStoreVersion.js +23 -0
- package/dist/request.d.ts +78 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +227 -0
- package/dist/resolve.d.ts +98 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +218 -0
- package/dist/texts/ja.d.ts +8 -0
- package/dist/texts/ja.d.ts.map +1 -0
- package/dist/texts/ja.js +18 -0
- package/dist/vendor/compare-versions.d.ts +52 -0
- package/dist/vendor/compare-versions.d.ts.map +1 -0
- package/dist/vendor/compare-versions.js +111 -0
- package/package.json +83 -44
- package/src/checkVersion.ts +273 -0
- package/src/cli/check.ts +59 -0
- package/src/cli/checks/ids.ts +163 -0
- package/src/cli/checks/installed.ts +75 -0
- package/src/cli/checks/peers.ts +34 -0
- package/src/cli/checks/rollout.ts +183 -0
- package/src/cli/checks/store.ts +105 -0
- package/src/cli/checks/versions.ts +108 -0
- package/src/cli/index.ts +51 -0
- package/src/cli/main.ts +229 -0
- package/src/cli/output.ts +122 -0
- package/src/cli/packages.ts +43 -0
- package/src/cli/project/android.ts +96 -0
- package/src/cli/project/expo.ts +232 -0
- package/src/cli/project/files.ts +56 -0
- package/src/cli/project/gradle.ts +181 -0
- package/src/cli/project/index.ts +18 -0
- package/src/cli/project/ios.ts +284 -0
- package/src/cli/project/jsx.ts +354 -0
- package/src/cli/project/plist.ts +179 -0
- package/src/cli/project/sources.ts +118 -0
- package/src/cli/result.ts +34 -0
- package/src/compare.ts +101 -0
- package/src/detect.ts +178 -0
- package/src/errors.ts +63 -0
- package/src/fetchers/android.ts +136 -0
- package/src/fetchers/custom.ts +84 -0
- package/src/fetchers/ios.ts +160 -0
- package/src/index.ts +37 -0
- package/src/openStore.ts +74 -0
- package/src/policy.ts +223 -0
- package/src/react/DefaultForceModal.tsx +100 -0
- package/src/react/DefaultWarningSheet.tsx +115 -0
- package/src/react/StoreVersionModal.tsx +365 -0
- package/src/react/StoreVersionProvider.tsx +149 -0
- package/src/react/defaultUi.tsx +205 -0
- package/src/react/engine.ts +768 -0
- package/src/react/texts.ts +119 -0
- package/src/react/useIsomorphicLayoutEffect.ts +8 -0
- package/src/react/useStoreVersion.ts +153 -0
- package/src/request.ts +328 -0
- package/src/resolve.ts +388 -0
- package/src/texts/ja.ts +26 -0
- package/src/vendor/compare-versions.ts +128 -0
- package/texts/ja.d.ts +2 -0
- package/texts/ja.js +3 -0
- package/.eslintrc.js +0 -51
- package/.github/FUNDING.yml +0 -3
- package/.github/workflows/pr.yml +0 -48
- package/.github/workflows/release.yml +0 -259
- package/.prettierrc.js +0 -6
- package/babel.config.js +0 -3
- package/dist/android.d.ts +0 -1
- package/dist/android.js +0 -17
- package/dist/ios.d.ts +0 -1
- package/dist/ios.js +0 -18
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// デフォルト UI の文言。型、英語の既定値、既定値との 1 段のマージと `{remote}` / `{local}` の置き換え。
|
|
2
|
+
// React に依存しない純粋な関数にして、デフォルト UI と `components` の両方がこの結果を受け取る。
|
|
3
|
+
// 日本語のプリセットは src/texts/ja.ts(`react-native-store-version/texts/ja`)
|
|
4
|
+
|
|
5
|
+
/** Force(強制アップデート)の文言。`{remote}`(最新バージョン)/ `{local}`(現在のバージョン)は表示時に置き換える */
|
|
6
|
+
export type StoreVersionForceTexts = {
|
|
7
|
+
title: string;
|
|
8
|
+
message: string;
|
|
9
|
+
/** 更新ボタン */
|
|
10
|
+
update: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Warning(閉じられるボトムシート)の文言。`{remote}` / `{local}` は表示時に置き換える */
|
|
14
|
+
export type StoreVersionWarningTexts = {
|
|
15
|
+
title: string;
|
|
16
|
+
message: string;
|
|
17
|
+
/** 更新ボタン */
|
|
18
|
+
update: string;
|
|
19
|
+
/** 「あとで」ボタン */
|
|
20
|
+
later: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Provider の `texts`。`force` / `warning` ごとに、指定しなかったキーは英語の既定値で補完する(1 段だけマージ)。
|
|
25
|
+
* 日本語は `react-native-store-version/texts/ja` のプリセットを使う。変わったら表示中のモーダルにも反映する
|
|
26
|
+
*/
|
|
27
|
+
export type StoreVersionTexts = {
|
|
28
|
+
force?: Partial<StoreVersionForceTexts>;
|
|
29
|
+
warning?: Partial<StoreVersionWarningTexts>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** すべてのキーが埋まった文言(英語の既定値、`texts/ja` のプリセット、{@link resolveTexts} の結果の形) */
|
|
33
|
+
export type StoreVersionResolvedTexts = {
|
|
34
|
+
force: StoreVersionForceTexts;
|
|
35
|
+
warning: StoreVersionWarningTexts;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** 英語の既定値(元のデザインと同じ文言) */
|
|
39
|
+
export const DEFAULT_TEXTS: StoreVersionResolvedTexts = {
|
|
40
|
+
force: {
|
|
41
|
+
title: 'Update required',
|
|
42
|
+
message:
|
|
43
|
+
'A new version ({remote}) is available. Please update to continue using the app.',
|
|
44
|
+
update: 'Update',
|
|
45
|
+
},
|
|
46
|
+
warning: {
|
|
47
|
+
title: 'Update available',
|
|
48
|
+
message: 'Version {remote} is now available.',
|
|
49
|
+
update: 'Update',
|
|
50
|
+
later: 'Later',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** 置き換えに使う値。最新バージョンが比較できず、最低バージョンで force になったときは `remote` が null */
|
|
55
|
+
export type TextVariables = {
|
|
56
|
+
local: string | null;
|
|
57
|
+
remote: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// プレースホルダーと、それを直接囲む括弧(半角 / 全角)・その前の空白。1 回の replace で全部を扱う
|
|
61
|
+
// (置き換えた値をもう一度走査しないので、remote に "{local}" が入っていても二重に置換しない。
|
|
62
|
+
// String.prototype.replaceAll に頼らないので古い JSC でも動く)
|
|
63
|
+
const PLACEHOLDER = /(\s*)([((]?)\{(local|remote)\}([))]?)/g;
|
|
64
|
+
|
|
65
|
+
const isPair = (open: string, close: string) =>
|
|
66
|
+
(open === '(' && close === ')') || (open === '(' && close === ')');
|
|
67
|
+
|
|
68
|
+
// `{remote}` / `{local}` のすべての出現を置き換える。値が null(Play の版が比較できず、最低バージョンで force になったとき)は
|
|
69
|
+
// 空文字にし、プレースホルダーを直接囲んでいた空の括弧「()」「()」(とその前の空白)を取り除く。元の文言にある無関係な括弧は消さない。
|
|
70
|
+
// 余った空白(連続する空白・前後の空白)も取り除く。既定の英語は「A new version is available. …」、
|
|
71
|
+
// 日本語は「新しいバージョンが公開されています。…」になる。括弧を使っていない独自の文言では、ただ空文字に置き換わるだけ。
|
|
72
|
+
// 値がそろっているときは文言に手を加えない
|
|
73
|
+
const fill = (text: string, variables: TextVariables): string => {
|
|
74
|
+
let emptied = false;
|
|
75
|
+
const out = text.replace(
|
|
76
|
+
PLACEHOLDER,
|
|
77
|
+
(_match, space: string, open: string, key: string, close: string) => {
|
|
78
|
+
const value = variables[key as keyof TextVariables];
|
|
79
|
+
if (value !== null) {
|
|
80
|
+
return `${space}${open}${value}${close}`;
|
|
81
|
+
}
|
|
82
|
+
emptied = true;
|
|
83
|
+
return isPair(open, close) ? '' : `${space}${open}${close}`;
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
return emptied ? out.replace(/ {2,}/g, ' ').trim() : out;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// 指定された値が文字列のときだけ使う(JS からの利用で数値や null を渡されても既定の文言に戻す)
|
|
90
|
+
const textOrDefault = (value: unknown, fallback: string): string =>
|
|
91
|
+
typeof value === 'string' ? value : fallback;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Provider の `texts` を表示用の文言にする。`force` / `warning` ごとに、指定しなかったキー(文字列でない値も)を英語の既定値で
|
|
95
|
+
* 補完し(1 段だけマージ)、`{remote}` / `{local}` のすべての出現を置き換える。
|
|
96
|
+
* `texts` / `local` / `remote` が変わるたびに呼ぶ(`texts` の変更を表示中のモーダルにも即座に反映するため)
|
|
97
|
+
*/
|
|
98
|
+
export const resolveTexts = (
|
|
99
|
+
texts: StoreVersionTexts | undefined,
|
|
100
|
+
variables: TextVariables
|
|
101
|
+
): StoreVersionResolvedTexts => {
|
|
102
|
+
const force = { ...DEFAULT_TEXTS.force };
|
|
103
|
+
const warning = { ...DEFAULT_TEXTS.warning };
|
|
104
|
+
for (const key of Object.keys(force) as (keyof StoreVersionForceTexts)[]) {
|
|
105
|
+
force[key] = fill(
|
|
106
|
+
textOrDefault(texts?.force?.[key], force[key]),
|
|
107
|
+
variables
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
for (const key of Object.keys(
|
|
111
|
+
warning
|
|
112
|
+
) as (keyof StoreVersionWarningTexts)[]) {
|
|
113
|
+
warning[key] = fill(
|
|
114
|
+
textOrDefault(texts?.warning?.[key], warning[key]),
|
|
115
|
+
variables
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return { force, warning };
|
|
119
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// サーバーでの描画(react-native-web の SSR など)では useLayoutEffect が React 18 で警告を出すので、window が無ければ useEffect にする。
|
|
2
|
+
// Provider(props の ref)とデフォルト UI の Modal(閉じる途中に描く最後の props)が、commit の後・配下の passive effect より先に ref を書くために使う
|
|
3
|
+
import { useEffect, useLayoutEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export const useIsomorphicLayoutEffect =
|
|
6
|
+
(globalThis as { window?: unknown }).window === undefined
|
|
7
|
+
? useEffect
|
|
8
|
+
: useLayoutEffect;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Provider が持つ判定結果を配下に配る Context と、それを読むだけの hook。
|
|
2
|
+
// 判定は Provider が 1 つだけ持つので、hook を何箇所で使っても fetch は増えない
|
|
3
|
+
import { type ComponentType, createContext, useContext } from 'react';
|
|
4
|
+
import type { ColorValue, ImageSourcePropType } from 'react-native';
|
|
5
|
+
|
|
6
|
+
import type { CompareDetail, CompareResult, DiffType } from '../compare.ts';
|
|
7
|
+
import type { StoreVersionError } from '../errors.ts';
|
|
8
|
+
import type { Action } from '../policy.ts';
|
|
9
|
+
import type {
|
|
10
|
+
StoreVersionForceTexts,
|
|
11
|
+
StoreVersionTexts,
|
|
12
|
+
StoreVersionWarningTexts,
|
|
13
|
+
} from './texts.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 進行状態。
|
|
17
|
+
* - `idle`: まだ判定していない(マウント直後。バックグラウンドのままマウントされたら active になるまで)
|
|
18
|
+
* - `checking`: 判定中
|
|
19
|
+
* - `ready`: 判定できた(`result` / `action` などに値が入る)
|
|
20
|
+
* - `skipped`: 判定しない環境(web / ios・android 以外の OS / Expo Go / `enabled={false}`)。`onError` は呼ばない
|
|
21
|
+
* - `error`: 判定できなかった(`error` に理由。UI は出さない)
|
|
22
|
+
*/
|
|
23
|
+
export type StoreVersionStatus =
|
|
24
|
+
| 'idle'
|
|
25
|
+
| 'checking'
|
|
26
|
+
| 'ready'
|
|
27
|
+
| 'skipped'
|
|
28
|
+
| 'error';
|
|
29
|
+
|
|
30
|
+
/** {@link useStoreVersion} の戻り値 */
|
|
31
|
+
export type UseStoreVersionResult = {
|
|
32
|
+
/** 進行状態 */
|
|
33
|
+
status: StoreVersionStatus;
|
|
34
|
+
/**
|
|
35
|
+
* 比較の結果(`ready` のときのみ。再チェック中の `checking` では前回の ready の値)。v1 の `checkVersion()` と同じ値で、policy では変わらない。
|
|
36
|
+
* 最新バージョンが比較できず、最低バージョンで force になったときは null
|
|
37
|
+
*/
|
|
38
|
+
result: CompareResult | null;
|
|
39
|
+
/** v1 の `checkVersion()` と同じ `detail`(`result` と同じく、ready と再チェック中のみ) */
|
|
40
|
+
detail: CompareDetail | null;
|
|
41
|
+
/**
|
|
42
|
+
* policy と最低バージョンを当てた結果(`result` と同じく、ready と再チェック中のみ)。デフォルト UI はこれで出す。
|
|
43
|
+
* 再チェックの間も前回の値のままなので、force のモーダルは再チェック中に消えない
|
|
44
|
+
*/
|
|
45
|
+
action: Action | null;
|
|
46
|
+
/** 差分の種類(`result` が `'new'` のときのみ) */
|
|
47
|
+
diff: DiffType | null;
|
|
48
|
+
/** `action` が `warning` だが「あとで」(`dismiss`)が押されて UI を出していない。remote が変われば false に戻る */
|
|
49
|
+
dismissed: boolean;
|
|
50
|
+
/** アプリのバージョン(ネイティブのバージョン)。判定を始めた後に入る */
|
|
51
|
+
local: string | null;
|
|
52
|
+
/** 最新バージョン(`result` と同じく、ready と再チェック中のみ) */
|
|
53
|
+
remote: string | null;
|
|
54
|
+
/** 開くべきストアの URL(`result` と同じく、ready と再チェック中のみ)。iOS で URL を取得中・取得に失敗したときは null */
|
|
55
|
+
storeUrl: string | null;
|
|
56
|
+
/** iOS でストアのバージョンを使ったときの `releaseNotes`(`result` と同じく、ready と再チェック中のみ)。Android と `getLatestVersion` 使用時は undefined */
|
|
57
|
+
releaseNotes: string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* ストアのアプリのページを開く。iOS で URL を取得中なら待ち、取れていなければその場でもう一度 lookup する。
|
|
60
|
+
* 開けたら true、開けなかったら false で resolve する(reject しない。`onPress={openStore}` のまま使える)。
|
|
61
|
+
* 失敗(iOS の lookup の失敗・`Linking.openURL` の失敗・判定前や skipped で開く先が分からない)は
|
|
62
|
+
* `STORE_URL_UNAVAILABLE` で `onError` に渡す。`status` / `error` は変えない
|
|
63
|
+
*/
|
|
64
|
+
openStore: () => Promise<boolean>;
|
|
65
|
+
/** warning を閉じる(同じ remote のバージョンの間は出さない。メモリだけに持つので、次に起動したら再び出る) */
|
|
66
|
+
dismiss: () => void;
|
|
67
|
+
/**
|
|
68
|
+
* 手動で再チェックする(判定中なら、それを取り消してやり直す)。再チェックの間も前回の結果は残り、失敗したら前回の結果に戻る。
|
|
69
|
+
* バックグラウンドの間に呼ばれたら、active になってから始める
|
|
70
|
+
*/
|
|
71
|
+
recheck: () => void;
|
|
72
|
+
/** 判定できなかった理由(`error` のときのみ。再チェックの失敗では前回の結果に戻るので入らない。`onError` には渡る) */
|
|
73
|
+
error: StoreVersionError | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `components.Force` に渡す props。このコンポーネントが描くのはカードの中身だけで、Modal・幕・配置・
|
|
78
|
+
* Android の戻るボタンで閉じないこと・開閉のアニメーションはライブラリが持つ。
|
|
79
|
+
* 置かれ方: 左右 24 の余白の内側で画面の縦中央、ScrollView の中。カードの面(背景・角丸・影・最大幅 480)はライブラリは掛けないので、
|
|
80
|
+
* このコンポーネントが自分で描く
|
|
81
|
+
*/
|
|
82
|
+
export type StoreVersionForceProps = {
|
|
83
|
+
local: string;
|
|
84
|
+
/** 最新バージョン。Play の版が比較できず、最低バージョンで force になったときは null */
|
|
85
|
+
remote: string | null;
|
|
86
|
+
storeUrl: string | null;
|
|
87
|
+
releaseNotes: string | undefined;
|
|
88
|
+
/** `{remote}` / `{local}` を置き換え済みの文言 */
|
|
89
|
+
texts: StoreVersionForceTexts;
|
|
90
|
+
icon: ImageSourcePropType | undefined;
|
|
91
|
+
/** 開けたら true、開けなかったら false(reject しない。失敗は `onError` に渡る) */
|
|
92
|
+
openStore: () => Promise<boolean>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `components.Warning` に渡す props。Force の props に加えて `dismiss`(「あとで」)。このコンポーネントが描くのはシートの中身だけで、
|
|
97
|
+
* Modal・幕・配置・戻るボタン / 背景タップで `dismiss` すること・開閉のアニメーションはライブラリが持つ。
|
|
98
|
+
* 置かれ方: 画面幅で下寄せ、ScrollView の中。シートの面(背景・角丸・影・最大幅 480)はライブラリは掛けないので、
|
|
99
|
+
* このコンポーネントが自分で描く
|
|
100
|
+
*/
|
|
101
|
+
export type StoreVersionWarningProps = Omit<StoreVersionForceProps, 'texts'> & {
|
|
102
|
+
/** `{remote}` / `{local}` を置き換え済みの文言 */
|
|
103
|
+
texts: StoreVersionWarningTexts;
|
|
104
|
+
dismiss: () => void;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* デフォルト UI の中身の差し替え。差し替えられるのはカード(Force)/ シート(Warning)の中身だけ。
|
|
109
|
+
* Modal、幕(オーバーレイ)の色、配置、Android の戻るボタンと背景タップの扱い(Force は閉じない、Warning は `dismiss`)、
|
|
110
|
+
* 開閉のアニメーションはライブラリが持ち、差し替えても変わらない。
|
|
111
|
+
* 置かれ方: Force は左右 24 の余白の内側で画面の縦中央、Warning は画面幅で下寄せ。どちらも ScrollView の中。
|
|
112
|
+
* カード / シートの面(背景・角丸・影・最大幅 480)はライブラリは掛けないので、差し替えたコンポーネントが自分で描く
|
|
113
|
+
*/
|
|
114
|
+
export type StoreVersionComponents = {
|
|
115
|
+
Force?: ComponentType<StoreVersionForceProps>;
|
|
116
|
+
Warning?: ComponentType<StoreVersionWarningProps>;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** デフォルト UI に渡す表示の設定(Provider の props をそのまま置く) */
|
|
120
|
+
export type StoreVersionDisplay = {
|
|
121
|
+
ui: boolean;
|
|
122
|
+
texts: StoreVersionTexts | undefined;
|
|
123
|
+
icon: ImageSourcePropType | undefined;
|
|
124
|
+
components: StoreVersionComponents | undefined;
|
|
125
|
+
primaryColor: ColorValue | undefined;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 判定の状態の Context(hook が読む。パッケージの入口からは export しない)。
|
|
130
|
+
* 表示の設定とは分けてある(インラインの `texts` などで Provider が描画し直しても、hook を使う側を描画し直さないように)
|
|
131
|
+
*/
|
|
132
|
+
export const StoreVersionStateContext =
|
|
133
|
+
createContext<UseStoreVersionResult | null>(null);
|
|
134
|
+
StoreVersionStateContext.displayName = 'StoreVersionStateContext';
|
|
135
|
+
|
|
136
|
+
/** 表示の設定の Context(デフォルト UI が読む。パッケージの入口からは export しない) */
|
|
137
|
+
export const StoreVersionDisplayContext =
|
|
138
|
+
createContext<StoreVersionDisplay | null>(null);
|
|
139
|
+
StoreVersionDisplayContext.displayName = 'StoreVersionDisplayContext';
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `StoreVersionProvider` が持つ判定結果を読む。Provider 配下ならどこでも呼べ、何箇所で呼んでも fetch は増えない。
|
|
143
|
+
* Provider の外で呼ぶと throw する
|
|
144
|
+
*/
|
|
145
|
+
export const useStoreVersion = (): UseStoreVersionResult => {
|
|
146
|
+
const state = useContext(StoreVersionStateContext);
|
|
147
|
+
if (state === null) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'useStoreVersion() must be called inside <StoreVersionProvider>. Wrap your app (or the component tree that calls it) with <StoreVersionProvider> from react-native-store-version.'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return state;
|
|
153
|
+
};
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// ストアへの取得の共通部分(タイムアウト / in-flight の共有 / iTunes のキャッシュ回避)。
|
|
2
|
+
// react-native を import しない(Node でも動かす。canary でも使う)。
|
|
3
|
+
// 古い Hermes に無い API(AbortSignal.timeout / AbortSignal.any / DOMException など)は使わず、
|
|
4
|
+
// AbortController と setTimeout だけで組む
|
|
5
|
+
import { StoreVersionError } from './errors.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 呼び出し元の `signal` が abort されたときに投げるエラー(`name === 'AbortError'`)。
|
|
9
|
+
* 古い Hermes には DOMException が無いので、name だけを合わせた Error にする
|
|
10
|
+
*/
|
|
11
|
+
export const createAbortError = (): Error => {
|
|
12
|
+
const error = new Error('The operation was aborted.');
|
|
13
|
+
error.name = 'AbortError';
|
|
14
|
+
return error;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** `createAbortError` や fetch の abort で投げられたエラーか */
|
|
18
|
+
export const isAbortError = (error: unknown): boolean =>
|
|
19
|
+
typeof error === 'object' &&
|
|
20
|
+
error !== null &&
|
|
21
|
+
(error as { name?: unknown }).name === 'AbortError';
|
|
22
|
+
|
|
23
|
+
// setTimeout は 2^31 - 1 ms を超えるとすぐに発火する(オーバーフロー)ので、そこで頭打ちにする
|
|
24
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
25
|
+
|
|
26
|
+
/** タイムアウトを入れるか。正の有限の数のときだけ(0 / 負 / NaN / Infinity / 未指定はタイムアウトなし) */
|
|
27
|
+
const hasTimeout = (timeoutMs: number | undefined): timeoutMs is number =>
|
|
28
|
+
typeof timeoutMs === 'number' && timeoutMs > 0 && timeoutMs !== Infinity;
|
|
29
|
+
|
|
30
|
+
/** 呼び出し元の signal とタイムアウトを 1 つにまとめた signal({@link createScope}) */
|
|
31
|
+
export type Scope = {
|
|
32
|
+
/** 呼び出し元の signal が abort されたとき、またはタイムアウトしたときに abort される */
|
|
33
|
+
readonly signal: AbortSignal;
|
|
34
|
+
/** abort の理由がタイムアウトだったか */
|
|
35
|
+
readonly timedOut: boolean;
|
|
36
|
+
/** 自分から abort する(タイムアウト扱いにはしない) */
|
|
37
|
+
abort(): void;
|
|
38
|
+
/** タイマーと呼び出し元の signal への登録を外す(終わったら必ず呼ぶ) */
|
|
39
|
+
dispose(): void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 呼び出し元の `signal` と `timeoutMs` から、どちらかで abort される signal を作る。
|
|
44
|
+
* `timeoutMs` が未指定(または正の有限の数でない)ならタイムアウトしない
|
|
45
|
+
*/
|
|
46
|
+
export const createScope = ({
|
|
47
|
+
signal,
|
|
48
|
+
timeoutMs,
|
|
49
|
+
}: {
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
} = {}): Scope => {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
let timedOut = false;
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
|
|
57
|
+
const dispose = () => {
|
|
58
|
+
if (timer !== undefined) {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
timer = undefined;
|
|
61
|
+
}
|
|
62
|
+
signal?.removeEventListener('abort', onAbort);
|
|
63
|
+
};
|
|
64
|
+
function onAbort() {
|
|
65
|
+
dispose();
|
|
66
|
+
controller.abort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (signal?.aborted) {
|
|
70
|
+
controller.abort();
|
|
71
|
+
} else {
|
|
72
|
+
signal?.addEventListener('abort', onAbort);
|
|
73
|
+
if (hasTimeout(timeoutMs)) {
|
|
74
|
+
timer = setTimeout(
|
|
75
|
+
() => {
|
|
76
|
+
timer = undefined;
|
|
77
|
+
timedOut = true;
|
|
78
|
+
dispose();
|
|
79
|
+
controller.abort();
|
|
80
|
+
},
|
|
81
|
+
Math.min(timeoutMs, MAX_TIMEOUT_MS)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
get timedOut() {
|
|
89
|
+
return timedOut;
|
|
90
|
+
},
|
|
91
|
+
abort: () => {
|
|
92
|
+
dispose();
|
|
93
|
+
controller.abort();
|
|
94
|
+
},
|
|
95
|
+
dispose,
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `promise` の完了か `signal` の abort の早い方で終わる。abort なら `createAbortError()` で reject する。
|
|
101
|
+
* `signal` を見ない処理(開発者の `getLatestVersion` など)を、タイムアウトや呼び出し元の abort で打ち切るため。
|
|
102
|
+
* 打ち切った後に `promise` が reject しても unhandled rejection にはならない
|
|
103
|
+
*/
|
|
104
|
+
export const raceAbort = <T>(
|
|
105
|
+
promise: PromiseLike<T>,
|
|
106
|
+
signal: AbortSignal
|
|
107
|
+
): Promise<T> =>
|
|
108
|
+
new Promise<T>((resolve, reject) => {
|
|
109
|
+
const onAbort = () => reject(createAbortError());
|
|
110
|
+
if (signal.aborted) {
|
|
111
|
+
onAbort();
|
|
112
|
+
} else {
|
|
113
|
+
signal.addEventListener('abort', onAbort);
|
|
114
|
+
}
|
|
115
|
+
promise.then(
|
|
116
|
+
(value) => {
|
|
117
|
+
signal.removeEventListener('abort', onAbort);
|
|
118
|
+
resolve(value);
|
|
119
|
+
},
|
|
120
|
+
(error: unknown) => {
|
|
121
|
+
signal.removeEventListener('abort', onAbort);
|
|
122
|
+
reject(error);
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** {@link requestText} の結果。成功・失敗を問わず status と本文を返す(status の扱いは fetcher が決める) */
|
|
128
|
+
export type TextResponse = {
|
|
129
|
+
readonly status: number;
|
|
130
|
+
readonly body: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** {@link requestText} のオプション */
|
|
134
|
+
export type RequestOptions = {
|
|
135
|
+
/**
|
|
136
|
+
* この呼び出し元のタイムアウト(ms)。未指定ならタイムアウトしない(10 秒の既定は Provider が渡す)。
|
|
137
|
+
* 共有中の取得に後から加わった呼び出し元も、自分の `timeoutMs` で数える(ほかの呼び出し元の値には影響されない)。
|
|
138
|
+
* 時間切れの呼び出し元だけが `TIMEOUT` で抜け、その時点で待っている呼び出し元がいなければ fetch も止める
|
|
139
|
+
*/
|
|
140
|
+
timeoutMs?: number;
|
|
141
|
+
/**
|
|
142
|
+
* この呼び出し元の取り消し。abort するとこの呼び出し元だけが `AbortError` で抜ける。
|
|
143
|
+
* ほかの呼び出し元が同じ取得を待っていれば fetch は続く
|
|
144
|
+
*/
|
|
145
|
+
signal?: AbortSignal;
|
|
146
|
+
/**
|
|
147
|
+
* iTunes Lookup の CDN キャッシュを避ける。URL の末尾にタイムスタンプ(`&<ts>`)を付け、
|
|
148
|
+
* `cache-control: no-cache` を送る(v1 と同じ形)
|
|
149
|
+
*/
|
|
150
|
+
noCache?: boolean;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// 本文の読み込みに失敗した NETWORK のエラーと、その応答の status。StoreVersionError にフィールドを足さずに(公開の形を変えずに)
|
|
154
|
+
// v1 互換の checkVersion へ status を渡すため。WeakMap なのでエラーが捨てられれば消える(キャッシュではない)
|
|
155
|
+
const failedResponseStatus = new WeakMap<object, number>();
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* {@link requestText} の `NETWORK` のうち、応答(status)は受け取ったが本文の読み込みに失敗したものなら、その status を返す。
|
|
159
|
+
* fetch 自体が失敗した場合やほかの値なら undefined。v1 互換の checkVersion が、v1 と同じく Android の 200 以外を
|
|
160
|
+
* 本文より先に `androidStoreURL is invalid.` にするために使う(パッケージの入口からは export しない)
|
|
161
|
+
*/
|
|
162
|
+
export const failedResponseStatusOf = (error: unknown): number | undefined =>
|
|
163
|
+
typeof error === 'object' && error !== null
|
|
164
|
+
? failedResponseStatus.get(error)
|
|
165
|
+
: undefined;
|
|
166
|
+
|
|
167
|
+
type Entry = {
|
|
168
|
+
key: string;
|
|
169
|
+
promise: Promise<TextResponse>;
|
|
170
|
+
controller: AbortController;
|
|
171
|
+
/** この取得を待っている呼び出し元の数 */
|
|
172
|
+
waiters: number;
|
|
173
|
+
settled: boolean;
|
|
174
|
+
/** 待っている呼び出し元が 0 になってから fetch を止めるまでのタイマー */
|
|
175
|
+
idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// 取得中のものだけを持つ(完了したら消す。結果は保持しない)。キーはタイムスタンプを付ける前の取得先
|
|
179
|
+
const inflight = new Map<string, Entry>();
|
|
180
|
+
|
|
181
|
+
const clearIdleTimer = (entry: Entry) => {
|
|
182
|
+
if (entry.idleTimer !== undefined) {
|
|
183
|
+
clearTimeout(entry.idleTimer);
|
|
184
|
+
entry.idleTimer = undefined;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** 誰も待っていなければ fetch を止める。後から来た呼び出し元は新しく取り直す */
|
|
189
|
+
const stopIfIdle = (entry: Entry) => {
|
|
190
|
+
clearIdleTimer(entry);
|
|
191
|
+
if (entry.settled || entry.waiters > 0) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (inflight.get(entry.key) === entry) {
|
|
195
|
+
inflight.delete(entry.key);
|
|
196
|
+
}
|
|
197
|
+
entry.controller.abort();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const start = (key: string, url: string, noCache: boolean): Entry => {
|
|
201
|
+
const controller = new AbortController();
|
|
202
|
+
// v1 と同じく、タイムスタンプはクエリの最後に `&<ts>` で付ける
|
|
203
|
+
const target = noCache
|
|
204
|
+
? `${url}${url.includes('?') ? '&' : '?'}${Date.now()}`
|
|
205
|
+
: url;
|
|
206
|
+
const init: RequestInit = noCache
|
|
207
|
+
? { headers: { 'cache-control': 'no-cache' }, signal: controller.signal }
|
|
208
|
+
: { signal: controller.signal };
|
|
209
|
+
|
|
210
|
+
const promise = (async (): Promise<TextResponse> => {
|
|
211
|
+
let status: number | undefined;
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch(target, init);
|
|
214
|
+
status = response.status;
|
|
215
|
+
return { status, body: await response.text() };
|
|
216
|
+
} catch (cause) {
|
|
217
|
+
const error = new StoreVersionError(
|
|
218
|
+
'NETWORK',
|
|
219
|
+
`request to ${url} failed`,
|
|
220
|
+
{ cause }
|
|
221
|
+
);
|
|
222
|
+
// 応答は来たが本文の読み込みに失敗した場合だけ、その status を添える({@link failedResponseStatusOf})
|
|
223
|
+
if (typeof status === 'number') {
|
|
224
|
+
failedResponseStatus.set(error, status);
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
|
|
230
|
+
const entry: Entry = {
|
|
231
|
+
key,
|
|
232
|
+
promise,
|
|
233
|
+
controller,
|
|
234
|
+
waiters: 0,
|
|
235
|
+
settled: false,
|
|
236
|
+
idleTimer: undefined,
|
|
237
|
+
};
|
|
238
|
+
const settle = () => {
|
|
239
|
+
entry.settled = true;
|
|
240
|
+
clearIdleTimer(entry);
|
|
241
|
+
if (inflight.get(key) === entry) {
|
|
242
|
+
inflight.delete(key);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
// 待っている呼び出し元が全員抜けた後の reject(abort)も、ここで受けるので unhandled rejection にならない
|
|
246
|
+
promise.then(settle, settle);
|
|
247
|
+
return entry;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `url` を取得して status と本文を返す。同じ取得先(タイムスタンプを付ける前の URL と `noCache`)への同時の呼び出しは
|
|
252
|
+
* 1 回の fetch にまとめ、完了したら(成功・失敗とも)共有をやめる(次の呼び出しは取り直す。キャッシュしない)。
|
|
253
|
+
*
|
|
254
|
+
* - 通信の失敗(fetch の reject / 本文の読み込みの失敗)は `StoreVersionError('NETWORK')`(cause に元のエラー)
|
|
255
|
+
* - この呼び出し元のタイムアウトは `StoreVersionError('TIMEOUT')`
|
|
256
|
+
* - この呼び出し元の `signal` の abort は `AbortError`({@link isAbortError})
|
|
257
|
+
* - 実際の fetch を止めるのは、待っている呼び出し元が全員抜けたとき。タイムアウトで抜けた場合はすぐ止め、
|
|
258
|
+
* abort で抜けた場合は次のタスクまで待ってから止める(StrictMode では 1 回目の effect の cleanup の abort の直後に
|
|
259
|
+
* 2 回目の effect が同じ取得に加わるので、それを受け取れるようにするため)。上位(resolve など)のタイムアウトは
|
|
260
|
+
* `signal` の abort として届くので、abort と同じく次のタスクで止める
|
|
261
|
+
*
|
|
262
|
+
* HTTP の status は見ない(200 以外の扱いは fetcher が決める)
|
|
263
|
+
*/
|
|
264
|
+
export const requestText = (
|
|
265
|
+
url: string,
|
|
266
|
+
{ timeoutMs, signal, noCache = false }: RequestOptions = {}
|
|
267
|
+
): Promise<TextResponse> =>
|
|
268
|
+
new Promise<TextResponse>((resolve, reject) => {
|
|
269
|
+
const scope = createScope({ signal, timeoutMs });
|
|
270
|
+
if (scope.signal.aborted) {
|
|
271
|
+
reject(createAbortError());
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const key = `${noCache ? 'no-cache ' : ''}${url}`;
|
|
276
|
+
let entry = inflight.get(key);
|
|
277
|
+
if (!entry) {
|
|
278
|
+
entry = start(key, url, noCache);
|
|
279
|
+
inflight.set(key, entry);
|
|
280
|
+
}
|
|
281
|
+
const joined = entry;
|
|
282
|
+
joined.waiters += 1;
|
|
283
|
+
clearIdleTimer(joined);
|
|
284
|
+
|
|
285
|
+
let done = false;
|
|
286
|
+
const finish = () => {
|
|
287
|
+
done = true;
|
|
288
|
+
scope.signal.removeEventListener('abort', onLeave);
|
|
289
|
+
scope.dispose();
|
|
290
|
+
};
|
|
291
|
+
// finish() で外すので、結果を受け取った後には呼ばれない
|
|
292
|
+
function onLeave() {
|
|
293
|
+
const timedOut = scope.timedOut;
|
|
294
|
+
finish();
|
|
295
|
+
joined.waiters -= 1;
|
|
296
|
+
if (joined.waiters === 0) {
|
|
297
|
+
if (timedOut) {
|
|
298
|
+
stopIfIdle(joined);
|
|
299
|
+
} else {
|
|
300
|
+
joined.idleTimer = setTimeout(() => stopIfIdle(joined), 0);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
reject(
|
|
304
|
+
timedOut
|
|
305
|
+
? new StoreVersionError(
|
|
306
|
+
'TIMEOUT',
|
|
307
|
+
`request to ${url} timed out after ${timeoutMs}ms`
|
|
308
|
+
)
|
|
309
|
+
: createAbortError()
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
scope.signal.addEventListener('abort', onLeave);
|
|
313
|
+
|
|
314
|
+
joined.promise.then(
|
|
315
|
+
(value) => {
|
|
316
|
+
if (!done) {
|
|
317
|
+
finish();
|
|
318
|
+
resolve(value);
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
(error: unknown) => {
|
|
322
|
+
if (!done) {
|
|
323
|
+
finish();
|
|
324
|
+
reject(error);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
);
|
|
328
|
+
});
|