web-launch-kit 0.0.5 → 0.0.7

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.ko.md CHANGED
@@ -30,13 +30,20 @@ npm install web-launch-kit
30
30
  | `LaunchKit.telephone(options?)` | `Promise<void>` | 전화 앱 열기 (`tel:`) |
31
31
  | `LaunchKit.message(options?)` | `Promise<void>` | 문자 작성 열기 (`sms:`) |
32
32
  | `LaunchKit.mail(options?)` | `Promise<void>` | 메일 작성 열기 (`mailto:`) |
33
- | `LaunchKit.map(options?)` | `Promise<void>` | 검색어·좌표·경로로 지도 열기. OS별 네이티브 앱과 Google Maps 웹 폴백 |
33
+ | `LaunchKit.map(options?)` | `Promise<AppOpenedBy>` | 검색어·좌표·경로로 지도 열기. OS별 네이티브 앱과 Google Maps 웹 폴백 |
34
34
  | `LaunchKit.filepicker(options?)` | `Promise<File[]>` | 파일 또는 디렉터리 선택 (File System Access API, input 폴백) |
35
35
  | `LaunchKit.setting(type?)` | `Promise<void>` | 지원되는 환경에서 시스템 설정 화면 열기 |
36
36
  | `LaunchKit.utils` | object | `canOpenIntent` / `canOpenUniversal` / `canOpenSetting` getter와 비동기 `getTrackId` / `getProductId` |
37
37
 
38
38
  `AppOpenedBy`는 `"scheme"`, `"universal"`, `"intent"`, `"fallback"`, `"store"` 중 하나입니다.
39
39
 
40
+ `app()`과 `map()`은 `LaunchError`로 reject합니다 — 필드 두 개가 붙은 실제 `Error`입니다.
41
+
42
+ | 필드 | 타입 | 설명 |
43
+ | --- | --- | --- |
44
+ | `code` | `"unsupported-os" \| "no-candidates" \| "all-failed"` | 실패 사유 |
45
+ | `attempted` | `readonly LaunchAttempt[]` | 시도한 모든 후보의 `{ by, url, index, total }` |
46
+
40
47
  ---
41
48
 
42
49
  ## ESM
@@ -112,10 +119,10 @@ flowchart TD
112
119
  A1{"intent given, but scheme /<br/>packageName / fallback missing?"}
113
120
  A1 -->|yes| A2["parseIntentURL:<br/>derive scheme · packageName · fallback"]
114
121
  A1 -->|no| A3
115
- A2 --> A3{"scheme given, but intent missing?"}
122
+ A2 --> A3{"allowIntent, and scheme given<br/>but intent missing?"}
116
123
  A3 -->|yes| A4["createIntentURL:<br/>scheme + packageName + fallback"]
117
124
  A3 -->|no| A5
118
- A4 --> A5["Priority list:<br/>intent (if canOpenIntent) ⭢ scheme (if canOpenScheme)<br/>⭢ fallback ⭢ app store (if canOpenScheme) ⭢ web store"]
125
+ A4 --> A5["Priority list:<br/>intent (if allowIntent and canOpenIntent) ⭢ scheme (if canOpenScheme)<br/>⭢ fallback ⭢ app store (if canOpenScheme) ⭢ web store"]
119
126
  end
120
127
 
121
128
  subgraph IOS["ios · resolveOptions"]
