Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,23 @@ jobs:
- run: npx tsc --noEmit
- run: npm run build

mobile:
name: Mobile — typecheck & test
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend/mobile
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/mobile/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test

lighthouse-ci:
name: Wallet PWA — Lighthouse performance budget
runs-on: ubuntu-latest
Expand Down
72 changes: 69 additions & 3 deletions frontend/mobile/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Veil Mobile

Expo (expo-router + TypeScript) mobile app for Veil. This is the bootable shell —
a single placeholder home route (`app/index.tsx`) proving the toolchain runs. No
wallet logic or SDK is wired up yet.
Expo (expo-router + TypeScript) mobile app for Veil. The screens are still
placeholders — no wallet SDK is wired up yet — but the routes exist so deep
links have somewhere to land.

## Getting started

Expand Down Expand Up @@ -224,3 +224,69 @@ Passkeys need platform association before they resolve on a device: an
`associatedDomains` entry (`webcredentials:<rp-id>`) for iOS and a matching
`assetlinks.json` on the domain for Android. `EXPO_PUBLIC_PASSKEY_RP_ID` must be
the same relying-party id the wallet's passkey was registered against.

## Deep linking

Three URL families open the app, and all three resolve to the same in-app routes:

| Incoming URL | Resolves to |
| --- | --- |
| `veil://pay?to=G…&amount=10` | `/pay` → `/send`, prefilled |
| `https://app.veil.xyz/receive` | `/receive` |
| `web+stellar:pay?destination=G…&amount=10` | `/pay`, raw URI preserved as `uri` |
| anything else | `/` |

`app/+native-intent.ts` is called by expo-router for every inbound link, on a
cold start (`initial: true`) and on a warm resume (`initial: false`) alike, which
is what makes the two behave identically. It delegates to `resolveDeepLink()` in
`lib/deepLinks.ts`.

Inbound links are untrusted — any app, web page, or QR code can send one — so the
resolver matches a fixed allowlist of routes and copies only the query parameters
each route declares. Foreign hosts, unknown schemes, unknown paths, and
over-long URLs all fall back to `/` instead of navigating.

Full SEP-7 validation (address checksums, amount ranges, hostile callbacks) is
the job of the handler in backlog #38. `sdk/src/sep7.ts` already implements it
for the web wallet; the raw URI is forwarded to `/pay` as `uri` so that handler
can parse the original request unmodified.

### Testing links locally

The schemes are only registered in a dev-client or standalone build — deep links
do not reach the app through Expo Go.

```bash
# Android (emulator or device)
adb shell am start -W -a android.intent.action.VIEW \
-d "veil://pay?to=GABC&amount=10" xyz.veil.wallet
adb shell am start -W -a android.intent.action.VIEW \
-d "https://app.veil.xyz/receive" xyz.veil.wallet

# iOS simulator
xcrun simctl openurl booted "veil://pay?to=GABC&amount=10"
xcrun simctl openurl booted "https://app.veil.xyz/receive"
```

Run each command twice: once with the app force-quit (cold start) and once with
it backgrounded (warm resume). Both must land on the same screen.

### Universal / app link setup

Two verification files are served by the wallet web app from
`frontend/wallet/public/.well-known/`. Both currently carry placeholders that
must be replaced before a store build, or the platforms will silently keep
opening links in the browser:

- `apple-app-site-association` — replace `APPLE_TEAM_ID` with the Apple Developer
Team ID that signs `xyz.veil.wallet`. The file must be served over HTTPS as
`application/json`, with no redirect and no `.json` extension.
- `assetlinks.json` — replace `ANDROID_RELEASE_CERT_SHA256_FINGERPRINT` with the
SHA-256 fingerprint of the release signing certificate
(`keytool -list -v -keystore <keystore> -alias <alias>`). Add the Play App
Signing fingerprint too if the app is distributed through Google Play.

