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
5 changes: 5 additions & 0 deletions .changeset/apple-app-site-association.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

Publish the apple-app-site-association file, so the native iPhone app can use passkeys made on app.ftw.energy and a pairing code scanned with the camera opens the app when it is installed.
15 changes: 15 additions & 0 deletions public/.well-known/apple-app-site-association
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"webcredentials": {
"apps": ["L66M368PZ6.energy.ftw.app"]
},
"applinks": {
"details": [
{
"appIDs": ["L66M368PZ6.energy.ftw.app"],
"components": [
{ "/": "/p", "comment": "A pairing code scanned with the iPhone camera" }
]
}
]
}
}
7 changes: 7 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,10 @@
# changes when the bytes do.
/assets/*
Cache-Control: public, max-age=31536000, immutable

# Apple reads this file to tie the native iPhone app to this domain. A passkey
# made here then works in the app too, and a pairing code scanned with the
# camera opens the app when it is installed. Apple only accepts it as JSON,
# and the file has no extension, so the type is set here.
/.well-known/apple-app-site-association
Content-Type: application/json
45 changes: 45 additions & 0 deletions tests/apple-app-site-association.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* The file that ties the native iPhone app to app.ftw.energy.
*
* Passkeys are bound to this domain. Without this file the app cannot use
* them, so one passkey would not open the same home on the web and in the
* app. Apple fetches it without a browser, so a typo shows up only as a
* passkey prompt that never appears on a phone. These tests catch it here.
*/

import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'

const APP_ID = 'L66M368PZ6.energy.ftw.app'
const PATH = '/.well-known/apple-app-site-association'

type Aasa = {
webcredentials?: { apps?: string[] }
applinks?: { details?: { appIDs?: string[]; components?: { '/'?: string }[] }[] }
}

function aasa(): Aasa {
return JSON.parse(readFileSync(`public${PATH}`, 'utf8')) as Aasa
}

describe('the apple-app-site-association file', () => {
it('lets the app use passkeys made on this domain', () => {
expect(aasa().webcredentials?.apps).toEqual([APP_ID])
})

it('opens the app only for the pairing path the web app also serves', () => {
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'])
// 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('is served as JSON', () => {
const headers = readFileSync('public/_headers', 'utf8')
const block = headers.split(/\n(?=\S)/).find(b => b.startsWith(`${PATH}\n`))
expect(block, `no _headers rule for ${PATH}`).toBeDefined()
expect(block).toMatch(/^\s+Content-Type: application\/json$/m)
})
})
Loading