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
4 changes: 4 additions & 0 deletions apps/apple/CascadeMac/Cascade.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:cascade.stephens.page</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
Expand Down
6 changes: 4 additions & 2 deletions apps/apple/CascadeiOS/Cascade.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- iOS apps are sandboxed by default; this file is here for parity with
the macOS target and to make the network-absent intent explicit. -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:cascade.stephens.page</string>
</array>
</dict>
</plist>
15 changes: 15 additions & 0 deletions apps/apple/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ targets:
# Outbound network for the optional account/listening-time sync
# (magic-link sign-in + G-Counter sync to cascade-sync-server).
com.apple.security.network.client: true
# Universal Links: lets the magic-link email (https://cascade.stephens.page
# /auth?token=…) open the app straight into completeSignIn() instead of a
# browser. Requires the matching AASA file hosted at
# https://cascade.stephens.page/.well-known/apple-app-site-association and
# a provisioning profile with the Associated Domains capability (paid
# Apple Developer Program — not available under free personal-team signing).
com.apple.developer.associated-domains:
- applinks:cascade.stephens.page
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: page.stephens.cascade
Expand Down Expand Up @@ -107,6 +115,13 @@ targets:
NSHumanReadableCopyright: "© 2026 Jacob Stephens"
entitlements:
path: CascadeiOS/Cascade.entitlements
properties:
# Universal Links — see the macOS target above. iOS reaches the network
# without a sandbox entitlement, so associated-domains is the only key.
# (Must live here, not in the .entitlements file: XcodeGen regenerates
# that file on every run and would clobber a hand-written key — see #1.)
com.apple.developer.associated-domains:
- applinks:cascade.stephens.page
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: page.stephens.cascade
Expand Down
16 changes: 16 additions & 0 deletions apps/web/public/.well-known/apple-app-site-association
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"applinks": {
"details": [
{
"appIDs": ["G38J85UN6P.page.stephens.cascade"],
"components": [
{
"/": "/auth",
"?": { "token": "?*" },
"comment": "Magic-link sign-in: open Cascade instead of the website."
}
]
}
]
}
}
115 changes: 115 additions & 0 deletions apps/web/public/auth/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b1a24" />
<title>Cascade — Sign in</title>
<meta name="description" content="Finish signing in to Cascade." />
<meta name="robots" content="noindex" />
<style>
:root {
--bg: #050b10;
--bg-deep: #02060a;
--ink: #e6f1f6;
--ink-dim: #8aa3b0;
--accent: #6ec9e2;
--card: #0b1a24;
--line: #16313f;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--ink);
background: radial-gradient(circle at 50% 0%, var(--bg), var(--bg-deep));
}
.card {
width: 100%;
max-width: 460px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
padding: 32px 28px;
text-align: center;
}
h1 { font-size: 1.4rem; margin: 0 0 8px; }
p { color: var(--ink-dim); margin: 0 0 20px; }
.token {
display: flex;
gap: 8px;
align-items: stretch;
}
code {
flex: 1;
min-width: 0;
padding: 12px 14px;
background: #02060a;
border: 1px solid var(--line);
border-radius: 10px;
font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--accent);
overflow: auto;
white-space: nowrap;
text-align: left;
}
button {
padding: 0 16px;
border: 0;
border-radius: 10px;
background: var(--accent);
color: #042029;
font-weight: 600;
cursor: pointer;
}
button:active { transform: translateY(1px); }
.hint { font-size: 0.85rem; margin-top: 18px; }
.err { color: #e2786e; }
</style>
</head>
<body>
<main class="card">
<h1>Sign in to Cascade</h1>
<div id="content">
<p>
If the Cascade app is installed it should have opened automatically.
Otherwise, open Cascade, then paste this sign-in code into the
<em>“paste the sign-in link”</em> box:
</p>
<div class="token">
<code id="token">…</code>
<button id="copy" type="button">Copy</button>
</div>
<p class="hint">This code is single-use and expires 15 minutes after it was emailed.</p>
</div>
</main>
<script>
// The whole sign-in link works too — the app extracts the token itself —
// but showing just the token keeps the copy/paste short.
const token = new URLSearchParams(location.search).get("token");
const tokenEl = document.getElementById("token");
const copyBtn = document.getElementById("copy");
if (token) {
tokenEl.textContent = token;
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(token);
copyBtn.textContent = "Copied";
setTimeout(() => (copyBtn.textContent = "Copy"), 1500);
} catch {
copyBtn.textContent = "Select it";
}
});
} else {
document.getElementById("content").innerHTML =
'<p class="err">This sign-in link is missing its token. Request a new one from the Cascade app.</p>';
}
</script>
</body>
</html>
77 changes: 77 additions & 0 deletions docs/universal-link-signin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Universal Link sign-in (open the magic link in the app)

