web-launch-kit
v0.0.11
Published
TypeScript library for launching external apps and communication intents from the web — deep links / custom schemes, tel, sms, mailto, maps, a file picker, and system-settings deep links, with graceful store/web fallbacks.
Maintainers
Readme
English · 한국어
web-launch-kit
A TypeScript library for launching external apps and communication intents
from the web. Handles deep links / custom schemes, Android intent:// URLs, iOS
universal links, and App Store / web-store fallbacks — trying each candidate in
order until one succeeds. Also covers tel, sms, mailto, a file picker, and
system-settings deep links.
npm install web-launch-kitThe bundle is self-contained (OS/locale detection is inlined) — no peer scripts required.
API at a glance
LaunchKit is a singleton.
| Member | Signature | Description |
| --- | --- | --- |
| LaunchKit.version | string | The installed package version |
| LaunchKit.debug | boolean (get/set) | Pause on a debugger before each launch attempt; defaults to false |
| LaunchKit.SettingType | enum | Setting panes: General, Network, Display, Appearance, Accessibility, Battery, Datetime, Language, Accounts, Storage |
| LaunchKit.app(options?) | Promise<AppOpenedBy> | Launch an app via the best available route; resolves with which route opened it |
| LaunchKit.telephone(options?) | Promise<void> | Open the dialer (tel:) |
| LaunchKit.message(options?) | Promise<void> | Open the SMS composer (sms:) |
| LaunchKit.mail(options?) | Promise<void> | Open the mail composer (mailto:) |
| LaunchKit.map(options?) | Promise<AppOpenedBy> | Open a map by query, coordinate, or directions; native app per-OS with a Google Maps web fallback |
| LaunchKit.filepicker(options?) | Promise<File[]> | Pick files or a directory (File System Access API, with input fallback) |
| LaunchKit.setting(type?) | Promise<void> | Open a system-settings pane where supported |
| LaunchKit.escapeWebview(options?) | Promise<EscapeWebviewResult> | Reopen a URL (the current page by default) in the external browser, escaping an in-app webview |
| LaunchKit.utils | object | canOpenIntent / canOpenUniversal / canOpenSetting / canEscapeWebview / userActivation getters, plus async getTrackId(bundleId) / getProductId(packageFamilyName, language?, country?) |
AppOpenedBy is one of: "scheme", "universal", "intent", "fallback", "store".
app() and map() reject with a LaunchError — a real Error carrying two extra fields:
| Field | Type | Description |
| --- | --- | --- |
| code | "unsupported-os" \| "no-candidates" \| "all-failed" | Why it failed |
| attempted | readonly LaunchAttempt[] | { by, url, index, total } for every candidate tried |
Usage
// ESM
import LaunchKit from 'web-launch-kit'
// CommonJS — the bundle is built with `exports: "named"`
const { default: LaunchKit } = require('web-launch-kit')<!-- UMD: the global is a namespace object -->
<script src="https://unpkg.com/web-launch-kit/dist/launch-kit.umd.min.js"></script>
<script>
document.querySelector('#call').addEventListener('click', function () {
window.LaunchKit.default.telephone({to: '+821012345678'})
})
</script>Every option object has a named type, and SettingType is a value export.
import LaunchKit, {SettingType, type AppOpenOptions, type AppOpenedBy} from 'web-launch-kit'
const openedBy: AppOpenedBy = await LaunchKit.app({
ios: {universal: 'https://example.com/profile/42', bundleId: 'com.example.myapp'},
} satisfies AppOpenOptions)Launching an app
app() takes per-platform options and only acts on the block matching the current
OS. It builds an ordered list of candidates and tries each until one launches the
app, resolving with the route that worked.
flowchart TD
A([LaunchKit.app called]) --> B{Detect OS via PlatformKit}
B -->|unknown| E1([Reject: unsupported OS])
B -->|android| AND
B -->|ios| IOS
B -->|windows| WIN
B -->|macos| MAC
subgraph AND["android · resolveOptions"]
A1{"intent given, but scheme /<br/>packageName / fallback missing?"}
A1 -->|yes| A2["parseIntentURL:<br/>derive scheme · packageName · fallback"]
A1 -->|no| A3
A2 --> A3{"allowIntent, and scheme given<br/>but intent missing?"}
A3 -->|yes| A4["createIntentURL:<br/>scheme + packageName + fallback"]
A3 -->|no| A5
A4 --> A5["Priority list:<br/>intent (if allowIntent and canOpenIntent) ⭢ scheme (if canOpenScheme)<br/>⭢ fallback ⭢ app store (if canOpenScheme) ⭢ web store"]
end
subgraph IOS["ios · resolveOptions"]
I1{"bundleId given, but trackId missing?"}
I1 -->|yes| I2["getTrackId:<br/>iTunes lookup API (bundleId ⭢ trackId)"]
I1 -->|no| I3
I2 --> I3["Priority list:<br/>universal (if canOpenUniversal) ⭢ scheme (if canOpenScheme)<br/>⭢ fallback ⭢ app store (if canOpenScheme) ⭢ web store"]
end
subgraph WIN["windows · resolveOptions"]
W1{"packageFamilyName given,<br/>but productId missing?"}
W1 -->|yes| W2["getProductId:<br/>packageFamilyName ⭢ productId"]
W1 -->|no| W3
W2 --> W3["Priority list:<br/>scheme ⭢ fallback<br/>⭢ app store ⭢ web store (by productId)"]
end
subgraph MAC["macos · resolveOptions"]
M1{"bundleId given, but trackId missing?"}
M1 -->|yes| M2["getTrackId:<br/>iTunes lookup API (bundleId ⭢ trackId)"]
M1 -->|no| M3
M2 --> M3["Priority list:<br/>scheme ⭢ fallback<br/>⭢ app store ⭢ web store (by trackId)"]
end
subgraph LOC["web store · createWebStoreURL"]
L1{"country given?"}
L1 -->|yes| L3["Play · MS Store: hl / gl parameters<br/>App Store: /country/ path + l parameter"]
L1 -->|no| L2["Derive from language:<br/>region subtag ⭢ Intl.Locale.maximize()<br/>⭢ omit (store geolocation)"]
L2 --> L3
end
A5 -.->|"web store"| L1
I3 -.->|"web store"| L1
W3 -.->|"web store"| L1
M3 -.->|"web store"| L1
AND --> P
IOS --> P
WIN --> P
MAC --> P
P{"Any URL candidates?"} -->|no| E2([Reject: no openable URL candidates])
P -->|yes| Q{"Next candidate type?"}
Q -->|"function fallback"| R["Invoke fallback function"] --> G
Q -->|"URL string"| O["openURL(index, url, timeout)"]
O --> F{Opened?}
F -->|yes| G(["Resolve AppOpenedBy:<br/>'intent' · 'universal' · 'scheme' · 'fallback' · 'store'"])
F -->|no| H{"Candidates remaining?"}
H -->|yes| Q
H -->|no| E3(["Reject: all attempted URLs failed<br/>(error lists every tried URL)"])
subgraph openURL["openURL · app-switch detection"]
T0["Register blur + visibilitychange listeners"] --> T1{"Document focused?"}
T1 -->|no| T2["restoreFocus:<br/>window ⭢ body ⭢ hidden input"]
T1 -->|yes| T3
T2 --> T3{"Environment?"}
T3 -->|cordova| T4["InAppBrowser.open / window.open ('_system')"]
T3 -->|browser| T5{"userActivation active<br/>or first attempt?"}
T5 -->|yes| T6["top.location.href = url"]
T5 -->|no| T7["Hidden anchor + synthetic click"]
T6 --> T8["+ hidden iframe (removed after 500ms)"]
T7 --> T8
T4 --> T9
T8 --> T9{"blur / hidden fired?"}
T9 -->|"yes ⭢ wait focus"| T10([resolve: app opened])
T9 -->|"no, until timeout"| T11([reject: app not detected])
end
O -.->|delegates to| T0import LaunchKit from 'web-launch-kit'
const openedBy = await LaunchKit.app({
android: {
scheme: 'myapp://profile/42',
packageName: 'com.example.myapp',
allowAppStore: true,
allowWebStore: true,
language: 'ko', // web-store UI language
country: 'KR', // omit to derive from language (region subtag → Intl.Locale.maximize())
},
ios: {
universal: 'https://example.com/profile/42',
scheme: 'myapp://profile/42',
bundleId: 'com.example.myapp', // resolved to a trackId for the store fallback
allowAppStore: true,
},
})
console.log(openedBy) // "universal" | "scheme" | "intent" | "fallback" | "store"Per-platform fields: Android accepts intent / scheme / packageName / fallback /
allowIntent (scheme ⇄ intent are derived from each other); iOS accepts universal / scheme /
bundleId / trackId; Windows accepts scheme / packageFamilyName / productId;
macOS accepts scheme / bundleId / trackId. All accept fallback, timeout,
allowAppStore, allowWebStore, language, country.
language / country localize the web-store candidate only — hl / gl on Google
Play and the Microsoft Store, a country path segment plus l on the App Store. An omitted
country is derived from language (region subtag first, then Intl.Locale.maximize()),
and dropped entirely when neither yields one, since a wrong country lands on a "not
available in your region" page. Native store schemes are untouched: the OS localizes those.
onAttempt fires synchronously before each candidate, which is where you drive the waiting
UI — there is otherwise nothing on screen for the length of the timeout.
await LaunchKit.app({
android: { scheme: 'myapp://profile/42', packageName: 'com.example.myapp', allowWebStore: true },
onAttempt({ by, index, total }) {
setStatus(by === 'store' ? 'Go to the store' : 'Opening the app… (' + (index + 1) + '/' + total + ')')
},
})A second call while one is still running returns the same promise, so a double tap cannot start a second chain that navigates over the first.
Android and iOS additionally accept
assumeAllowedInApp — declare it true only when your app is whitelisted by a
partner-gated in-app browser (e.g. Weibo) so scheme / universal-link candidates are
kept there; it never bypasses hard-blocked webviews or OS version requirements.
Communication intents
import LaunchKit from 'web-launch-kit'
await LaunchKit.telephone({ to: '+821012345678' })
await LaunchKit.message({ to: '+821012345678', body: 'hello' })
await LaunchKit.mail({
to: ['[email protected]', '[email protected]'],
cc: '[email protected]',
subject: 'Hi',
body: 'from web-launch-kit',
})File picker
import LaunchKit from 'web-launch-kit'
// Files (uses showOpenFilePicker where available, falls back to <input type=file>)
const files = await LaunchKit.filepicker({ accept: ['image/*', '.pdf'], multiple: true })
// A directory (recursive; webkitRelativePath is populated)
const tree = await LaunchKit.filepicker({ directory: true })Cancelling resolves with []. Engines with a cancel event report it immediately; the rest
are detected by regaining focus with an empty value, which takes a moment longer.
Maps
map() takes an intent — a search query, a coordinate, or a route — and builds the
right URL for the current OS (maps:// on iOS/macOS, geo: on Android, bingmaps:
on Windows), falling back to Google Maps on the web.
import LaunchKit from 'web-launch-kit'
// Search for a place
await LaunchKit.map({ query: 'Seoul City Hall' })
// Show a coordinate with a labelled pin
await LaunchKit.map({ coordinate: [37.5665, 126.9780], label: 'Seoul City Hall', zoom: 15 })
// Directions (origin defaults to current location when omitted)
await LaunchKit.map({ directions: { destination: 'Seoul Station', origin: [37.5665, 126.9780] } })Provide exactly one of query / coordinate / directions; when several are set,
directions wins, then coordinate, then query. A label with coordinate
renders a named pin on iOS/macOS/Windows and is ignored on Android, where geo:
handlers other than Google Maps misread the labelled form as a search. Android's
geo: scheme also has no standard directions support, so routes use the Google Maps
fallback there, and on Windows the discontinued Maps app means bingmaps: usually
defers to the web fallback too.
Testing from the console
A call from the DevTools console has no focus and no user activation, so it fails and
app-switch detection has nothing to observe. debug pauses on a debugger before the
navigation; pressing resume hands both back, so the console behaves like a real tap.
LaunchKit.debug = true
await LaunchKit.app({ ios: { scheme: 'myapp://profile/42' } })Leave it off otherwise — it pauses once per candidate, in every launch method.
System settings
import LaunchKit from 'web-launch-kit'
if (LaunchKit.utils.canOpenSetting) {
await LaunchKit.setting(LaunchKit.SettingType.Network)
}Escaping in-app browsers
escapeWebview() reopens a URL — the current page by default — in the external
default browser, escaping the in-app webview the page is trapped in.
import LaunchKit from 'web-launch-kit'
if (LaunchKit.utils.canEscapeWebview) {
await LaunchKit.escapeWebview()
}
// Or send a specific URL out instead of the current page
await LaunchKit.escapeWebview({ url: 'https://example.com/checkout' })It resolves with which door it used and whether leaving was actually seen:
{ route: 'kakaotalk' | 'line' | 'intent' | 'x-safari', url: string, verified: boolean }verified is false for LINE, whose route navigates this very page to an
http(s) URL: an app that ignores the hint looks exactly like one that honors it,
so the escape was issued, not confirmed.
The route depends on the environment. KakaoTalk exposes a dedicated
kakaotalk://web/openExternal scheme and LINE honors the documented
openExternalBrowser=1 query parameter — both work on Android and iOS. Other
webviews fall back to the OS route: a Chrome-pinned intent:// URL on Android,
and the undocumented x-safari- scheme on iOS — which works on iOS 15 and 17+
but not 16, always opens Safari regardless of the default browser, and is
blocked inside Meta's webviews (Facebook, Instagram).
The Android intent carries no S.browser_fallback_url. The only URL there
would be to fall back to is the page issuing the escape, and a webview that
cannot open the intent would then reload it and escape again, without end.
Without the key a failed intent leaves the page where it stands and the promise
rejects with escape-failed, which is the caller's cue to open the app in place.
For the same reason an escape that has just happened is not repeated:
await LaunchKit.escapeWebview({ cooldown: 5000 }) // default 3000, 0 disablesescape-cooldown is raised only for the exact shape of the loop — the same
URL, within the window, with the engine positively reporting that no gesture
has been spent. Everything else passes: a different URL, an engine that does
not report activation, and above all a reader who presses something, since that
press carries a transient activation. A page reopened by an escape that landed
back on itself is refused; a person asking again is not.
Only http(s) URLs can be reopened. Outside a webview, or where no route is
available (hard-blocked webviews, iOS 16, desktop), the promise rejects — gate
the button on utils.canEscapeWebview.
Notes
- Deep links are most reliable from a real user gesture. Call
app()from a click handler where you can. Activation is read as a hint about the route, never as a verdict on whether to try —app()always attempts. A top-level navigation reaches the OS with no activation at all; only the anchor and iframe routes are refused without a gesture.utils.userActivation('active' | 'sticky' | 'none' | 'unknown') exposes the reading. app()resolves with the route, not a guarantee of launch. Detection is a focus/visibility heuristic on a per-OS timeout: a resolvedAppOpenedBymeans that candidate was attempted and the page appeared to background.- Pre-warm store ids.
utils.getTrackId/getProductIdhit remote APIs (iTunes Lookup, Microsoft display catalog), cache for an hour, and resolve toundefinedon failure. Call one during page setup, or passtrackId/productIddirectly — insideapp()a missing id is looked up synchronously rather than awaiting and losing the gesture.getProductIdtakes the same optionallanguage/countryyou giveapp(), since a lookup can succeed in one market and not another. - In-app browsers are gated per candidate. Inside webviews that block launches (WeChat,
QQ, Qzone, Baidu; Weibo unless partnered), dead candidates are skipped up front instead
of burning their timeouts, so the chain falls straight to
fallback/ web store. Whitelisted partner apps opt back in withassumeAllowedInApp: true. allowIntent: falsedrops the Androidintent://route. A derived intent carriesS.browser_fallback_url, so the browser handles that hop itself on the first attempt: fastest route to the store, but the first attempt always lands somewhere and the plain scheme behind it never runs. Turning it off lets the chain decide the order instead: scheme, thenfallback, then the store. An explicitintentis gated too, though the scheme,packageNameandfallbackparsed out of it are still used.- Navigating candidates resolve on navigation. A
fallbackor web-store candidate replaces the document, so there is no app switch to wait for. The same goes for a candidate that turns into an unplanned navigation — a universal link whose app is not installed, an intent whose browser fallback fires:pagehideresolves it, and since an app switch fireshiddenwithoutpagehide, the two stay distinguishable.
Browser support
Runs down to IE 9 — which has no Promise, so supply a polyfill there: every method
returns one.
