Skip to content
Closed
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
38 changes: 38 additions & 0 deletions .github/workflows/app_clip.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: App Clip website

on:
pull_request:
paths:
- '.github/workflows/app_clip.yml'
- 'app/pages/clip.vue'
- 'public/.well-known/apple-app-site-association'
- 'public/_headers'
- 'scripts/verify-app-clip.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
push:
branches: [main]
paths:
- '.github/workflows/app_clip.yml'
- 'app/pages/clip.vue'
- 'public/.well-known/apple-app-site-association'
- 'public/_headers'
- 'scripts/verify-app-clip.mjs'
- 'package.json'
- 'pnpm-lock.yaml'

jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run generate
- run: pnpm run verify:app-clip -- --dist
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ Generates the static website in the `dist` directory.
pnpm run generate
```

### App Clip invocation endpoint

The static output includes `https://localsend.org/clip` and the Apple App Site Association file at `/.well-known/apple-app-site-association`. Validate their bundle/team identifiers, card metadata, headers, and generated output with:

```bash
pnpm run verify:app-clip -- --dist
```

The App Clip build uses `https://localsend.org/clip` as its invocation prefix. The Developer Portal capabilities and the default/advanced App Clip experiences in App Store Connect must match that URL and the identifiers in the AASA file before deployment.

## Contributing

### Adding a new language
Expand Down
64 changes: 64 additions & 0 deletions app/pages/clip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<script setup lang="ts">
// App Clip invocation fallback: advertise the signed App Clip without reading its bearer query.
definePageMeta({ layout: false })

useHead({
title: 'Send with LocalSend',
meta: [
{
name: 'apple-itunes-app',
content: 'app-id=1661733229, app-clip-bundle-id=org.localsend.localsendApp.Clip, app-clip-display=card',
},
{ name: 'referrer', content: 'no-referrer' },
{ property: 'og:title', content: 'Send with LocalSend' },
{ property: 'og:image', content: 'https://localsend.org/img/logo.png' },
],
})

onNuxtReady(() => {
// secretQueryScrubber — removes hotspot/session capability fields from browser history.
window.history.replaceState(null, document.title, '/clip')
})
</script>

<template>
<main class="clip-page">
<img class="clip-logo" src="/img/logo.png" alt="LocalSend" width="96" height="96">
<h1>Send with LocalSend</h1>
<p>Open the LocalSend App Clip card above to send photos and videos to the nearby Android phone.</p>
<a class="clip-download" href="https://apps.apple.com/us/app/localsend/id1661733229">Get the full LocalSend app</a>
</main>
</template>

<style scoped>
.clip-page {
min-height: 100vh;
display: grid;
place-content: center;
justify-items: center;
gap: 1rem;
padding: 2rem;
text-align: center;
color: #17213a;
background: #f7f9ff;
}

.clip-logo {
border-radius: 1.5rem;
}

.clip-page p {
max-width: 34rem;
color: #526078;
}

.clip-download {
display: inline-block;
padding: .8rem 1.2rem;
border-radius: 999px;
color: white;
background: #315ff4;
text-decoration: none;
font-weight: 600;
}
</style>
3 changes: 2 additions & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default defineNuxtConfig({
contact: false,
news: false,
changelog: false,
clip: false,
},
locales: [
{
Expand Down Expand Up @@ -413,4 +414,4 @@ export default defineNuxtConfig({
},
],
},
})
})
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"verify:app-clip": "node scripts/verify-app-clip.mjs"
},
"dependencies": {
"@nuxt/icon": "2.2.2",
Expand Down
7 changes: 7 additions & 0 deletions public/.well-known/apple-app-site-association
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"appclips": {
"apps": [
"3W7H4PYMCV.org.localsend.localsendApp.Clip"
]
}
}
7 changes: 7 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/.well-known/apple-app-site-association
Content-Type: application/json
Cache-Control: public, max-age=3600

/clip
Referrer-Policy: no-referrer
Cache-Control: no-store
34 changes: 34 additions & 0 deletions scripts/verify-app-clip.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'

const root = process.cwd()
const clipIdentifier = '3W7H4PYMCV.org.localsend.localsendApp.Clip'
const bundleIdentifier = 'org.localsend.localsendApp.Clip'
const smartBanner = `app-id=1661733229, app-clip-bundle-id=${bundleIdentifier}, app-clip-display=card`

const aasaPath = resolve(root, 'public/.well-known/apple-app-site-association')
const aasa = JSON.parse(readFileSync(aasaPath, 'utf8'))
assert.deepEqual(aasa, { appclips: { apps: [clipIdentifier] } })

const page = readFileSync(resolve(root, 'app/pages/clip.vue'), 'utf8')
assert.ok(page.includes(smartBanner))
assert.match(page, /onNuxtReady\(\(\) => \{[\s\S]*history\.replaceState\(null, document\.title, '\/clip'\)/)
assert.doesNotMatch(page, /useRoute\(|route\.query|URLSearchParams|location\.search/)

const headers = readFileSync(resolve(root, 'public/_headers'), 'utf8')
assert.match(headers, /\/\.well-known\/apple-app-site-association\s+Content-Type: application\/json/)
assert.match(headers, /\/clip\s+Referrer-Policy: no-referrer/)

if (process.argv.includes('--dist')) {
const generatedAASA = resolve(root, 'dist/.well-known/apple-app-site-association')
assert.ok(existsSync(generatedAASA), 'generated AASA file is missing')
assert.deepEqual(JSON.parse(readFileSync(generatedAASA, 'utf8')), aasa)
const generatedPage = ['dist/clip.html', 'dist/clip/index.html'].map(path => resolve(root, path)).find(existsSync)
assert.ok(generatedPage, 'generated /clip page is missing')
const html = readFileSync(generatedPage, 'utf8')
assert.ok(html.includes(smartBanner))
assert.equal(readFileSync(resolve(root, 'dist/_headers'), 'utf8'), headers)
}

console.log('App Clip website configuration verified')
Loading