
Company News
Socket Joins New OpenJS Program to Fund Node.js Security Work
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.
@limrun/device-install
Advanced tools
Browser WebUSB pairing and installation primitives for iOS devices.
Install a signed iOS app onto a physical iPhone that's plugged into the user's own computer — straight from the browser, no Mac and no Xcode on their machine. The browser talks to the iPhone over WebUSB; Limrun's registry runs the native pairing and install on its side and relays the USB traffic over a WebSocket.
@limrun/device-install ships the browser primitives and React hooks; your app owns the UI.
There is no prebuilt wizard, so you decide how much of the flow to expose and
how the steps look in your product.
The flow:
SigningSecretStore
(@limrun/apple-auth).iphoneos IPA from your backend with @limrun/api,
uploading it to Limrun asset storage.Only steps 1, 2, and 4 happen in the browser. The build is a backend concern:
your UI calls your backend, and your backend drives the Xcode sandbox with
@limrun/api using the stored signing secrets.
Want to see it working first? A runnable pair-and-install reference with scoped-token minting lives in
examples/device-install; a minimal single-page version lives insrc/device-install/demo. For the Apple sign-in + secret store + backend build flow, seeexamples/publish-to-stores.
https:// or localhost.npm install @limrun/device-install @limrun/apple-auth
The browser side is split across two subpath entry points:
| Import | Provides |
|---|---|
@limrun/device-install + /react | WebUSB device selection, pairing, install relay, pair-record storage (useDeviceInstallRelay). |
@limrun/apple-auth + /react | Apple ID login (SRP + 2FA), Apple Developer Portal calls, and SigningSecretStore-based credential helpers (useAppleIDLogin, ensureAppleCertificateSecret, …). |
Pairing, install, and the Apple relay all run on Limrun's registry
(https://registry.limrun.com), a shared service that authenticates a Limrun
token. Your org API key works, but it must never reach the browser. Instead,
your backend mints a scoped token with @limrun/api and hands that to
the browser, which connects to the registry directly:
// Your backend. The API key stays here.
import Limrun from '@limrun/api';
const limrun = new Limrun({ apiKey: process.env['LIM_API_KEY'] });
const session = await limrun.scopedTokens.create({
scopes: ['device:*:install', `asset:${assetId}:read`],
// ttlSeconds: 3600 is the default; maximum is 14400.
});
// Return session.token to the browser.
// Your frontend. Point the hook straight at the registry.
const install = useDeviceInstallRelay({
registryApiUrl: 'https://registry.limrun.com',
token: session.token,
});
Scopes have the form <resource>:<id|*>:<action> — device:*:install opens
the device relay, asset:<id>:read (or asset:*:read) lets installs read
those assets, and applerelay:*:connect opens the Apple relay for the
@limrun/apple-auth flow. Scoped tokens are verified offline, expire on
their own (they cannot be revoked, so keep TTLs short), and can only install
from assets — the registry rejects { url } sources for them.
examples/device-install/backend is a complete minting backend you can copy.
useDeviceInstallRelay drives the WebUSB side. requestUSBAccess opens the
browser's device picker; pairBrowser runs the pairing handshake through the
relay and stores the resulting pair record in the browser's IndexedDB, so the
user only taps Trust once per device.
import { useDeviceInstallRelay } from '@limrun/device-install/react';
const install = useDeviceInstallRelay({
registryApiUrl,
token,
organizationId,
log: (m, d) => console.log(m, d),
});
// 1. Pick the iPhone (shows Chrome's WebUSB chooser).
await install.requestUSBAccess();
// install.device?.hello.serialNumber is the UDID.
// 2. Pair (user unlocks + taps Trust). Stored for next time.
await install.pairBrowser();
// install.hasPairRecord === true
| Field / method | Use |
|---|---|
requestUSBAccess() | Open the WebUSB picker and select the iPhone. |
pairBrowser() | Pair through the relay; persists the pair record. |
startInstallation(source) | Install an IPA onto the paired device; source is { assetName }, { assetId }, or { url }. |
device | Selected device; device.hello.serialNumber is the UDID. |
hasPairRecord / canPair / canInstall | Gating flags for your buttons. |
busyAction | 'usb', 'pair', or 'install' while an operation is in flight. |
error | Last error message, if any. |
Pairing is independent of the build — you can pair before signing or building.
Only startInstallation() needs both a stored pair record and an artifact to
install.
A real-device install must be signed. The @limrun/apple-auth credential helpers
run the Apple ID login in the browser (the password never leaves it — only SRP
proof material does) and persist the resulting material into a
SigningSecretStore you provide: Limrun's org secret store
(createLimrunSecretStore), the browser's IndexedDB
(createBrowserSecretStore), or your own implementation of the interface —
your backend's database, a KMS, anything.
import { useAppleIDLogin } from '@limrun/apple-auth/react';
import {
createAppleProfile,
ensureAppleCertificateSecret,
registerAppleDevice,
saveAppleProfileSecret,
stringField,
type SigningSecretStore,
} from '@limrun/apple-auth';
const appleLogin = useAppleIDLogin({ registryApiUrl, token, organizationId });
const session = await appleLogin.startLogin({ accountName, password });
if (session?.requiresTwoFactor) await appleLogin.submitTwoFactorCode(code);
const relay = appleLogin.session!.relay;
// Reuses the stored certificate when it's still on the team, mints one
// otherwise (Apple caps development certs at 2 and never returns private keys).
const certificate = await ensureAppleCertificateSecret({
relay,
teamId,
secretStore, // your SigningSecretStore
certificateKind: 'development',
});
// Register the paired iPhone and mint a profile covering it, then persist
// the profile bytes as a secret.
await registerAppleDevice({ relay, teamId, deviceUDID, name: 'My iPhone' });
const profile = await createAppleProfile({
relay,
teamId,
profileKind: 'development',
bundleId,
appIdId,
certificateIds: [certificate.certificateId],
deviceIds: [appleDeviceId],
name: `MyApp ${Date.now()}`,
});
const profileSecret = await saveAppleProfileSecret({
relay,
teamId,
profileId: stringField(profile, 'provisioningProfileId')!,
secretStore,
});
These are Apple's rules, not Limrun's, and each one is a common dead end:
| Rule | Consequence if ignored |
|---|---|
| A cert is useless without its private key. | You can only sign with a cert whose key is in your secret store. Apple never returns private keys. |
| Max 2 development certs per team. | Creating one per build fails with "you already have a current Development certificate". ensureAppleCertificateSecret reuses when it can. |
| A revoked cert still appears in lists. | Signing with it builds fine but the device rejects install with ApplicationVerificationFailed. Treat revoked certs as unusable. |
The team id may live in providerId/publicProviderId. | Reading only teamId leaves the flow stuck after sign-in. |
| Profiles are device-scoped and immutable. | To authorize a new device you regenerate the profile (see Adding a device). |
Builds run on a Limrun Xcode sandbox, driven from your backend with
@limrun/api. Read the certificate and profile out of your secret store and
pass them as the signing config; upload: { assetName } mints the asset and
uploads the signed IPA to it when the build succeeds.
import Limrun from '@limrun/api';
const lim = new Limrun({ apiKey: process.env['LIM_API_KEY'] });
const instance = await lim.xcodeInstances.create({ wait: true, reuseIfExists: true });
const xcode = await lim.xcodeInstances.createClient({ instance });
// Sync your project first: await xcode.sync('path/to/project') or `lim xcode sync .`
const { exitCode } = await xcode.xcodebuild(
{ sdk: 'iphoneos' },
{
signing: {
certificateP12Base64: certificateSecret.data.certificateP12Base64,
certificatePassword: certificateSecret.data.certificatePassword,
provisioningProfileBase64: profileSecret.data.provisioningProfileBase64,
},
upload: { assetName: 'my-app.ipa' },
},
);
Your UI just calls your backend endpoint and waits for it to report success — no build client or log streaming is needed in the browser.
Once the artifact is in asset storage and the device is paired, install:
await install.startInstallation({ assetName: 'my-app.ipa' });
Progress streams through your log callback. The relay surfaces the device's
real reason on failure (for example
Install error: ApplicationVerificationFailed — The identity used to sign the executable is no longer valid.),
so render the log — it's the fastest way to diagnose a signing/profile mismatch.
The demo is a standalone Vite page covering pair → install:
cd packages/ui
npm install
npx vite src/device-install/demo
Open the printed https://localhost URL in Chrome or Edge, paste a registry URL
and token (a scoped token or, for local experiments, an API key), then pair
and install an asset by name.
examples/device-install is the same
flow with a backend that mints scoped tokens so the API key stays
server-side.
Provisioning profiles can't be edited in place. To authorize a new iPhone, register the UDID, then regenerate the profile with it included:
registerAppleDevice({ deviceUDID }) — add the UDID to the team.createAppleProfile({ ..., deviceIds: [...existing, newDeviceId] }) — mint a
fresh profile (unique name).saveAppleProfileSecret(...) — download and persist the new bytes.This needs an Apple Developer session. A profile that's missing the device can't be amended locally — regenerate it through the Apple flow.
.p12, .mobileprovision, passwords) live wherever
your SigningSecretStore puts them — Limrun's org secret store, the
browser, or your own backend.limrun.scopedTokens.create) instead. Serve the UI over HTTPS in
production.| Symptom | Cause and fix |
|---|---|
WebUSB is not available | Use Chrome/Edge on HTTPS or localhost. Safari/Firefox are unsupported. |
Unable to claim interface / "operation in progress" | Unplug/replug the iPhone, unlock it, close other WebUSB tabs, retry. Make sure you're on a current @limrun/device-install. |
| First transfer fails with "endpoint is not part of a claimed and selected alternate interface" | Update @limrun/device-install — recent versions explicitly select the usbmux alternate (incl. alt 0). |
| Pairing seems to hang | The user must unlock the iPhone and tap Trust; the first attempt can fail if the prompt isn't accepted in time. Retry. |
no synced folder found; call /sync first | Sync your project into the sandbox before building (xcode.sync(...) / lim xcode sync .). |
Install fails, log shows ApplicationVerificationFailed | Signed with a revoked/invalid cert, or the device isn't in the profile, or the bundle ID isn't covered. zsign doesn't rewrite the bundle ID — it must match (or be wildcarded by) the profile. |
no current certificates matching the provided certificate IDs | You passed a certRequestId, or the cert was revoked. ensureAppleCertificateSecret resolves the canonical certificateId for you. |
You already have a current Development certificate… | Apple's cert cap. Reuse the stored cert (ensureAppleCertificateSecret does), or revoke one at developer.apple.com. |
Multiple profiles found with the name '…' | Give created profiles a unique name. |
| Apple flow does nothing after sign-in | Resolve the team id across teamId/providerId/publicProviderId. |
Provision an Xcode sandbox, sync source, and produce signed IPAs. Drive a simulator for the same app: taps, screenshots, logs, recordings. Build every PR and post a live preview link from a GitHub workflow.
FAQs
Browser WebUSB pairing and installation primitives for iOS devices.
The npm package @limrun/device-install receives a total of 4,192 weekly downloads. As such, @limrun/device-install popularity was classified as popular.
We found that @limrun/device-install demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 4 open source maintainers collaborating on the project.

Company News
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.

Security News
Two compromised GitHub Actions were re-enabled with malicious tags intact, exposing thousands of downstream repositories to Mini Shai-Hulud.

Research
/Security News
A malicious Firefox extension fetches its payload after installation to evade detection, steal Google session cookies, and automate account takeover.