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
1 change: 1 addition & 0 deletions .build/.buildSystem_debug
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
swiftbuild
4 changes: 4 additions & 0 deletions .build/CACHEDIR.TAG
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by (Swift Package Manager).
# For information about cache directory tags, see:
#. http://www.brynosaurus.com/cachedir/
5 changes: 5 additions & 0 deletions .changeset/access-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

An assistant's consent link at app.ftw.energy/a opens the FTW iPhone app, and a small page says to open it there when the app is not installed.
3 changes: 2 additions & 1 deletion public/.well-known/apple-app-site-association
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
{
"appIDs": ["L66M368PZ6.energy.ftw.app"],
"components": [
{ "/": "/p", "comment": "A pairing code scanned with the iPhone camera" }
{ "/": "/p", "comment": "A pairing code scanned with the iPhone camera" },
{ "/": "/a", "comment": "An assistant's consent page asks the owner to approve in the app" }
]
}
]
Expand Down
12 changes: 12 additions & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,17 @@
# scanned in the address bar, which is what they can check against the box.
/p /index.html 200

# /a is where an assistant's consent page at FTW Cloud sends the owner, with
# the request and the signed offer in the fragment, which never reaches this
# server. The iPhone app opens it when installed (apple-app-site-association).
# Anywhere else this small page says to open it on the iPhone with FTW; it
# never reads the fragment, and the web app has no part in approving. There
# is no /a/<request>: a request in a path would land in a log.
#
# The target is the page's own path, without .html: Cloudflare serves
# access-link.html there and answers a .html path with a 307, which would
# turn this rewrite into a redirect.
/a /access-link 200

# Everything else is hash-routed inside the shell, so there is no SPA fallback
# to add. A request for an unknown path is genuinely not found.
35 changes: 35 additions & 0 deletions public/access-link.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html>
<!--
Where https://app.ftw.energy/a lands when the FTW iPhone app is not there
to open it. An assistant's consent page at FTW Cloud links here, with its
signed offer in the fragment, which never reaches this server and which
this page never reads. Approving happens in the app, with Face ID, and
nowhere else: the web app has no key the box would take.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>Open this on your iPhone with FTW</title>
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<style>
:root { color-scheme: light dark; --ink: #111; --ink2: #555; --ground: #f4f4f2; }
@media (prefers-color-scheme: dark) { :root { --ink: #eee; --ink2: #9a9a9a; --ground: #0d0d0d; } }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--ground); color: var(--ink);
font: 17px/1.45 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
main { max-width: 26rem; padding: 32px 24px; }
h1 { font-size: 1.6rem; line-height: 1.2; margin: 0 0 12px; }
p { margin: 0 0 12px; color: var(--ink2); }
p[lang="sv"] { margin-top: 28px; }
</style>
</head>
<body>
<main>
<h1>Open this on your iPhone with FTW</h1>
<p>This link asks you to let an assistant reach your home. Open it on the iPhone where the FTW app is installed, and approve there with Face ID.</p>
<p>Or open FTW and type the code the page showed you: Settings, Assistants and support, Add an assistant.</p>
<p lang="sv"><strong>脰ppna l盲nken p氓 din iPhone med FTW.</strong> D盲r godk盲nner du med Face ID, eller skriver koden fr氓n sidan under Inst盲llningar, Assistenter och support, L盲gg till en assistent.</p>
</main>
</body>
</html>
5 changes: 5 additions & 0 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ sw.addEventListener('fetch', (event) => {
const url = new URL(request.url)
if (url.origin !== sw.location.origin) return

// An assistant's consent link (/a#<request>.<offer>) is the iPhone app's,
// and its page here only says so. The network answers it, never the
// shell: the web app has no part in approving.
if (url.pathname === '/a') return

// Cache first, so opening the app never waits on a network that may not be
// there. A build is only ever replaced wholesale, so this cannot serve a
// shell that disagrees with the assets it will ask for next.
Expand Down
16 changes: 14 additions & 2 deletions tests/apple-app-site-association.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,28 @@ describe('the apple-app-site-association file', () => {
expect(aasa().webcredentials?.apps).toEqual([APP_ID])
})

it('opens the app only for the pairing path the web app also serves', () => {
it('opens the app only for pairing and for an assistant\'s consent link', () => {
const details = aasa().applinks?.details ?? []
expect(details.map(d => d.appIDs)).toEqual([[APP_ID]])
const paths = details.flatMap(d => d.components ?? []).map(c => c['/'])
expect(paths).toEqual(['/p'])
// Only /a itself: the request rides in the fragment, never a path.
expect(paths).toEqual(['/p', '/a'])
// The web app answers the same path, so a phone without the app still pairs.
const redirects = readFileSync('public/_redirects', 'utf8')
expect(redirects).toMatch(/^\/p \/index\.html 200$/m)
})

it('answers a consent link without the app with a page that sends you to it', () => {
const redirects = readFileSync('public/_redirects', 'utf8')
// Without .html: Cloudflare answers a .html path with a 307.
expect(redirects).toMatch(/^\/a \/access-link 200$/m)
expect(redirects).not.toMatch(/^\/a\//m)
const page = readFileSync('public/access-link.html', 'utf8')
expect(page).toContain('Open this on your iPhone with FTW')
// It approves nothing and reads nothing: no script, no form, no fetch.
expect(page).not.toMatch(/<script|<form|fetch\(/i)
})

it('is served as JSON', () => {
const headers = readFileSync('public/_headers', 'utf8')
const block = headers.split(/\n(?=\S)/).find(b => b.startsWith(`${PATH}\n`))
Expand Down
6 changes: 6 additions & 0 deletions tests/service-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ describe('opening without a network', () => {
expect(await answer(worker, request('/assets/index-a1.js'))).toEqual({ body: 'app a1' })
})

it("leaves an assistant's consent link to the network, never the shell", async () => {
const worker = await activate('a1')

expect(await answer(worker, request('/a', 'navigate'))).toBeNull()
})

it('answers any navigation with the shell, whatever the path', async () => {
const worker = await activate('a1')
server = new Map()
Expand Down
Loading