@@ -195,11 +202,26 @@ const openedBy = await LaunchKit.app({
195
202
  console.log(openedBy) // "universal" | "scheme" | "intent" | "fallback" | "store"
196
203
  ```
197
204
 
198
- 플랫폼별 필드: 안드로이드는 `intent` / `scheme` / `packageName` / `fallback`(scheme ⇄ intent는
199
- 서로에게서 유도됩니다), iOS는 `universal` / `scheme` / `bundleId` / `trackId`, Windows는
205
+ 플랫폼별 필드: 안드로이드는 `intent` / `scheme` / `packageName` / `fallback` /
206
+ `allowIntent`(scheme ⇄ intent는 서로에게서 유도됩니다), iOS는 `universal` / `scheme` / `bundleId` / `trackId`, Windows는
200
207
  `scheme` / `packageFamilyName` / `productId`, macOS는 `scheme` / `bundleId` / `trackId`를
201
208
  받습니다. 모두 `fallback`, `timeout`, `allowAppStore`, `allowWebStore`를 함께 받습니다.
202
209
 
210
+ `onAttempt`는 각 후보 시도 직전에 동기로 호출됩니다. 대기 UI를 여기서 그리세요 — 그러지
211
+ 않으면 타임아웃 동안 화면에 아무 변화가 없습니다.
212
+
213
+ ```js
214
+ await LaunchKit.app({
215
+ android: { scheme: 'myapp://profile/42', packageName: 'com.example.myapp', allowWebStore: true },
216
+ onAttempt({ by, index, total }) {
217
+ setStatus(by === 'store' ? '스토어로 이동합니다' : '앱을 여는 중… (' + (index + 1) + '/' + total + ')')
218
+ },
219
+ })
220
+ ```
221
+
222
+ 이미 진행 중인 호출이 있으면 **같은 프로미스를 반환**합니다. 연타해도 두 번째 체인이 첫 번째
223
+ 내비게이션을 덮어쓰는 일이 없습니다.
224
+
203
225
  안드로이드와 iOS는 `assumeAllowedInApp`을 추가로 받습니다. 파트너 화이트리스트로 동작하는
204
226
  인앱 브라우저(예: 웨이보)에 앱이 등록되어 있을 때만 `true`로 선언하세요. 그러면 그 환경에서
205
227
  scheme·유니버설 링크 후보가 유지됩니다. 완전히 차단된 웹뷰나 OS 버전 요구사항은 이 옵션으로
@@ -234,6 +256,9 @@ const files = await LaunchKit.filepicker({ accept: ['image/*', '.pdf'], multiple
234
256
  const tree = await LaunchKit.filepicker({ directory: true })
235
257
  ```
236
258
 
259
+ 취소하면 `[]`로 resolve합니다. `cancel` 이벤트가 있는 엔진은 즉시 알려주고, 나머지는 값이 빈
260
+ 상태로 포커스가 돌아오는 것으로 판별하므로 조금 더 걸립니다.
261
+
237
262
  ## 지도
238
263
 
239
264
  `map()`은 검색어·좌표·경로 중 하나를 받아 현재 OS에 맞는 URL을 만듭니다
@@ -298,15 +323,27 @@ if (LaunchKit.utils.canOpenSetting) {
298
323
  - **`app()`이 반환하는 것은 경로이지 실행 보증이 아닙니다.** 감지는 OS별 타임아웃을 둔
299
324
  포커스·가시성 휴리스틱에 기반합니다. `AppOpenedBy`가 반환됐다는 건 그 후보가 시도되었고
300
325
  페이지가 백그라운드로 전환된 것처럼 보였다는 뜻이지, 확정된 실행 확인은 아닙니다.
301
- - **`utils.getTrackId` / `getProductId`는 비동기**이고 미리 호출해도 안전합니다. 제스처 전에
302
- id를 확보해두면 `app()`이 조회를 인라인으로 하지 않습니다.
326
+ - **`utils.getTrackId` / `getProductId`는 비동기**이고 1시간 캐시됩니다. 페이지 초기화
327
+ 호출해두면 `app()`이 id를 인라인으로 조회하지 않습니다. `app()`은 사용자 제스처 안에서
328
+ 실행돼야 하므로, id가 없으면 `await`으로 user activation을 잃는 대신 동기로 조회합니다.
329
+ `trackId` / `productId`를 직접 주면 조회 자체가 생략됩니다.
303
330
  - **스토어 id 조회는 외부 API에 의존합니다**(iTunes Lookup, Microsoft display catalog).
304
331
  1시간 캐시되며 실패는 예외가 아니라 `undefined`로 resolve됩니다.
305
332
  - **인앱 브라우저는 후보 단위로 걸러집니다.** 실행이 차단된 웹뷰(WeChat, QQ, Qzone, Baidu,
306
333
  그리고 파트너가 아닌 웨이보) 안에서는 커스텀 스킴·`intent://`·`market://` 같은 스토어 스킴이
307
334
  타임아웃을 소진하지 않고 미리 제외됩니다. 그래서 체인이 곧바로 `fallback` / 웹스토어로
308
335
  떨어집니다. 화이트리스트에 등록된 파트너 앱은 `assumeAllowedInApp: true`로 되돌립니다.
309
- - **`map()`은 후보에 OS별 타임아웃**을 사용합니다.
336
+ - **`allowIntent: false`는 안드로이드의 `intent://` 경로를 제외합니다.** 유도된 `intent://`에는
337
+ `S.browser_fallback_url`이 실립니다 — `fallback`에 URL을 주면 그 URL, 아니면 `packageName`이
338
+ 있을 때 스토어입니다. 그래서 그 이동은 브라우저가 첫 시도 안에서 직접 처리합니다. 앱이 없는
339
+ 사용자를 스토어로 보내는 가장 빠른 경로지만, 동시에 첫 시도가 항상 어딘가에 도달하므로 그
340
+ 뒤의 순수 스킴은 실행되지 않습니다. `allowIntent: false`로 이 경로를 아예 건너뛰면 순서를
341
+ 체인이 결정합니다 — 스킴, `fallback`, 그리고 허용한 스토어 순입니다. 직접 넘긴 `intent`도 같이
342
+ 제외되지만, 거기서 파싱한 scheme·`packageName`·`fallback`은 그대로 사용됩니다.
343
+ - **`map()`은 각 후보에 OS별 타임아웃**을 사용하고, `app()`처럼 성공한 경로를 반환합니다.
344
+ - **웹 폴백은 내비게이션 시점에 resolve합니다.** `fallback`이나 웹스토어 후보는 문서 자체를
345
+ 교체하므로 기다릴 앱 전환이 없습니다. 이동을 시작하는 즉시 resolve하며, 이동이 성공한 뒤에
346
+ reject되는 일이 없습니다.
310
347
 
311
348
  ## 브라우저 지원
312
349
 
package/README.md CHANGED
@@ -31,13 +31,20 @@ The bundle is self-contained (OS/locale detection is inlined) — no peer script
31
31
  | `LaunchKit.telephone(options?)` | `Promise<void>` | Open the dialer (`tel:`) |
32
32
  | `LaunchKit.message(options?)` | `Promise<void>` | Open the SMS composer (`sms:`) |
33
33
  | `LaunchKit.mail(options?)` | `Promise<void>` | Open the mail composer (`mailto:`) |
34
- | `LaunchKit.map(options?)` | `Promise<void>` | Open a map by query, coordinate, or directions; native app per-OS with a Google Maps web fallback |
34
+ | `LaunchKit.map(options?)` | `Promise<AppOpenedBy>` | Open a map by query, coordinate, or directions; native app per-OS with a Google Maps web fallback |
35
35
  | `LaunchKit.filepicker(options?)` | `Promise<File[]>` | Pick files or a directory (File System Access API, with input fallback) |
36
36
  | `LaunchKit.setting(type?)` | `Promise<void>` | Open a system-settings pane where supported |
37
37
  | `LaunchKit.utils` | object | `canOpenIntent` / `canOpenUniversal` / `canOpenSetting` getters, plus async `getTrackId` / `getProductId` |
38
38
 
39
39
  `AppOpenedBy` is one of: `"scheme"`, `"universal"`, `"intent"`, `"fallback"`, `"store"`.
40
40
 
41
+ `app()` and `map()` reject with a `LaunchError` — a real `Error` carrying two extra fields:
42
+
43
+ | Field | Type | Description |
44
+ | --- | --- | --- |
45
+ | `code` | `"unsupported-os" \| "no-candidates" \| "all-failed"` | Why it failed |
46
+ | `attempted` | `readonly LaunchAttempt[]` | `{ by, url, index, total }` for every candidate tried |
47
+
41
48
  ---
42
49
 
43
50
  ## ESM
@@ -113,10 +120,10 @@ flowchart TD
113
120
  A1{"intent given, but scheme /<br/>packageName / fallback missing?"}
114
121
  A1 -->|yes| A2["parseIntentURL:<br/>derive scheme · packageName · fallback"]
115
122
  A1 -->|no| A3
116
- A2 --> A3{"scheme given, but intent missing?"}
123
+ A2 --> A3{"allowIntent, and scheme given<br/>but intent missing?"}
117
124
  A3 -->|yes| A4["createIntentURL:<br/>scheme + packageName + fallback"]
118
125
  A3 -->|no| A5
119
- A4 --> A5["Priority list:<br/>intent (if canOpenIntent) ⭢ scheme (if canOpenScheme)<br/>⭢ fallback ⭢ app store (if canOpenScheme) ⭢ web store"]
126
+ A4 --> A5["Priority list:<br/>intent (if allowIntent and canOpenIntent) ⭢ scheme (if canOpenScheme)<br/>⭢ fallback ⭢ app store (if canOpenScheme) ⭢ web store"]
120
127
  end
121
128
 
122
129
  subgraph IOS["ios · resolveOptions"]
@@ -196,12 +203,27 @@ const openedBy = await LaunchKit.app({
196
203
  console.log(openedBy) // "universal" | "scheme" | "intent" | "fallback" | "store"
197
204
  ```
198
205
 
199
- Per-platform fields: Android accepts `intent` / `scheme` / `packageName` / `fallback`
200
- (scheme ⇄ intent are derived from each other); iOS accepts `universal` / `scheme` /
206
+ Per-platform fields: Android accepts `intent` / `scheme` / `packageName` / `fallback` /
207
+ `allowIntent` (scheme ⇄ intent are derived from each other); iOS accepts `universal` / `scheme` /
201
208
  `bundleId` / `trackId`; Windows accepts `scheme` / `packageFamilyName` / `productId`;
202
209
  macOS accepts `scheme` / `bundleId` / `trackId`. All accept `fallback`, `timeout`,
203
210
  `allowAppStore`, `allowWebStore`.
204
211
 
212
+ `onAttempt` fires synchronously before each candidate, which is where you drive the waiting
213
+ UI — there is otherwise nothing on screen for the length of the timeout.
214
+
215
+ ```js
216
+ await LaunchKit.app({
217
+ android: { scheme: 'myapp://profile/42', packageName: 'com.example.myapp', allowWebStore: true },
218
+ onAttempt({ by, index, total }) {
219
+ setStatus(by === 'store' ? 'Go to the store' : 'Opening the app… (' + (index + 1) + '/' + total + ')')
220
+ },
221
+ })
222
+ ```
223
+
224
+ A second call while one is still running returns the same promise, so a double tap cannot
225
+ start a second chain that navigates over the first.
226
+
205
227
  Android and iOS additionally accept
206
228
  `assumeAllowedInApp` — declare it `true` only when your app is whitelisted by a
207
229
  partner-gated in-app browser (e.g. Weibo) so scheme / universal-link candidates are
@@ -236,6 +258,9 @@ const files = await LaunchKit.filepicker({ accept: ['image/*', '.pdf'], multiple
236
258
  const tree = await LaunchKit.filepicker({ directory: true })
237
259
  ```
238
260
 
261
+ Cancelling resolves with `[]`. Engines with a `cancel` event report it immediately; the rest
262
+ are detected by regaining focus with an empty value, which takes a moment longer.
263
+
239
264
  ## Maps
240
265
 
241
266
  `map()` takes an intent — a search query, a coordinate, or a route — and builds the
@@ -301,8 +326,10 @@ if (LaunchKit.utils.canOpenSetting) {
301
326
  - **`app()` resolves with the route, not a guarantee of launch.** Detection relies on
302
327
  focus/visibility heuristics with per-OS timeouts; a resolved `AppOpenedBy` means that
303
328
  candidate was attempted and the page appeared to background, not a hard confirmation.
304
- - **`utils.getTrackId` / `getProductId` are async** and safe to call ahead of time
305
- resolving an id before the gesture keeps `app()` from looking it up inline.
329
+ - **`utils.getTrackId` / `getProductId` are async** and cached for an hour, so calling one
330
+ during page setup keeps `app()` from resolving the id inline. `app()` must run inside a
331
+ user gesture, so when the id is missing it looks it up synchronously rather than awaiting
332
+ and losing user activation. Passing `trackId` / `productId` directly skips it entirely.
306
333
  - **Store-id lookups depend on remote APIs** (iTunes Lookup, Microsoft display catalog)
307
334
  and are cached for one hour; failures resolve to `undefined` rather than throwing.
308
335
  - **In-app browsers are gated per candidate.** Inside webviews that block launches
@@ -311,7 +338,19 @@ if (LaunchKit.utils.canOpenSetting) {
311
338
  burning their timeouts, so the chain falls straight through to `fallback` / web store
312
339
  (the only routes that work there). Whitelisted partner apps opt back in with
313
340
  `assumeAllowedInApp: true`.
314
- - **`map()` uses the per-OS timeout** for each candidate.
341
+ - **`allowIntent: false` drops the Android `intent://` route.** A derived `intent://` carries
342
+ `S.browser_fallback_url` — your `fallback` when you gave a URL, otherwise the store when a
343
+ `packageName` is present — so the browser handles that hop itself during the first attempt.
344
+ That is the fastest route to the store for a user without the app, but it also means the
345
+ first attempt always lands somewhere, and the plain scheme behind it never runs. Set
346
+ `allowIntent: false` to skip the route entirely and let the chain decide the order: scheme,
347
+ then `fallback`, then the store you allowed. An explicit `intent` is gated too, though the
348
+ scheme, `packageName` and `fallback` parsed out of it are still used.
349
+ - **`map()` uses the per-OS timeout** for each candidate, and resolves with the route that
350
+ worked just like `app()`.
351
+ - **Web fallbacks resolve on navigation.** A `fallback` or web-store candidate replaces the
352
+ document, so there is no app switch to wait for — those resolve as soon as the navigation
353
+ is issued instead of risking a rejection after the move already succeeded.
315
354
 
316
355
  ## Browser support
317
356
 
package/dist/index.d.ts CHANGED
@@ -3,6 +3,17 @@ declare type URLCandidateOrFallback = URLCandidate | (() => any);
3
3
  declare type URLStringOrFallback = string | (() => any);
4
4
  declare type OpenPickerStartIn = 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos';
5
5
  declare type AppOpenedBy = 'scheme' | 'universal' | 'intent' | 'fallback' | 'store';
6
+ declare type LaunchErrorCode = 'unsupported-os' | 'no-candidates' | 'all-failed';
7
+ declare interface LaunchAttempt {
8
+ readonly by: AppOpenedBy;
9
+ readonly url: string;
10
+ readonly index: number;
11
+ readonly total: number;
12
+ }
13
+ declare interface LaunchError extends Error {
14
+ readonly code: LaunchErrorCode;
15
+ readonly attempted: readonly LaunchAttempt[];
16
+ }
6
17
  declare enum SettingType {
7
18
  General = "general",
8
19
  Network = "network",
@@ -25,6 +36,7 @@ declare interface AppInfo {
25
36
  declare interface AndroidAppInfo extends AppInfo {
26
37
  intent?: URLCandidate;
27
38
  packageName?: string;
39
+ allowIntent?: boolean;
28
40
  assumeAllowedInApp?: boolean;
29
41
  }
30
42
  declare interface IOSAppInfo extends AppInfo {
@@ -46,6 +58,7 @@ declare interface AppOpenOptions {
46
58
  ios?: IOSAppInfo;
47
59
  windows?: WindowsAppInfo;
48
60
  macos?: MacOSAppInfo;
61
+ onAttempt?: (attempt: LaunchAttempt) => void;
49
62
  }
50
63
  declare interface TelephoneOptions {
51
64
  to?: string;
@@ -78,6 +91,7 @@ declare interface MapOptions {
78
91
  };
79
92
  zoom?: number;
80
93
  fallback?: URLStringOrFallback;
94
+ onAttempt?: (attempt: LaunchAttempt) => void;
81
95
  }
82
96
  interface LaunchKitUtils {
83
97
  get canOpenIntent(): boolean;
@@ -96,11 +110,11 @@ interface LaunchKitInstance {
96
110
  telephone(options?: TelephoneOptions): Promise<void>;
97
111
  message(options?: MessageOptions): Promise<void>;
98
112
  mail(options?: MailOptions): Promise<void>;
99
- map(options?: MapOptions): Promise<void>;
113
+ map(options?: MapOptions): Promise<AppOpenedBy>;
100
114
  filepicker(options?: FilepickerOptions): Promise<File[]>;
101
115
  setting(type?: SettingType): Promise<void>;
102
116
  }
103
117
  declare const LaunchKit: LaunchKitInstance;
104
118
 
105
119
  export { SettingType, LaunchKit as default };
106
- export type { AndroidAppInfo, AppInfo, AppOpenOptions, AppOpenedBy, FilepickerOptions, IOSAppInfo, LaunchKitInstance, MacOSAppInfo, MailOptions, MapOptions, MessageOptions, OpenPickerStartIn, TelephoneOptions, URLCandidate, URLCandidateOrFallback, URLStringOrFallback, WindowsAppInfo };
120
+ export type { AndroidAppInfo, AppInfo, AppOpenOptions, AppOpenedBy, FilepickerOptions, IOSAppInfo, LaunchAttempt, LaunchError, LaunchErrorCode, LaunchKitInstance, MacOSAppInfo, MailOptions, MapOptions, MessageOptions, OpenPickerStartIn, TelephoneOptions, WindowsAppInfo };
@@ -2,7 +2,7 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var version$3 = "0.0.5";
5
+ var version$3 = "0.0.7";
6
6
  var packageJSON$3 = {
7
7
  version: version$3};
8
8
 
@@ -1898,6 +1898,7 @@ var GLOBAL = getGlobal$1();
1898
1898
  var NAVIGATOR = GLOBAL.navigator;
1899
1899
  var CLEANUP_INPUT_ELEMENT = null;
1900
1900
  var DEBUG = false;
1901
+ var APP_IN_FLIGHT = null;
1901
1902
  function createTypeGuard(tag) {
1902
1903
  return function (value) {
1903
1904
  return Object.prototype.toString.call(value) === '[object ' + tag + ']';
@@ -2114,7 +2115,8 @@ function resolveFocusEventConfig() {
2114
2115
  };
2115
2116
  }
2116
2117
  }
2117
- function openURL(tried, url, timeout) {
2118
+ function openURL(tried, url, timeout, terminal) {
2119
+ if (terminal === void 0) { terminal = false; }
2118
2120
  var config = resolveFocusEventConfig();
2119
2121
  var top = getTopmostWindow();
2120
2122
  var topDocument = top.document;
@@ -2152,6 +2154,8 @@ function openURL(tried, url, timeout) {
2152
2154
  }
2153
2155
  }
2154
2156
  }
2157
+ if (terminal)
2158
+ return;
2155
2159
  try {
2156
2160
  iframe = createHiddenElement('iframe');
2157
2161
  iframe.src = url;
@@ -2170,6 +2174,17 @@ function openURL(tried, url, timeout) {
2170
2174
  catch (_) {
2171
2175
  }
2172
2176
  }
2177
+ if (terminal) {
2178
+ return new Promise(function (resolve, reject) {
2179
+ try {
2180
+ open();
2181
+ resolve();
2182
+ }
2183
+ catch (_) {
2184
+ reject();
2185
+ }
2186
+ });
2187
+ }
2173
2188
  return new Promise(function (resolve, reject) {
2174
2189
  if (DEBUG)
2175
2190
  debugger;
@@ -2251,11 +2266,78 @@ function openURL(tried, url, timeout) {
2251
2266
  }
2252
2267
  });
2253
2268
  }
2269
+ var FUNCTION_FALLBACK_LABEL = '[function fallback]';
2270
+ var CANCEL_DELAY = 1000;
2271
+ function createLaunchError(code, message, attempted) {
2272
+ var error = new Error(message);
2273
+ var holder = error;
2274
+ holder['code'] = code;
2275
+ holder['attempted'] = attempted;
2276
+ return error;
2277
+ }
2278
+ function isWebURL(url) {
2279
+ return /^https?:\/\//i.test(url);
2280
+ }
2281
+ function isTerminalCandidate(by, url) {
2282
+ return (by === 'fallback' || by === 'store') && isWebURL(url);
2283
+ }
2284
+ function notifyAttempt(onAttempt, attempt) {
2285
+ if (typeof onAttempt !== 'function')
2286
+ return;
2287
+ try {
2288
+ onAttempt(attempt);
2289
+ }
2290
+ catch (_) {
2291
+ }
2292
+ }
2293
+ function describeAttempts(attempted) {
2294
+ var routes = [];
2295
+ for (var i = 0; i < attempted.length; i++)
2296
+ routes.push(attempted[i].by);
2297
+ return ' ' + attempted.length + ' candidate(s) attempted (' + joining(routes, undefined, ' \u2b62 ') + '). Read error.attempted for the URLs.';
2298
+ }
2299
+ function runCandidates(candidates, timeout, onAttempt, failure) {
2300
+ return new Promise(function (resolve, reject) {
2301
+ var attempted = [];
2302
+ var total = candidates.length;
2303
+ function step(index) {
2304
+ if (index >= total)
2305
+ return reject(createLaunchError('all-failed', failure + describeAttempts(attempted), attempted));
2306
+ var by = candidates[index][0];
2307
+ var url = candidates[index][1];
2308
+ var attempt = {
2309
+ by: by,
2310
+ url: typeof url === 'string' ? url : FUNCTION_FALLBACK_LABEL,
2311
+ index: index,
2312
+ total: total,
2313
+ };
2314
+ attempted.push(attempt);
2315
+ notifyAttempt(onAttempt, attempt);
2316
+ if (typeof url !== 'string') {
2317
+ try {
2318
+ url();
2319
+ }
2320
+ catch (_) {
2321
+ return step(index + 1);
2322
+ }
2323
+ return resolve(by);
2324
+ }
2325
+ openURL(index, url, timeout, isTerminalCandidate(by, url))
2326
+ .then(function () {
2327
+ resolve(by);
2328
+ })
2329
+ .catch(function () {
2330
+ step(index + 1);
2331
+ });
2332
+ }
2333
+ step(0);
2334
+ });
2335
+ }
2254
2336
  function resolveOptions(option) {
2255
2337
  var resolved = [];
2256
2338
  var os = PlatformKit.os.name;
2257
2339
  if (os === 'unknown')
2258
- throw new Error('Cannot resolve app open options: unsupported or undetected OS. (userAgent: "' + PlatformKit.userAgent + '")');
2340
+ throw createLaunchError('unsupported-os', 'Cannot resolve app open options: unsupported or undetected OS. (userAgent: "' + PlatformKit.userAgent + '")', []);
2259
2341
  var scheme;
2260
2342
  var fallback;
2261
2343
  var allowAppStore;
@@ -2265,6 +2347,7 @@ function resolveOptions(option) {
2265
2347
  var timeout;
2266
2348
  var intent;
2267
2349
  var packageName;
2350
+ var allowIntent = true;
2268
2351
  var bundleId;
2269
2352
  var trackId;
2270
2353
  var universal;
@@ -2274,6 +2357,7 @@ function resolveOptions(option) {
2274
2357
  case 'android':
2275
2358
  if (typeof option.android === 'undefined')
2276
2359
  return [[], 0];
2360
+ allowIntent = option.android.allowIntent !== false;
2277
2361
  intent = stripURL(option.android.intent);
2278
2362
  scheme = stripURL(option.android.scheme);
2279
2363
  fallback = stripURL(option.android.fallback);
@@ -2292,11 +2376,11 @@ function resolveOptions(option) {
2292
2376
  if (typeof parsed.fallback !== 'undefined' && typeof fallback === 'undefined')
2293
2377
  fallback = parsed.fallback;
2294
2378
  }
2295
- if (typeof scheme !== 'undefined' && typeof intent === 'undefined')
2379
+ if (allowIntent && typeof scheme !== 'undefined' && typeof intent === 'undefined')
2296
2380
  intent = createIntentURL(scheme, packageName, fallback);
2297
2381
  if (typeof timeout === 'undefined')
2298
2382
  timeout = ANDROID_DEFAULT_TIMEOUT;
2299
- if (typeof intent !== 'undefined' && canOpenIntent())
2383
+ if (allowIntent && typeof intent !== 'undefined' && canOpenIntent())
2300
2384
  resolved.push(['intent', intent]);
2301
2385
  if (typeof scheme !== 'undefined' && allowScheme)
2302
2386
  resolved.push(['scheme', scheme]);
@@ -2685,7 +2769,7 @@ function resolveFile(module) {
2685
2769
  done(true);
2686
2770
  else
2687
2771
  done(false);
2688
- }, 1000);
2772
+ }, CANCEL_DELAY);
2689
2773
  }
2690
2774
  function onvisibilitychange() {
2691
2775
  if (!isDocumentHidden())
@@ -2817,6 +2901,8 @@ var LaunchKit = {
2817
2901
  },
2818
2902
  app: function (options) {
2819
2903
  if (options === void 0) { options = {}; }
2904
+ if (APP_IN_FLIGHT !== null)
2905
+ return APP_IN_FLIGHT;
2820
2906
  var resolved;
2821
2907
  try {
2822
2908
  resolved = resolveOptions(options);
@@ -2827,39 +2913,15 @@ var LaunchKit = {
2827
2913
  var urls = resolved[0];
2828
2914
  var timeout = resolved[1];
2829
2915
  if (urls.length === 0)
2830
- return Promise.reject(new Error('No openable URL candidates were resolved for the current OS ("' + PlatformKit.os.name + '"). Provide at least one of: scheme, intent, universal, fallback, or store id for this platform.'));
2831
- return new Promise(function (resolve, reject) {
2832
- var tried = [];
2833
- function openURLSequential(index) {
2834
- if (index === void 0) { index = 0; }
2835
- if (index >= urls.length)
2836
- return reject(new Error('Failed to open the application using all available URLs.\n\n' + 'Attempted URLs:\n' + joining(tried, undefined, '\n↓\n')));
2837
- var entry = urls[index];
2838
- var by = entry[0];
2839
- var url = entry[1];
2840
- if (typeof url === 'string') {
2841
- tried[index] = url;
2842
- return openURL(index, url, timeout)
2843
- .then(function () {
2844
- resolve(by);
2845
- })
2846
- .catch(function () {
2847
- openURLSequential(index + 1);
2848
- });
2849
- }
2850
- else {
2851
- tried[index] = '[function fallback]';
2852
- try {
2853
- url();
2854
- }
2855
- catch (_) {
2856
- return openURLSequential(index + 1);
2857
- }
2858
- resolve(by);
2859
- }
2860
- }
2861
- return openURLSequential();
2862
- });
2916
+ return Promise.reject(createLaunchError('no-candidates', 'No openable URL candidates were resolved for the current OS ("' + PlatformKit.os.name + '"). Provide at least one of: scheme, intent, universal, fallback, or store id for this platform.', []));
2917
+ var attempt = runCandidates(urls, timeout, options.onAttempt, 'Failed to open the application.');
2918
+ function release() {
2919
+ if (APP_IN_FLIGHT === attempt)
2920
+ APP_IN_FLIGHT = null;
2921
+ }
2922
+ APP_IN_FLIGHT = attempt;
2923
+ attempt.then(release, release);
2924
+ return attempt;
2863
2925
  },
2864
2926
  telephone: function (options) {
2865
2927
  if (options === void 0) { options = {}; }
@@ -2909,7 +2971,7 @@ var LaunchKit = {
2909
2971
  map: function (options) {
2910
2972
  if (options === void 0) { options = {}; }
2911
2973
  var params = [];
2912
- var urls = [];
2974
+ var candidates = [];
2913
2975
  var url = '';
2914
2976
  switch (PlatformKit.os.name) {
2915
2977
  case 'android':
@@ -2927,7 +2989,7 @@ var LaunchKit = {
2927
2989
  params.push('z=' + options.zoom);
2928
2990
  if (params.length > 0)
2929
2991
  url = url + '?' + joining(params, undefined, '&');
2930
- urls.push(url);
2992
+ candidates.push(['scheme', url]);
2931
2993
  break;
2932
2994
  case 'ios':
2933
2995
  case 'macos':
@@ -2947,7 +3009,7 @@ var LaunchKit = {
2947
3009
  }
2948
3010
  if (typeof options.zoom !== 'undefined')
2949
3011
  params.push('z=' + options.zoom);
2950
- urls.push('maps://?' + joining(params, undefined, '&'));
3012
+ candidates.push(['scheme', 'maps://?' + joining(params, undefined, '&')]);
2951
3013
  break;
2952
3014
  case 'windows':
2953
3015
  if (typeof options.directions !== 'undefined') {
@@ -2963,10 +3025,10 @@ var LaunchKit = {
2963
3025
  }
2964
3026
  if (typeof options.zoom !== 'undefined')
2965
3027
  params.push('lvl=' + options.zoom);
2966
- urls.push('bingmaps:?' + joining(params, undefined, '&'));
3028
+ candidates.push(['scheme', 'bingmaps:?' + joining(params, undefined, '&')]);
2967
3029
  }
2968
3030
  if (typeof options.fallback !== 'undefined') {
2969
- urls.push(stripURL(options.fallback));
3031
+ candidates.push(['fallback', stripURL(options.fallback)]);
2970
3032
  }
2971
3033
  else {
2972
3034
  var params_1 = ['api=1'];
@@ -2974,7 +3036,7 @@ var LaunchKit = {
2974
3036
  params_1.push('destination=' + encodeMapValue(options.directions.destination));
2975
3037
  if (typeof options.directions.origin !== 'undefined')
2976
3038
  params_1.push('origin=' + encodeMapValue(options.directions.origin));
2977
- urls.push('https://www.google.com/maps/dir/?' + joining(params_1, undefined, '&'));
3039
+ candidates.push(['fallback', 'https://www.google.com/maps/dir/?' + joining(params_1, undefined, '&')]);
2978
3040
  }
2979
3041
  else {
2980
3042
  if (typeof options.coordinate !== 'undefined')
@@ -2983,34 +3045,10 @@ var LaunchKit = {
2983
3045
  params_1.push('query=' + escapeURIComponentString(options.query));
2984
3046
  if (typeof options.zoom !== 'undefined')
2985
3047
  params_1.push('zoom=' + options.zoom);
2986
- urls.push('https://www.google.com/maps/search/?' + joining(params_1, undefined, '&'));
3048
+ candidates.push(['fallback', 'https://www.google.com/maps/search/?' + joining(params_1, undefined, '&')]);
2987
3049
  }
2988
3050
  }
2989
- return new Promise(function (resolve, reject) {
2990
- var tried = [];
2991
- function openURLSequential(index) {
2992
- if (index === void 0) { index = 0; }
2993
- if (index >= urls.length)
2994
- return reject(new Error('Failed to open a map using all available URLs.\n\nAttempted URLs:\n' + joining(tried, undefined, '\n↓\n')));
2995
- var url = urls[index];
2996
- if (typeof url === 'string') {
2997
- tried[index] = url;
2998
- return openURL(index, url, getDefaultTimeout())
2999
- .then(function () {
3000
- resolve();
3001
- })
3002
- .catch(function () {
3003
- openURLSequential(index + 1);
3004
- });
3005
- }
3006
- else {
3007
- tried[index] = '[function fallback]';
3008
- url();
3009
- resolve();
3010
- }
3011
- }
3012
- return openURLSequential();
3013
- });
3051
+ return runCandidates(candidates, getDefaultTimeout(), options.onAttempt, 'Failed to open a map.');
3014
3052
  },
3015
3053
  filepicker: function (options) {
3016
3054
  if (options === void 0) { options = {}; }