The account magic-link email contains
`https://cascade.stephens.page/auth?token=…`. By default that URL opens the
**website**. With Universal Links configured it instead opens the **Cascade
app** directly into `AppStore.completeSignIn(…)`, so the user never copies a
token by hand.

## Pieces (all in this repo)

| Piece | Where | Status |
|-------|-------|--------|
| `onOpenURL` → `handleOpenURL` → `completeSignIn` | `CascadeShared/App/AppStore.swift`, `Cascade*App.swift` | already wired |
| `associated-domains` entitlement (`applinks:cascade.stephens.page`) | `apps/apple/project.yml` (both targets, via `entitlements.properties` so XcodeGen doesn't clobber it) | added |
| AASA file | `apps/web/public/.well-known/apple-app-site-association` | added |
| Browser fallback for un-installed devices | `apps/web/public/auth/index.html` (shows the token to paste) | added |

The app already extracts the token from either a full link or a bare token, so
no Swift changes are needed — only the OS-level link registration above.

## Requirement: paid Apple Developer Program

The `associated-domains` capability is **not available under free
personal-team signing**. A *signed* build with this entitlement fails with:

```
"CascadeMac" has entitlements that require signing with a development certificate.
```

So to ship this you must:

1. Enrol in the paid Apple Developer Program.
2. Set `DEVELOPMENT_TEAM` in `project.yml` to your Team ID (`G38J85UN6P`) and
build with development/distribution signing (not ad-hoc `-`).
3. Enable the **Associated Domains** capability for the App ID in the developer
portal.

> CI is unaffected: `apple.yml` compiles with `CODE_SIGNING_ALLOWED=NO`, which
> skips entitlement validation. Only signed artifacts (the ad-hoc DMG and the
> free `install-device.sh` flow) require the paid cert — those will need the
> paid team once this is merged.

## Hosting the AASA file

Apple fetches `https://cascade.stephens.page/.well-known/apple-app-site-association`
(no redirects, `Content-Type: application/json`, **no** `.json` extension). The
file is plain static content under `apps/web/public/`, but verify after deploy:

```bash
curl -sI https://cascade.stephens.page/.well-known/apple-app-site-association
# 200, content-type application/json, served directly (no 30x)
```

Two gotchas:

- The web build must copy the dotfolder. Confirm `.well-known/` lands in the
built `dist/` and the web server (Apache vhost) serves dotfolders and
extensionless files as JSON.
- Apple caches the AASA via its CDN
(`https://app-site-association.cdn-apple.com/a/v1/cascade.stephens.page`);
changes can take time to propagate. On a dev device, reinstalling the app
re-fetches it.

## Verify end-to-end (signed build, real device)

1. Install a properly-signed build on a device/Mac with the app.
2. Request a sign-in link from the app, open Mail, tap the link.
3. The app should foreground and land signed-in (no paste). If it opens the
website instead, the AASA isn't being served correctly or the entitlement
isn't in the signed build.

## Security note

The token rides in the URL, so keep it **single-use + short-lived** (the server
already expires it after 15 minutes / one use). Universal Links are safer than a
custom `cascade://` scheme because Apple verifies domain ownership via the AASA
file, so no other app can claim `cascade.stephens.page/auth`.
Loading