diff --git a/.github/workflows/app_clip.yml b/.github/workflows/app_clip.yml new file mode 100644 index 00000000..2bf62836 --- /dev/null +++ b/.github/workflows/app_clip.yml @@ -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 diff --git a/README.md b/README.md index b25b3f92..ac27d4f6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/pages/clip.vue b/app/pages/clip.vue new file mode 100644 index 00000000..07f26934 --- /dev/null +++ b/app/pages/clip.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index d075e9a2..8a73ec70 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -66,6 +66,7 @@ export default defineNuxtConfig({ contact: false, news: false, changelog: false, + clip: false, }, locales: [ { @@ -413,4 +414,4 @@ export default defineNuxtConfig({ }, ], }, -}) \ No newline at end of file +}) diff --git a/package.json b/package.json index cfdd776e..6043616e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association new file mode 100644 index 00000000..6b068311 --- /dev/null +++ b/public/.well-known/apple-app-site-association @@ -0,0 +1,7 @@ +{ + "appclips": { + "apps": [ + "3W7H4PYMCV.org.localsend.localsendApp.Clip" + ] + } +} diff --git a/public/_headers b/public/_headers new file mode 100644 index 00000000..6cd5d2b3 --- /dev/null +++ b/public/_headers @@ -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 diff --git a/scripts/verify-app-clip.mjs b/scripts/verify-app-clip.mjs new file mode 100644 index 00000000..1769bbcb --- /dev/null +++ b/scripts/verify-app-clip.mjs @@ -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')