`frontend/wallet/next.config.js` pins the `Content-Type` on both files. After
deploying, verify with Apple's CDN
(`https://app-site-association.cdn-apple.com/a/v1/app.veil.xyz`) and Google's
[Digital Asset Links API](https://developers.google.com/digital-asset-links/tools/generator).
105 changes: 105 additions & 0 deletions frontend/mobile/app.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { ExpoConfig } from 'expo/config';

/**
* Expo config, replacing `app.json` so the deep-linking surface can carry the
* reasoning behind it.
*
* The values below are duplicated from `lib/deepLinks.ts` rather than imported:
* Expo transpiles this file on its own and then `require`s it, so a relative
* import of a sibling TypeScript module fails to resolve at config-load time.
* `lib/__tests__/appConfig.test.ts` asserts the two stay in agreement — the
* config and the resolver drifting apart is exactly how deep links break
* silently.
*
* Universal links additionally require the two verification files served by the
* wallet web app from `frontend/wallet/public/.well-known/`; see
* `frontend/mobile/README.md` for the values to fill in before a store build.
*/

/** Custom URL scheme registered by the app. Mirrors `DEEP_LINK_SCHEME`. */
const DEEP_LINK_SCHEME = 'veil';

/** Hosts claimed as universal / app links. Mirrors `ASSOCIATED_DOMAINS`. */
const ASSOCIATED_DOMAINS = ['app.veil.xyz'];

/** SEP-7 URI scheme, without the trailing colon. Mirrors `SEP7_SCHEME`. */
const SEP7_SCHEME = 'web+stellar';

const BUNDLE_IDENTIFIER = 'xyz.veil.wallet';

/**
* Paths claimed as universal / app links. Kept narrow on purpose: every path
* listed here stops opening in the browser once the app is installed, so the
* marketing site and docs must keep working as web pages.
*/
const LINKED_PATHS = ['pay', 'send', 'receive', 'create-wallet'];

const config: ExpoConfig = {
name: 'Veil',
slug: 'veil-mobile',
version: '0.1.0',
orientation: 'portrait',
icon: './assets/images/icon.png',
// `veil://` is the app's own scheme; `web+stellar:` is claimed so SEP-7
// payment requests (backlog #38) open here too. Expo turns both into
// CFBundleURLTypes on iOS and BROWSABLE intent filters on Android at prebuild.
scheme: [DEEP_LINK_SCHEME, SEP7_SCHEME],
userInterfaceStyle: 'automatic',
ios: {
icon: './assets/expo.icon',
bundleIdentifier: BUNDLE_IDENTIFIER,
associatedDomains: ASSOCIATED_DOMAINS.map((domain) => `applinks:${domain}`),
},
android: {
package: BUNDLE_IDENTIFIER,
adaptiveIcon: {
backgroundColor: '#E6F4FE',
foregroundImage: './assets/images/android-icon-foreground.png',
backgroundImage: './assets/images/android-icon-background.png',
monochromeImage: './assets/images/android-icon-monochrome.png',
},
predictiveBackGestureEnabled: false,
intentFilters: [
// Verified app links. `autoVerify` makes Android fetch
// https://app.veil.xyz/.well-known/assetlinks.json at install time; if the
// fingerprint there does not match the installed build, links keep opening
// in the browser rather than failing outright.
{
action: 'VIEW',
autoVerify: true,
category: ['BROWSABLE', 'DEFAULT'],
data: ASSOCIATED_DOMAINS.flatMap((host) =>
LINKED_PATHS.map((path) => ({ scheme: 'https', host, pathPrefix: `/${path}` })),
),
},
],
},
web: {
output: 'static',
favicon: './assets/images/favicon.png',
},
plugins: [
'expo-router',
[
'expo-splash-screen',
{
backgroundColor: '#208AEF',
image: './assets/images/splash-icon.png',
imageWidth: 76,
},
],
'expo-secure-store',
[
'expo-camera',
{
cameraPermission: 'Veil uses the camera to scan WalletConnect QR codes.',
},
],
],
experiments: {
typedRoutes: true,
reactCompiler: true,
},
};

export default config;
49 changes: 0 additions & 49 deletions frontend/mobile/app.json

This file was deleted.

45 changes: 43 additions & 2 deletions frontend/mobile/app/(tabs)/send.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
import { ScreenScaffold, ComingSoonBadge, NavRow, colors } from '@/components/ScreenScaffold';
import { useLocalSearchParams } from 'expo-router';
import { Text, View, StyleSheet } from 'react-native';

/**
* Send tab.
*
* This route owns the prefill contract deep links depend on — `to`, `amount`,
* `asset` and `memo` arrive as query parameters from `veil://send`,
* `https://app.veil.xyz/send`, or a SEP-7 request forwarded by `/pay`. The
* fields are read-only placeholders until the send UI lands, but the values
* are surfaced here so a deep link is visibly carried through end to end.
*/
export default function SendTab() {
const params = useLocalSearchParams<{
to?: string;
amount?: string;
asset?: string;
memo?: string;
}>();

const recipient = firstValue(params.to);
const amount = firstValue(params.amount);
const asset = firstValue(params.asset) || 'XLM';
const memo = firstValue(params.memo);

return (
<ScreenScaffold
hideBack
Expand All @@ -11,13 +33,26 @@ export default function SendTab() {
>
<View style={styles.card}>
<Text style={styles.cardLabel}>Recipient</Text>
<Text style={styles.cardPlaceholder}>G… or C… address, contact, or @handle</Text>
<Text testID="send-recipient" style={styles.cardPlaceholder} numberOfLines={1}>
{recipient || 'G… or C… address, contact, or @handle'}
</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardLabel}>Amount</Text>
<Text style={styles.cardPlaceholder}>0.00</Text>
<Text testID="send-amount" style={styles.cardPlaceholder}>
{amount ? `${amount} ${asset}` : '0.00'}
</Text>
</View>

{memo ? (
<View style={styles.card}>
<Text style={styles.cardLabel}>Memo</Text>
<Text testID="send-memo" style={styles.cardPlaceholder}>
{memo}
</Text>
</View>
) : null}

<View style={styles.stackLinks}>
<NavRow href="/contacts" label="Contacts" hint="Saved recipients" />
</View>
Expand All @@ -27,6 +62,12 @@ export default function SendTab() {
);
}

/** expo-router yields `string | string[]` for a repeated query key. */
function firstValue(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? '';
return value ?? '';
}

const styles = StyleSheet.create({
card: {
padding: 18,
Expand Down
25 changes: 25 additions & 0 deletions frontend/mobile/app/+native-intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { FALLBACK_ROUTE, resolveDeepLink } from '../lib/deepLinks';

/**
* expo-router calls this for every inbound deep link before it builds any
* navigation state — on a cold start (`initial: true`) and on a warm resume
* (`initial: false`) alike. Returning a normalised in-app path here is what
* makes both paths land on the same screen.
*
* It runs during launch, so it must never throw and must never block:
* {@link resolveDeepLink} is pure, synchronous, and falls back to the home
* route instead of raising.
*/
export function redirectSystemPath({
path,
initial,
}: {
path: string;
initial: boolean;
}): string {
try {
return resolveDeepLink(path, { initial });
} catch {
return FALLBACK_ROUTE;
}
}
Loading
Loading