Complete native iPhone calls, shared chat and invitation sharing with Oficina icons
136 files changed,
+2445
−201
.gitignore+2 −0AGENTS.md+3 −0Makefile+10 −1design/oficina-icons/INTEGRATION.md+71 −0design/oficina-icons/LICENSE.lucide+43 −0design/oficina-icons/README.md+47 −0design/oficina-icons/generate.mjs+58 −0design/oficina-icons/icons.json+9 −0design/oficina-icons/png/calendar-1024.pngbinarydesign/oficina-icons/png/calendar-16.pngbinarydesign/oficina-icons/png/calendar-180.pngbinarydesign/oficina-icons/png/calendar-192.pngbinarydesign/oficina-icons/png/calendar-32.pngbinarydesign/oficina-icons/png/calendar-48.pngbinarydesign/oficina-icons/png/calendar-512.pngbinarydesign/oficina-icons/png/docs-1024.pngbinarydesign/oficina-icons/png/docs-16.pngbinarydesign/oficina-icons/png/docs-180.pngbinarydesign/oficina-icons/png/docs-192.pngbinarydesign/oficina-icons/png/docs-32.pngbinarydesign/oficina-icons/png/docs-48.pngbinarydesign/oficina-icons/png/docs-512.pngbinarydesign/oficina-icons/png/meet-1024.pngbinarydesign/oficina-icons/png/meet-16.pngbinarydesign/oficina-icons/png/meet-180.pngbinarydesign/oficina-icons/png/meet-192.pngbinarydesign/oficina-icons/png/meet-32.pngbinarydesign/oficina-icons/png/meet-48.pngbinarydesign/oficina-icons/png/meet-512.pngbinarydesign/oficina-icons/png/memoria-1024.pngbinarydesign/oficina-icons/png/memoria-16.pngbinarydesign/oficina-icons/png/memoria-180.pngbinarydesign/oficina-icons/png/memoria-192.pngbinarydesign/oficina-icons/png/memoria-32.pngbinarydesign/oficina-icons/png/memoria-48.pngbinarydesign/oficina-icons/png/memoria-512.pngbinarydesign/oficina-icons/png/oficina-1024.pngbinarydesign/oficina-icons/png/oficina-16.pngbinarydesign/oficina-icons/png/oficina-180.pngbinarydesign/oficina-icons/png/oficina-192.pngbinarydesign/oficina-icons/png/oficina-32.pngbinarydesign/oficina-icons/png/oficina-48.pngbinarydesign/oficina-icons/png/oficina-512.pngbinarydesign/oficina-icons/png/sheets-1024.pngbinarydesign/oficina-icons/png/sheets-16.pngbinarydesign/oficina-icons/png/sheets-180.pngbinarydesign/oficina-icons/png/sheets-192.pngbinarydesign/oficina-icons/png/sheets-32.pngbinarydesign/oficina-icons/png/sheets-48.pngbinarydesign/oficina-icons/png/sheets-512.pngbinarydesign/oficina-icons/png/slides-1024.pngbinarydesign/oficina-icons/png/slides-16.pngbinarydesign/oficina-icons/png/slides-180.pngbinarydesign/oficina-icons/png/slides-192.pngbinarydesign/oficina-icons/png/slides-32.pngbinarydesign/oficina-icons/png/slides-48.pngbinarydesign/oficina-icons/png/slides-512.pngbinarydesign/oficina-icons/preview.pngbinarydesign/oficina-icons/preview.svg+97 −0design/oficina-icons/svg/calendar.svg+10 −0design/oficina-icons/svg/docs.svg+5 −0design/oficina-icons/svg/meet.svg+2 −0design/oficina-icons/svg/memoria.svg+4 −0design/oficina-icons/svg/oficina.svg+3 −0design/oficina-icons/svg/sheets.svg+4 −0design/oficina-icons/svg/slides.svg+3 −0design/oficina-icons/upstream/book-search.svg+16 −0design/oficina-icons/upstream/calendar-days.svg+22 −0design/oficina-icons/upstream/file-text.svg+17 −0design/oficina-icons/upstream/panels-top-left.svg+15 −0design/oficina-icons/upstream/presentation.svg+15 −0design/oficina-icons/upstream/table.svg+16 −0design/oficina-icons/upstream/video.svg+14 −0docs/superpowers/specs/2026-09-07-meet-design.md+9 −1internal/meet/app.go+2 −1internal/meet/handlers_meetings.go+33 −3internal/meet/home_router_test.go+4 −1internal/meet/ids.go+2 −2internal/meet/migrations/0003_alter_meeting_links.sql+2 −0internal/meet/migrations/schema.sql+1 −1internal/meet/models.go+9 −9internal/meet/render.go+1 −1internal/meet/seal/seal.go+1 −0internal/meet/static/brand/LICENSE.lucide+43 −0internal/meet/static/brand/apple-touch-icon.pngbinaryinternal/meet/static/brand/brand.css+19 −0internal/meet/static/brand/icon-32.pngbinaryinternal/meet/static/brand/icon.svg+2 −0internal/meet/static/meet.css+3 −2internal/meet/static/share-link.js+26 −0internal/meet/store.go+51 −5internal/meet/store_share_test.go+94 −0internal/meet/templates/join.html+1 −0internal/meet/templates/layout.html+5 −1internal/meet/templates/link_once.html+1 −1internal/meet/templates/room.html+1 −0internal/meet/templates/share.html+14 −0internal/meettest/iphone_browser_test.go+102 −0internal/meettest/join_test.go+1 −1internal/meettest/native_browser_test.go+18 −4internal/meettest/share_test.go+57 −0internal/meettest/signin_test.go+6 −6meetcore/room.go+36 −1meetcore/room_test.go+109 −0meetcore/transport.go+27 −0meetcore/transport_test.go+41 −0native/LICENSE.lucide+43 −0native/README.md+29 −10native/VERIFICATION.md+32 −0native/apple/Package.swift+3 −2native/apple/Sources/MeetInterop/Interop.swift+76 −4native/apple/Sources/MeetKit/Call.swift+82 −14native/apple/Sources/MeetKit/Capture.swift+8 −2native/apple/Sources/MeetKit/ChatView.swift+52 −0native/apple/Sources/MeetKit/Media.swift+30 −3native/apple/Sources/MeetKit/MeetModel.swift+137 −0native/apple/Sources/MeetKit/MeetingShareButton.swift+64 −0native/apple/Sources/MeetKit/Wire.swift+1 −0native/apple/Sources/MeetMac/MeetMac.swift+16 −120native/apple/Sources/MeetMac/Resources/AppIcon.pngbinarynative/bridge/bridge.go+2 −1native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.pngbinarynative/ios/Assets.xcassets/AppIcon.appiconset/Contents.json+1 −0native/ios/Assets.xcassets/Contents.json+1 −0native/ios/README.md+74 −0native/ios/Sources/MeetPhone.swift+196 −0native/ios/Tests/MeetPhoneTests.swift+61 −0native/ios/Tests/SilentAudioDevice.swift+54 −0native/ios/UITests/MeetPhoneUITests.swift+76 −0native/ios/project.yml+72 −0native/scripts/build-bridge.sh+1 −1native/scripts/build-iphone.sh+19 −0native/scripts/bundle-mac.py+17 −0native/scripts/generate-iphone-icon.swift+9 −0test/browser/call.mjs+11 −0test/browser/native.mjs+71 −3
CI passed — run details
diff --git a/.gitignore b/.gitignore| index 39efeea..77276c2 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -51,3 +51,5 @@ Thumbs.db |
| /native/build/ |
| /native/apple/.build/ |
| /native/apple/.swiftpm/ |
| + |
| +/native/ios/MeetPhone.xcodeproj/ |
diff --git a/AGENTS.md b/AGENTS.md| index c9c5d9f..3bc2096 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -26,6 +26,9 @@ mechanically. |
| a keyed fingerprint, while `persons.email` holds a verified address in |
| plaintext, the suite's current shape for a person (spec §2.1, as in |
| Docs, Slides and Calendar). |
| + Meeting invitation tokens are looked up by hash and sealed for repeat sharing |
| + by members. Legacy hash-only invitations remain valid; first sharing creates |
| + a sealed invitation alongside them. Replacing a link revokes all current ones. |
| - `internal/meet/store.go` — every database access. `standing.go` is |
| the entry authority (spec §2.3), computed per request, never stored. |
| - Sign-in: `auth.go` is Docs' shape with the pending-join exception of |
diff --git a/Makefile b/Makefile| index 2606e9e..71c7adc 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -164,4 +164,13 @@ native-test: |
| CGO_ENABLED=1 go test -race ./meetcore |
| native-browser: native-mac |
| - CGO_ENABLED=0 go test -tags browser -count=1 -timeout 3m -run 'TestNative(MacCallsBrowser|BrowserAuthentication)' -v ./internal/meettest/ |
| + CGO_ENABLED=0 go test -tags browser -count=1 -timeout 3m -run 'TestNative(MacCallsBrowser|MacChat|BrowserAuthentication)' -v ./internal/meettest/ |
| + |
| +.PHONY: iphone iphone-device iphone-browser |
| +iphone: |
| + ./native/scripts/build-iphone.sh simulator |
| +iphone-device: |
| + ./native/scripts/build-iphone.sh device |
| +iphone-browser: |
| + xcodegen generate --spec native/ios/project.yml |
| + CGO_ENABLED=0 go test -tags browser -count=1 -timeout 10m -run 'TestIPhone(SafariAuthentication|NativeMedia)' -v ./internal/meettest/ |
diff --git a/design/oficina-icons/INTEGRATION.md b/design/oficina-icons/INTEGRATION.md| new file mode 100644 |
| index 0000000..1b27ee9 |
| --- /dev/null |
| +++ b/design/oficina-icons/INTEGRATION.md |
| @@ -0,0 +1,71 @@ |
| +# Oficina icon integration — 2026-09-12 |
| + |
| +The approved set is wired into browser favicons (SVG and PNG), Apple touch |
| +icons and visible app branding for Oficina Home, Docs, Sheets, Slides, |
| +Calendar, Meet and Memoria. Home's six app rows have the matching icons; |
| +Mail and Files keep their existing entries because no artwork was approved |
| +for them. Product names remain visible and the adjacent images have empty |
| +alt text. All assets are served locally, without JavaScript or a CDN. |
| + |
| +## Source and branches |
| + |
| +The maintained source is Estilo's `brand/` directory. Its `distribute.py` |
| +copies artwork, shared CSS and the license into each application and |
| +supports `--check` to detect drift. This is asset distribution, with no |
| +new Go module dependency or application build step. |
| + |
| +All seven sibling icon branches passed remote CI and independent source and |
| +canary review, then merged into main on 2026-09-12: |
| + |
| +| Repository | Merge commit | |
| +| --- | --- | |
| +| estilo | 35e3bf4 | |
| +| home | f1caf3a | |
| +| docs | d5ff04e | |
| +| sheets | fa8b940 | |
| +| slides | 2ebd8f6 | |
| +| calendar | 9c44bc1 | |
| +| memoria | aab25c0 | |
| + |
| +Production rollout uses those merged sources. Home's canonical domain still |
| +uses its legacy page; the signed-in app listing is present on the paired Home |
| +preview deployment. Changing Home's identity/directory topology is separate |
| +from deploying these assets. |
| + |
| +Meet's icons ship with `codex/iphone-and-mirrored-preview`, including the Mac |
| +Dock/Finder resources, iPhone asset catalog and packaged Lucide licenses. |
| +The Mac and signed iPhone builds are installed locally. See |
| +`native/VERIFICATION.md` for native test coverage and acceptance limits. |
| + |
| +## Verification |
| + |
| +- Asset distributor `--check` passes for all seven consumers. |
| +- Actual server-rendered HTML and served asset bytes were captured from |
| + test fixtures and rendered in Chromium at 1280 and 320 px with JavaScript |
| + disabled. All branding images loaded, with no horizontal overflow or |
| + missing requests. These are local visual checks, not deployed-canary |
| + acceptance. Evidence: `/private/tmp/oficina-icons-evidence/<app>/`. |
| +- Meet, Calendar, Slides and Memoria `make ci` passed; Estilo `make test` |
| + passed. Meet and Calendar's real-browser gates passed. |
| +- Docs' Go packages passed after adjusting the existing label test for the |
| + decorative image. Its release test needs GNU realpath on PATH and a short |
| + TMPDIR for macOS Unix-socket limits; it passed with both supplied. |
| +- Sheets' default gate initially used an incompatible installed migration |
| + CLI. The app-pinned v0.23.0 CLI confirms its schema is unchanged; Go and |
| + race tests passed with the correct CLI supplied. |
| +- Home's vet/build/Go tests and applicable race tests passed. Its full |
| + `make test` hits a pre-existing macOS limitation: the race target names |
| + `internal/provisioner`, whose sources are Linux-only. |
| +- Independent review: inherited GPT-6 Astra. The reviewer identified Mac |
| + bundle/Finder resources and native license packaging; all findings were |
| + corrected and re-reviewed with no remaining concrete source defects. |
| +- Reviewer-selected Home test mutations (wrong SVG MIME and removal of the |
| + directory's asset mount) both failed as intended and were restored. |
| +- Mac app builds and its signature, icon files, metadata and license verify. |
| + The iOS 26.5 platform was installed, and the signed device build and simulator |
| + authentication/media tests pass. |
| +- Docs and Slides' full local Rust/JS gates cannot start without rustup and |
| + the wasm target. Their pushed branches run the full gates in remote CI. |
| + |
| +All seven remote branch CI runs passed before merge. Deployed canaries reported |
| +the expected process versions and served byte-identical artwork. |
diff --git a/design/oficina-icons/LICENSE.lucide b/design/oficina-icons/LICENSE.lucide| new file mode 100644 |
| index 0000000..718bb3f |
| --- /dev/null |
| +++ b/design/oficina-icons/LICENSE.lucide |
| @@ -0,0 +1,43 @@ |
| +ISC License |
| + |
| +Copyright (c) 2026 Lucide Icons and Contributors |
| + |
| +Permission to use, copy, modify, and/or distribute this software for any |
| +purpose with or without fee is hereby granted, provided that the above |
| +copyright notice and this permission notice appear in all copies. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES |
| +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF |
| +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR |
| +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES |
| +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN |
| +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF |
| +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
| + |
| +--- |
| + |
| +The following Lucide icons are derived from the Feather project: |
| + |
| +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out |
| + |
| +The MIT License (MIT) (for the icons listed above) |
| + |
| +Copyright (c) 2013-present Cole Bemis |
| + |
| +Permission is hereby granted, free of charge, to any person obtaining a copy |
| +of this software and associated documentation files (the "Software"), to deal |
| +in the Software without restriction, including without limitation the rights |
| +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| +copies of the Software, and to permit persons to whom the Software is |
| +furnished to do so, subject to the following conditions: |
| + |
| +The above copyright notice and this permission notice shall be included in all |
| +copies or substantial portions of the Software. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| +SOFTWARE. |
diff --git a/design/oficina-icons/README.md b/design/oficina-icons/README.md| new file mode 100644 |
| index 0000000..2ce163e |
| --- /dev/null |
| +++ b/design/oficina-icons/README.md |
| @@ -0,0 +1,47 @@ |
| +# Oficina icon set |
| + |
| +Approved 2026-09-12. The maintained source and distributor now live in |
| +Oficina Estilo's `brand/` directory (branch `codex/oficina-icons`). This |
| +folder preserves the original review sheet and exports. Make subsequent |
| +artwork changes in Estilo, then run its `brand/distribute.py` for each app. |
| + |
| +Open `preview.png` or `preview.svg` to compare the family and small sizes. |
| + |
| +| Product | Lucide symbol | Background | |
| +| --- | --- | --- | |
| +| Oficina | panels-top-left | `#303642` | |
| +| Docs | file-text | `#2563EB` | |
| +| Sheets | table | `#168049` | |
| +| Slides | presentation | `#C45B16` | |
| +| Calendar | calendar-days | `#CB3F50` | |
| +| Meet | video | `#087F8C` | |
| +| Memoria | book-search | `#7C3FC8` | |
| + |
| +`svg/` contains opaque square 1024-unit masters. `png/` contains 16, 32, |
| +48, 180, 192, 512 and 1024-pixel exports for each. Platform masks should |
| +provide rounded corners; the rounded tiles in the preview are illustrative. |
| +The Lucide canvas occupies 70% of each tile, with the original 2-unit white |
| +stroke. Use visible product names alongside launcher icons. |
| + |
| +Edit `icons.json`, then from the Meet repository root run: |
| + |
| +```sh |
| +node design/oficina-icons/generate.mjs --render |
| +``` |
| + |
| +Rendering uses the repository's installed Playwright and Chromium. Omit |
| +`--render` to regenerate SVGs without a browser. No network is needed. |
| + |
| +The original SVGs in `upstream/` come from Lucide commit |
| +`a79b2d131dab2bf20cb224bd0937b439a9c4fa99`: |
| +https://github.com/lucide-icons/lucide/tree/a79b2d131dab2bf20cb224bd0937b439a9c4fa99/icons |
| + |
| +The complete upstream license is included as `LICENSE.lucide`, copied from |
| +Estilo's existing notice for that same commit. Retain it when distributing |
| +the icons. Upstream paths are unchanged; the generated artwork adds white |
| +stroke colour, scaling and a background. |
| + |
| +Validation: preview rendered in Chromium and visually inspected at 48, 32 |
| +and 16 px; all 49 PNG dimensions and full opacity checked. White/background |
| +contrast ranges from 4.33:1 (Slides) to 12.12:1 (Oficina). Fine details soften |
| +at 16 px; these are provisional marks, not bespoke favicon drawings. |
diff --git a/design/oficina-icons/generate.mjs b/design/oficina-icons/generate.mjs| new file mode 100644 |
| index 0000000..d13f140 |
| --- /dev/null |
| +++ b/design/oficina-icons/generate.mjs |
| @@ -0,0 +1,58 @@ |
| +import { readFile, writeFile, mkdir } from 'node:fs/promises'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const root = new URL('./', import.meta.url); |
| +const icons = JSON.parse(await readFile(new URL('icons.json', root), 'utf8')); |
| +const bodies = new Map(); |
| +for (const item of icons) { |
| + const source = await readFile(new URL(`upstream/${item.icon}.svg`, root), 'utf8'); |
| + bodies.set(item.id, source.match(/<svg[^>]*>([\s\S]*?)<\/svg>/)[1].trim()); |
| +} |
| +const glyph = (item, x, y, size) => `<g transform="translate(${x} ${y}) scale(${size / 24})" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${bodies.get(item.id)}</g>`; |
| +// All seven use the same 24-unit Lucide canvas, occupying 70% of the tile. |
| +const tile = (item, x, y, size, radius = 0) => `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${radius}" fill="${item.colour}"/>${glyph(item, x + size * .15, y + size * .15, size * .7)}`; |
| +const svg = (width, height, content, title) => `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${title}"><title>${title}</title>${content}</svg>\n`; |
| +await mkdir(new URL('svg/', root), { recursive: true }); |
| +for (const item of icons) { |
| + await writeFile(new URL(`svg/${item.id}.svg`, root), svg(1024, 1024, tile(item, 0, 0, 1024), item.name)); |
| +} |
| +let sheet = `<rect width="1400" height="790" fill="#F6F5F2"/><g font-family="Arial, sans-serif"><text x="64" y="77" font-size="32" fill="#252932" font-weight="700">Oficina</text><text x="64" y="111" font-size="16" fill="#606571">One family. Seven marks. White Lucide symbols on solid colour.</text>`; |
| +for (let i = 0; i < icons.length; i++) { |
| + const item = icons[i]; |
| + const x = 64 + i * 184; |
| + sheet += tile(item, x, 165, 144, 30); |
| + sheet += `<text x="${x}" y="345" font-size="20" font-weight="700" fill="#252932">${item.name}</text><text x="${x}" y="372" font-size="13" fill="#606571">${item.icon}</text><text x="${x}" y="395" font-size="13" fill="#606571">${item.colour}</text>`; |
| + for (const [size, y] of [[48, 450], [32, 525], [16, 588]]) sheet += tile(item, x, y, size, size * .21); |
| +} |
| +sheet += `<text x="64" y="666" font-size="14" fill="#606571">Actual sizes above: 48, 32 and 16 px. Rounded corners are a preview mask only.</text><text x="64" y="733" font-size="14" fill="#606571">Square, opaque masters · Shared padding · 2-unit strokes · No gradients or shadows</text></g>`; |
| +await writeFile(new URL('preview.svg', root), svg(1400, 790, sheet, 'Oficina app icon set')); |
| +console.log('Generated seven SVG masters and preview.svg'); |
| + |
| +if (process.argv.includes('--render')) { |
| + const { chromium } = await import('playwright'); |
| + const browser = await chromium.launch({ headless: true }); |
| + try { |
| + const page = await browser.newPage({ viewport: { width: 1400, height: 790 }, deviceScaleFactor: 1 }); |
| + await page.goto(new URL('preview.svg', root).href); |
| + await page.screenshot({ path: fileURLToPath(new URL('preview.png', root)) }); |
| + await mkdir(new URL('png/', root), { recursive: true }); |
| + for (const item of icons) { |
| + const source = await readFile(new URL(`svg/${item.id}.svg`, root), 'utf8'); |
| + for (const size of [16, 32, 48, 180, 192, 512, 1024]) { |
| + const png = await page.evaluate(async ({ source, size }) => { |
| + const img = new Image(); |
| + img.src = 'data:image/svg+xml;base64,' + btoa(source); |
| + await img.decode(); |
| + const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); |
| + canvas.width = canvas.height = size; |
| + canvas.getContext('2d', { alpha: false }).drawImage(img, 0, 0, size, size); |
| + return canvas.toDataURL('image/png').split(',')[1]; |
| + }, { source, size }); |
| + await writeFile(new URL(`png/${item.id}-${size}.png`, root), Buffer.from(png, 'base64')); |
| + } |
| + } |
| + console.log('Rendered preview.png and seven PNG sizes per app'); |
| + } finally { |
| + await browser.close(); |
| + } |
| +} |
diff --git a/design/oficina-icons/icons.json b/design/oficina-icons/icons.json| new file mode 100644 |
| index 0000000..86a9219 |
| --- /dev/null |
| +++ b/design/oficina-icons/icons.json |
| @@ -0,0 +1,9 @@ |
| +[ |
| + {"id":"oficina","name":"Oficina","icon":"panels-top-left","colour":"#303642"}, |
| + {"id":"docs","name":"Docs","icon":"file-text","colour":"#2563EB"}, |
| + {"id":"sheets","name":"Sheets","icon":"table","colour":"#168049"}, |
| + {"id":"slides","name":"Slides","icon":"presentation","colour":"#C45B16"}, |
| + {"id":"calendar","name":"Calendar","icon":"calendar-days","colour":"#CB3F50"}, |
| + {"id":"meet","name":"Meet","icon":"video","colour":"#087F8C"}, |
| + {"id":"memoria","name":"Memoria","icon":"book-search","colour":"#7C3FC8"} |
| +] |
diff --git a/design/oficina-icons/png/calendar-1024.png b/design/oficina-icons/png/calendar-1024.png| new file mode 100644 |
| index 0000000..548da0a |
| Binary files /dev/null and b/design/oficina-icons/png/calendar-1024.png differ |
diff --git a/design/oficina-icons/png/calendar-16.png b/design/oficina-icons/png/calendar-16.png| new file mode 100644 |
| index 0000000..afc37bb |
| Binary files /dev/null and b/design/oficina-icons/png/calendar-16.png differ |
diff --git a/design/oficina-icons/png/calendar-180.png b/design/oficina-icons/png/calendar-180.png| new file mode 100644 |
| index 0000000..bc1747b |
| Binary files /dev/null and b/design/oficina-icons/png/calendar-180.png differ |
diff --git a/design/oficina-icons/png/calendar-192.png b/design/oficina-icons/png/calendar-192.png| new file mode 100644 |
| index 0000000..b969d12 |
| Binary files /dev/null and b/design/oficina-icons/png/calendar-192.png differ |
diff --git a/design/oficina-icons/png/calendar-32.png b/design/oficina-icons/png/calendar-32.png| new file mode 100644 |
| index 0000000..68d97aa |
| Binary files /dev/null and b/design/oficina-icons/png/calendar-32.png differ |
diff --git a/design/oficina-icons/png/calendar-48.png b/design/oficina-icons/png/calendar-48.png| new file mode 100644 |
| index 0000000..3382e10 |
| Binary files /dev/null and b/design/oficina-icons/png/calendar-48.png differ |
diff --git a/design/oficina-icons/png/calendar-512.png b/design/oficina-icons/png/calendar-512.png| new file mode 100644 |
| index 0000000..25b8c58 |
| Binary files /dev/null and b/design/oficina-icons/png/calendar-512.png differ |
diff --git a/design/oficina-icons/png/docs-1024.png b/design/oficina-icons/png/docs-1024.png| new file mode 100644 |
| index 0000000..ad3778f |
| Binary files /dev/null and b/design/oficina-icons/png/docs-1024.png differ |
diff --git a/design/oficina-icons/png/docs-16.png b/design/oficina-icons/png/docs-16.png| new file mode 100644 |
| index 0000000..4d44d74 |
| Binary files /dev/null and b/design/oficina-icons/png/docs-16.png differ |
diff --git a/design/oficina-icons/png/docs-180.png b/design/oficina-icons/png/docs-180.png| new file mode 100644 |
| index 0000000..d75bdba |
| Binary files /dev/null and b/design/oficina-icons/png/docs-180.png differ |
diff --git a/design/oficina-icons/png/docs-192.png b/design/oficina-icons/png/docs-192.png| new file mode 100644 |
| index 0000000..a4edecb |
| Binary files /dev/null and b/design/oficina-icons/png/docs-192.png differ |
diff --git a/design/oficina-icons/png/docs-32.png b/design/oficina-icons/png/docs-32.png| new file mode 100644 |
| index 0000000..b61cb5f |
| Binary files /dev/null and b/design/oficina-icons/png/docs-32.png differ |
diff --git a/design/oficina-icons/png/docs-48.png b/design/oficina-icons/png/docs-48.png| new file mode 100644 |
| index 0000000..37b3009 |
| Binary files /dev/null and b/design/oficina-icons/png/docs-48.png differ |
diff --git a/design/oficina-icons/png/docs-512.png b/design/oficina-icons/png/docs-512.png| new file mode 100644 |
| index 0000000..66da51f |
| Binary files /dev/null and b/design/oficina-icons/png/docs-512.png differ |
diff --git a/design/oficina-icons/png/meet-1024.png b/design/oficina-icons/png/meet-1024.png| new file mode 100644 |
| index 0000000..29baeff |
| Binary files /dev/null and b/design/oficina-icons/png/meet-1024.png differ |
diff --git a/design/oficina-icons/png/meet-16.png b/design/oficina-icons/png/meet-16.png| new file mode 100644 |
| index 0000000..a8075c6 |
| Binary files /dev/null and b/design/oficina-icons/png/meet-16.png differ |
diff --git a/design/oficina-icons/png/meet-180.png b/design/oficina-icons/png/meet-180.png| new file mode 100644 |
| index 0000000..1354068 |
| Binary files /dev/null and b/design/oficina-icons/png/meet-180.png differ |
diff --git a/design/oficina-icons/png/meet-192.png b/design/oficina-icons/png/meet-192.png| new file mode 100644 |
| index 0000000..185e98f |
| Binary files /dev/null and b/design/oficina-icons/png/meet-192.png differ |
diff --git a/design/oficina-icons/png/meet-32.png b/design/oficina-icons/png/meet-32.png| new file mode 100644 |
| index 0000000..e5b0218 |
| Binary files /dev/null and b/design/oficina-icons/png/meet-32.png differ |
diff --git a/design/oficina-icons/png/meet-48.png b/design/oficina-icons/png/meet-48.png| new file mode 100644 |
| index 0000000..d4ba0ad |
| Binary files /dev/null and b/design/oficina-icons/png/meet-48.png differ |
diff --git a/design/oficina-icons/png/meet-512.png b/design/oficina-icons/png/meet-512.png| new file mode 100644 |
| index 0000000..ba3c3c7 |
| Binary files /dev/null and b/design/oficina-icons/png/meet-512.png differ |
diff --git a/design/oficina-icons/png/memoria-1024.png b/design/oficina-icons/png/memoria-1024.png| new file mode 100644 |
| index 0000000..d07f2d2 |
| Binary files /dev/null and b/design/oficina-icons/png/memoria-1024.png differ |
diff --git a/design/oficina-icons/png/memoria-16.png b/design/oficina-icons/png/memoria-16.png| new file mode 100644 |
| index 0000000..ba1acc9 |
| Binary files /dev/null and b/design/oficina-icons/png/memoria-16.png differ |
diff --git a/design/oficina-icons/png/memoria-180.png b/design/oficina-icons/png/memoria-180.png| new file mode 100644 |
| index 0000000..8e9e150 |
| Binary files /dev/null and b/design/oficina-icons/png/memoria-180.png differ |
diff --git a/design/oficina-icons/png/memoria-192.png b/design/oficina-icons/png/memoria-192.png| new file mode 100644 |
| index 0000000..c49afe3 |
| Binary files /dev/null and b/design/oficina-icons/png/memoria-192.png differ |
diff --git a/design/oficina-icons/png/memoria-32.png b/design/oficina-icons/png/memoria-32.png| new file mode 100644 |
| index 0000000..9c7742b |
| Binary files /dev/null and b/design/oficina-icons/png/memoria-32.png differ |
diff --git a/design/oficina-icons/png/memoria-48.png b/design/oficina-icons/png/memoria-48.png| new file mode 100644 |
| index 0000000..072c329 |
| Binary files /dev/null and b/design/oficina-icons/png/memoria-48.png differ |
diff --git a/design/oficina-icons/png/memoria-512.png b/design/oficina-icons/png/memoria-512.png| new file mode 100644 |
| index 0000000..c982593 |
| Binary files /dev/null and b/design/oficina-icons/png/memoria-512.png differ |
diff --git a/design/oficina-icons/png/oficina-1024.png b/design/oficina-icons/png/oficina-1024.png| new file mode 100644 |
| index 0000000..2d178fe |
| Binary files /dev/null and b/design/oficina-icons/png/oficina-1024.png differ |
diff --git a/design/oficina-icons/png/oficina-16.png b/design/oficina-icons/png/oficina-16.png| new file mode 100644 |
| index 0000000..b19f71e |
| Binary files /dev/null and b/design/oficina-icons/png/oficina-16.png differ |
diff --git a/design/oficina-icons/png/oficina-180.png b/design/oficina-icons/png/oficina-180.png| new file mode 100644 |
| index 0000000..97b522c |
| Binary files /dev/null and b/design/oficina-icons/png/oficina-180.png differ |
diff --git a/design/oficina-icons/png/oficina-192.png b/design/oficina-icons/png/oficina-192.png| new file mode 100644 |
| index 0000000..92669d4 |
| Binary files /dev/null and b/design/oficina-icons/png/oficina-192.png differ |
diff --git a/design/oficina-icons/png/oficina-32.png b/design/oficina-icons/png/oficina-32.png| new file mode 100644 |
| index 0000000..bbfaca7 |
| Binary files /dev/null and b/design/oficina-icons/png/oficina-32.png differ |
diff --git a/design/oficina-icons/png/oficina-48.png b/design/oficina-icons/png/oficina-48.png| new file mode 100644 |
| index 0000000..9d7f483 |
| Binary files /dev/null and b/design/oficina-icons/png/oficina-48.png differ |
diff --git a/design/oficina-icons/png/oficina-512.png b/design/oficina-icons/png/oficina-512.png| new file mode 100644 |
| index 0000000..6712cb6 |
| Binary files /dev/null and b/design/oficina-icons/png/oficina-512.png differ |
diff --git a/design/oficina-icons/png/sheets-1024.png b/design/oficina-icons/png/sheets-1024.png| new file mode 100644 |
| index 0000000..458ff0f |
| Binary files /dev/null and b/design/oficina-icons/png/sheets-1024.png differ |
diff --git a/design/oficina-icons/png/sheets-16.png b/design/oficina-icons/png/sheets-16.png| new file mode 100644 |
| index 0000000..17f9057 |
| Binary files /dev/null and b/design/oficina-icons/png/sheets-16.png differ |
diff --git a/design/oficina-icons/png/sheets-180.png b/design/oficina-icons/png/sheets-180.png| new file mode 100644 |
| index 0000000..2487bbb |
| Binary files /dev/null and b/design/oficina-icons/png/sheets-180.png differ |
diff --git a/design/oficina-icons/png/sheets-192.png b/design/oficina-icons/png/sheets-192.png| new file mode 100644 |
| index 0000000..a5ce5fe |
| Binary files /dev/null and b/design/oficina-icons/png/sheets-192.png differ |
diff --git a/design/oficina-icons/png/sheets-32.png b/design/oficina-icons/png/sheets-32.png| new file mode 100644 |
| index 0000000..b99fcb4 |
| Binary files /dev/null and b/design/oficina-icons/png/sheets-32.png differ |
diff --git a/design/oficina-icons/png/sheets-48.png b/design/oficina-icons/png/sheets-48.png| new file mode 100644 |
| index 0000000..c6624d8 |
| Binary files /dev/null and b/design/oficina-icons/png/sheets-48.png differ |
diff --git a/design/oficina-icons/png/sheets-512.png b/design/oficina-icons/png/sheets-512.png| new file mode 100644 |
| index 0000000..73ed474 |
| Binary files /dev/null and b/design/oficina-icons/png/sheets-512.png differ |
diff --git a/design/oficina-icons/png/slides-1024.png b/design/oficina-icons/png/slides-1024.png| new file mode 100644 |
| index 0000000..4967c67 |
| Binary files /dev/null and b/design/oficina-icons/png/slides-1024.png differ |
diff --git a/design/oficina-icons/png/slides-16.png b/design/oficina-icons/png/slides-16.png| new file mode 100644 |
| index 0000000..73f878c |
| Binary files /dev/null and b/design/oficina-icons/png/slides-16.png differ |
diff --git a/design/oficina-icons/png/slides-180.png b/design/oficina-icons/png/slides-180.png| new file mode 100644 |
| index 0000000..eed92dd |
| Binary files /dev/null and b/design/oficina-icons/png/slides-180.png differ |
diff --git a/design/oficina-icons/png/slides-192.png b/design/oficina-icons/png/slides-192.png| new file mode 100644 |
| index 0000000..7a74373 |
| Binary files /dev/null and b/design/oficina-icons/png/slides-192.png differ |
diff --git a/design/oficina-icons/png/slides-32.png b/design/oficina-icons/png/slides-32.png| new file mode 100644 |
| index 0000000..58f126e |
| Binary files /dev/null and b/design/oficina-icons/png/slides-32.png differ |
diff --git a/design/oficina-icons/png/slides-48.png b/design/oficina-icons/png/slides-48.png| new file mode 100644 |
| index 0000000..7a01c0c |
| Binary files /dev/null and b/design/oficina-icons/png/slides-48.png differ |
diff --git a/design/oficina-icons/png/slides-512.png b/design/oficina-icons/png/slides-512.png| new file mode 100644 |
| index 0000000..6908667 |
| Binary files /dev/null and b/design/oficina-icons/png/slides-512.png differ |
diff --git a/design/oficina-icons/preview.png b/design/oficina-icons/preview.png| new file mode 100644 |
| index 0000000..6626cef |
| Binary files /dev/null and b/design/oficina-icons/preview.png differ |
diff --git a/design/oficina-icons/preview.svg b/design/oficina-icons/preview.svg| new file mode 100644 |
| index 0000000..5484fe7 |
| --- /dev/null |
| +++ b/design/oficina-icons/preview.svg |
| @@ -0,0 +1,97 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="790" viewBox="0 0 1400 790" role="img" aria-label="Oficina app icon set"><title>Oficina app icon set</title><rect width="1400" height="790" fill="#F6F5F2"/><g font-family="Arial, sans-serif"><text x="64" y="77" font-size="32" fill="#252932" font-weight="700">Oficina</text><text x="64" y="111" font-size="16" fill="#606571">One family. Seven marks. White Lucide symbols on solid colour.</text><rect x="64" y="165" width="144" height="144" rx="30" fill="#303642"/><g transform="translate(85.6 186.6) scale(4.2)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M9 21V9" /></g><text x="64" y="345" font-size="20" font-weight="700" fill="#252932">Oficina</text><text x="64" y="372" font-size="13" fill="#606571">panels-top-left</text><text x="64" y="395" font-size="13" fill="#606571">#303642</text><rect x="64" y="450" width="48" height="48" rx="10.08" fill="#303642"/><g transform="translate(71.2 457.2) scale(1.3999999999999997)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M9 21V9" /></g><rect x="64" y="525" width="32" height="32" rx="6.72" fill="#303642"/><g transform="translate(68.8 529.8) scale(0.9333333333333332)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M9 21V9" /></g><rect x="64" y="588" width="16" height="16" rx="3.36" fill="#303642"/><g transform="translate(66.4 590.4) scale(0.4666666666666666)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M9 21V9" /></g><rect x="248" y="165" width="144" height="144" rx="30" fill="#2563EB"/><g transform="translate(269.6 186.6) scale(4.2)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> |
| + <path d="M14 2v5a1 1 0 0 0 1 1h5" /> |
| + <path d="M10 9H8" /> |
| + <path d="M16 13H8" /> |
| + <path d="M16 17H8" /></g><text x="248" y="345" font-size="20" font-weight="700" fill="#252932">Docs</text><text x="248" y="372" font-size="13" fill="#606571">file-text</text><text x="248" y="395" font-size="13" fill="#606571">#2563EB</text><rect x="248" y="450" width="48" height="48" rx="10.08" fill="#2563EB"/><g transform="translate(255.2 457.2) scale(1.3999999999999997)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> |
| + <path d="M14 2v5a1 1 0 0 0 1 1h5" /> |
| + <path d="M10 9H8" /> |
| + <path d="M16 13H8" /> |
| + <path d="M16 17H8" /></g><rect x="248" y="525" width="32" height="32" rx="6.72" fill="#2563EB"/><g transform="translate(252.8 529.8) scale(0.9333333333333332)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> |
| + <path d="M14 2v5a1 1 0 0 0 1 1h5" /> |
| + <path d="M10 9H8" /> |
| + <path d="M16 13H8" /> |
| + <path d="M16 17H8" /></g><rect x="248" y="588" width="16" height="16" rx="3.36" fill="#2563EB"/><g transform="translate(250.4 590.4) scale(0.4666666666666666)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> |
| + <path d="M14 2v5a1 1 0 0 0 1 1h5" /> |
| + <path d="M10 9H8" /> |
| + <path d="M16 13H8" /> |
| + <path d="M16 17H8" /></g><rect x="432" y="165" width="144" height="144" rx="30" fill="#168049"/><g transform="translate(453.6 186.6) scale(4.2)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18" /> |
| + <rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M3 15h18" /></g><text x="432" y="345" font-size="20" font-weight="700" fill="#252932">Sheets</text><text x="432" y="372" font-size="13" fill="#606571">table</text><text x="432" y="395" font-size="13" fill="#606571">#168049</text><rect x="432" y="450" width="48" height="48" rx="10.08" fill="#168049"/><g transform="translate(439.2 457.2) scale(1.3999999999999997)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18" /> |
| + <rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M3 15h18" /></g><rect x="432" y="525" width="32" height="32" rx="6.72" fill="#168049"/><g transform="translate(436.8 529.8) scale(0.9333333333333332)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18" /> |
| + <rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M3 15h18" /></g><rect x="432" y="588" width="16" height="16" rx="3.36" fill="#168049"/><g transform="translate(434.4 590.4) scale(0.4666666666666666)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18" /> |
| + <rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M3 15h18" /></g><rect x="616" y="165" width="144" height="144" rx="30" fill="#C45B16"/><g transform="translate(637.6 186.6) scale(4.2)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20" /> |
| + <path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" /> |
| + <path d="m7 21 5-5 5 5" /></g><text x="616" y="345" font-size="20" font-weight="700" fill="#252932">Slides</text><text x="616" y="372" font-size="13" fill="#606571">presentation</text><text x="616" y="395" font-size="13" fill="#606571">#C45B16</text><rect x="616" y="450" width="48" height="48" rx="10.08" fill="#C45B16"/><g transform="translate(623.2 457.2) scale(1.3999999999999997)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20" /> |
| + <path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" /> |
| + <path d="m7 21 5-5 5 5" /></g><rect x="616" y="525" width="32" height="32" rx="6.72" fill="#C45B16"/><g transform="translate(620.8 529.8) scale(0.9333333333333332)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20" /> |
| + <path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" /> |
| + <path d="m7 21 5-5 5 5" /></g><rect x="616" y="588" width="16" height="16" rx="3.36" fill="#C45B16"/><g transform="translate(618.4 590.4) scale(0.4666666666666666)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20" /> |
| + <path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" /> |
| + <path d="m7 21 5-5 5 5" /></g><rect x="800" y="165" width="144" height="144" rx="30" fill="#CB3F50"/><g transform="translate(821.6 186.6) scale(4.2)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3" /> |
| + <path d="M16 2v3" /> |
| + <rect x="3" y="3" width="18" height="18" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M8 13h.01" /> |
| + <path d="M12 13h.01" /> |
| + <path d="M16 13h.01" /> |
| + <path d="M8 17h.01" /> |
| + <path d="M12 17h.01" /> |
| + <path d="M16 17h.01" /></g><text x="800" y="345" font-size="20" font-weight="700" fill="#252932">Calendar</text><text x="800" y="372" font-size="13" fill="#606571">calendar-days</text><text x="800" y="395" font-size="13" fill="#606571">#CB3F50</text><rect x="800" y="450" width="48" height="48" rx="10.08" fill="#CB3F50"/><g transform="translate(807.2 457.2) scale(1.3999999999999997)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3" /> |
| + <path d="M16 2v3" /> |
| + <rect x="3" y="3" width="18" height="18" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M8 13h.01" /> |
| + <path d="M12 13h.01" /> |
| + <path d="M16 13h.01" /> |
| + <path d="M8 17h.01" /> |
| + <path d="M12 17h.01" /> |
| + <path d="M16 17h.01" /></g><rect x="800" y="525" width="32" height="32" rx="6.72" fill="#CB3F50"/><g transform="translate(804.8 529.8) scale(0.9333333333333332)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3" /> |
| + <path d="M16 2v3" /> |
| + <rect x="3" y="3" width="18" height="18" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M8 13h.01" /> |
| + <path d="M12 13h.01" /> |
| + <path d="M16 13h.01" /> |
| + <path d="M8 17h.01" /> |
| + <path d="M12 17h.01" /> |
| + <path d="M16 17h.01" /></g><rect x="800" y="588" width="16" height="16" rx="3.36" fill="#CB3F50"/><g transform="translate(802.4 590.4) scale(0.4666666666666666)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3" /> |
| + <path d="M16 2v3" /> |
| + <rect x="3" y="3" width="18" height="18" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M8 13h.01" /> |
| + <path d="M12 13h.01" /> |
| + <path d="M16 13h.01" /> |
| + <path d="M8 17h.01" /> |
| + <path d="M12 17h.01" /> |
| + <path d="M16 17h.01" /></g><rect x="984" y="165" width="144" height="144" rx="30" fill="#087F8C"/><g transform="translate(1005.6 186.6) scale(4.2)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /> |
| + <rect x="2" y="6" width="14" height="12" rx="2" /></g><text x="984" y="345" font-size="20" font-weight="700" fill="#252932">Meet</text><text x="984" y="372" font-size="13" fill="#606571">video</text><text x="984" y="395" font-size="13" fill="#606571">#087F8C</text><rect x="984" y="450" width="48" height="48" rx="10.08" fill="#087F8C"/><g transform="translate(991.2 457.2) scale(1.3999999999999997)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /> |
| + <rect x="2" y="6" width="14" height="12" rx="2" /></g><rect x="984" y="525" width="32" height="32" rx="6.72" fill="#087F8C"/><g transform="translate(988.8 529.8) scale(0.9333333333333332)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /> |
| + <rect x="2" y="6" width="14" height="12" rx="2" /></g><rect x="984" y="588" width="16" height="16" rx="3.36" fill="#087F8C"/><g transform="translate(986.4 590.4) scale(0.4666666666666666)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /> |
| + <rect x="2" y="6" width="14" height="12" rx="2" /></g><rect x="1168" y="165" width="144" height="144" rx="30" fill="#7C3FC8"/><g transform="translate(1189.6 186.6) scale(4.2)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 22H5.5a1 1 0 0 1 0-5h4.501" /> |
| + <path d="m21 22-1.879-1.878" /> |
| + <path d="M3 19.5v-15A2.5 2.5 0 0 1 5.5 2H18a1 1 0 0 1 1 1v8" /> |
| + <circle cx="17" cy="18" r="3" /></g><text x="1168" y="345" font-size="20" font-weight="700" fill="#252932">Memoria</text><text x="1168" y="372" font-size="13" fill="#606571">book-search</text><text x="1168" y="395" font-size="13" fill="#606571">#7C3FC8</text><rect x="1168" y="450" width="48" height="48" rx="10.08" fill="#7C3FC8"/><g transform="translate(1175.2 457.2) scale(1.3999999999999997)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 22H5.5a1 1 0 0 1 0-5h4.501" /> |
| + <path d="m21 22-1.879-1.878" /> |
| + <path d="M3 19.5v-15A2.5 2.5 0 0 1 5.5 2H18a1 1 0 0 1 1 1v8" /> |
| + <circle cx="17" cy="18" r="3" /></g><rect x="1168" y="525" width="32" height="32" rx="6.72" fill="#7C3FC8"/><g transform="translate(1172.8 529.8) scale(0.9333333333333332)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 22H5.5a1 1 0 0 1 0-5h4.501" /> |
| + <path d="m21 22-1.879-1.878" /> |
| + <path d="M3 19.5v-15A2.5 2.5 0 0 1 5.5 2H18a1 1 0 0 1 1 1v8" /> |
| + <circle cx="17" cy="18" r="3" /></g><rect x="1168" y="588" width="16" height="16" rx="3.36" fill="#7C3FC8"/><g transform="translate(1170.4 590.4) scale(0.4666666666666666)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 22H5.5a1 1 0 0 1 0-5h4.501" /> |
| + <path d="m21 22-1.879-1.878" /> |
| + <path d="M3 19.5v-15A2.5 2.5 0 0 1 5.5 2H18a1 1 0 0 1 1 1v8" /> |
| + <circle cx="17" cy="18" r="3" /></g><text x="64" y="666" font-size="14" fill="#606571">Actual sizes above: 48, 32 and 16 px. Rounded corners are a preview mask only.</text><text x="64" y="733" font-size="14" fill="#606571">Square, opaque masters · Shared padding · 2-unit strokes · No gradients or shadows</text></g></svg> |
diff --git a/design/oficina-icons/svg/calendar.svg b/design/oficina-icons/svg/calendar.svg| new file mode 100644 |
| index 0000000..97108f8 |
| --- /dev/null |
| +++ b/design/oficina-icons/svg/calendar.svg |
| @@ -0,0 +1,10 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Calendar"><title>Calendar</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#CB3F50"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3" /> |
| + <path d="M16 2v3" /> |
| + <rect x="3" y="3" width="18" height="18" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M8 13h.01" /> |
| + <path d="M12 13h.01" /> |
| + <path d="M16 13h.01" /> |
| + <path d="M8 17h.01" /> |
| + <path d="M12 17h.01" /> |
| + <path d="M16 17h.01" /></g></svg> |
diff --git a/design/oficina-icons/svg/docs.svg b/design/oficina-icons/svg/docs.svg| new file mode 100644 |
| index 0000000..9b7f4c6 |
| --- /dev/null |
| +++ b/design/oficina-icons/svg/docs.svg |
| @@ -0,0 +1,5 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Docs"><title>Docs</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#2563EB"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> |
| + <path d="M14 2v5a1 1 0 0 0 1 1h5" /> |
| + <path d="M10 9H8" /> |
| + <path d="M16 13H8" /> |
| + <path d="M16 17H8" /></g></svg> |
diff --git a/design/oficina-icons/svg/meet.svg b/design/oficina-icons/svg/meet.svg| new file mode 100644 |
| index 0000000..a027150 |
| --- /dev/null |
| +++ b/design/oficina-icons/svg/meet.svg |
| @@ -0,0 +1,2 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Meet"><title>Meet</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#087F8C"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /> |
| + <rect x="2" y="6" width="14" height="12" rx="2" /></g></svg> |
diff --git a/design/oficina-icons/svg/memoria.svg b/design/oficina-icons/svg/memoria.svg| new file mode 100644 |
| index 0000000..6d7c4c8 |
| --- /dev/null |
| +++ b/design/oficina-icons/svg/memoria.svg |
| @@ -0,0 +1,4 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Memoria"><title>Memoria</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#7C3FC8"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 22H5.5a1 1 0 0 1 0-5h4.501" /> |
| + <path d="m21 22-1.879-1.878" /> |
| + <path d="M3 19.5v-15A2.5 2.5 0 0 1 5.5 2H18a1 1 0 0 1 1 1v8" /> |
| + <circle cx="17" cy="18" r="3" /></g></svg> |
diff --git a/design/oficina-icons/svg/oficina.svg b/design/oficina-icons/svg/oficina.svg| new file mode 100644 |
| index 0000000..f30e545 |
| --- /dev/null |
| +++ b/design/oficina-icons/svg/oficina.svg |
| @@ -0,0 +1,3 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Oficina"><title>Oficina</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#303642"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M9 21V9" /></g></svg> |
diff --git a/design/oficina-icons/svg/sheets.svg b/design/oficina-icons/svg/sheets.svg| new file mode 100644 |
| index 0000000..3739ca0 |
| --- /dev/null |
| +++ b/design/oficina-icons/svg/sheets.svg |
| @@ -0,0 +1,4 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Sheets"><title>Sheets</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#168049"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18" /> |
| + <rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M3 15h18" /></g></svg> |
diff --git a/design/oficina-icons/svg/slides.svg b/design/oficina-icons/svg/slides.svg| new file mode 100644 |
| index 0000000..ea6ead1 |
| --- /dev/null |
| +++ b/design/oficina-icons/svg/slides.svg |
| @@ -0,0 +1,3 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Slides"><title>Slides</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#C45B16"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20" /> |
| + <path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" /> |
| + <path d="m7 21 5-5 5 5" /></g></svg> |
diff --git a/design/oficina-icons/upstream/book-search.svg b/design/oficina-icons/upstream/book-search.svg| new file mode 100644 |
| index 0000000..3c1e1ad |
| --- /dev/null |
| +++ b/design/oficina-icons/upstream/book-search.svg |
| @@ -0,0 +1,16 @@ |
| +<svg |
| + xmlns="http://www.w3.org/2000/svg" |
| + width="24" |
| + height="24" |
| + viewBox="0 0 24 24" |
| + fill="none" |
| + stroke="currentColor" |
| + stroke-width="2" |
| + stroke-linecap="round" |
| + stroke-linejoin="round" |
| +> |
| + <path d="M11 22H5.5a1 1 0 0 1 0-5h4.501" /> |
| + <path d="m21 22-1.879-1.878" /> |
| + <path d="M3 19.5v-15A2.5 2.5 0 0 1 5.5 2H18a1 1 0 0 1 1 1v8" /> |
| + <circle cx="17" cy="18" r="3" /> |
| +</svg> |
diff --git a/design/oficina-icons/upstream/calendar-days.svg b/design/oficina-icons/upstream/calendar-days.svg| new file mode 100644 |
| index 0000000..78c5420 |
| --- /dev/null |
| +++ b/design/oficina-icons/upstream/calendar-days.svg |
| @@ -0,0 +1,22 @@ |
| +<svg |
| + xmlns="http://www.w3.org/2000/svg" |
| + width="24" |
| + height="24" |
| + viewBox="0 0 24 24" |
| + fill="none" |
| + stroke="currentColor" |
| + stroke-width="2" |
| + stroke-linecap="round" |
| + stroke-linejoin="round" |
| +> |
| + <path d="M8 2v3" /> |
| + <path d="M16 2v3" /> |
| + <rect x="3" y="3" width="18" height="18" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M8 13h.01" /> |
| + <path d="M12 13h.01" /> |
| + <path d="M16 13h.01" /> |
| + <path d="M8 17h.01" /> |
| + <path d="M12 17h.01" /> |
| + <path d="M16 17h.01" /> |
| +</svg> |
diff --git a/design/oficina-icons/upstream/file-text.svg b/design/oficina-icons/upstream/file-text.svg| new file mode 100644 |
| index 0000000..67dc5b9 |
| --- /dev/null |
| +++ b/design/oficina-icons/upstream/file-text.svg |
| @@ -0,0 +1,17 @@ |
| +<svg |
| + xmlns="http://www.w3.org/2000/svg" |
| + width="24" |
| + height="24" |
| + viewBox="0 0 24 24" |
| + fill="none" |
| + stroke="currentColor" |
| + stroke-width="2" |
| + stroke-linecap="round" |
| + stroke-linejoin="round" |
| +> |
| + <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> |
| + <path d="M14 2v5a1 1 0 0 0 1 1h5" /> |
| + <path d="M10 9H8" /> |
| + <path d="M16 13H8" /> |
| + <path d="M16 17H8" /> |
| +</svg> |
diff --git a/design/oficina-icons/upstream/panels-top-left.svg b/design/oficina-icons/upstream/panels-top-left.svg| new file mode 100644 |
| index 0000000..574e4de |
| --- /dev/null |
| +++ b/design/oficina-icons/upstream/panels-top-left.svg |
| @@ -0,0 +1,15 @@ |
| +<svg |
| + xmlns="http://www.w3.org/2000/svg" |
| + width="24" |
| + height="24" |
| + viewBox="0 0 24 24" |
| + fill="none" |
| + stroke="currentColor" |
| + stroke-width="2" |
| + stroke-linecap="round" |
| + stroke-linejoin="round" |
| +> |
| + <rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M9 21V9" /> |
| +</svg> |
diff --git a/design/oficina-icons/upstream/presentation.svg b/design/oficina-icons/upstream/presentation.svg| new file mode 100644 |
| index 0000000..7795726 |
| --- /dev/null |
| +++ b/design/oficina-icons/upstream/presentation.svg |
| @@ -0,0 +1,15 @@ |
| +<svg |
| + xmlns="http://www.w3.org/2000/svg" |
| + width="24" |
| + height="24" |
| + viewBox="0 0 24 24" |
| + fill="none" |
| + stroke="currentColor" |
| + stroke-width="2" |
| + stroke-linecap="round" |
| + stroke-linejoin="round" |
| +> |
| + <path d="M2 3h20" /> |
| + <path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" /> |
| + <path d="m7 21 5-5 5 5" /> |
| +</svg> |
diff --git a/design/oficina-icons/upstream/table.svg b/design/oficina-icons/upstream/table.svg| new file mode 100644 |
| index 0000000..725f23c |
| --- /dev/null |
| +++ b/design/oficina-icons/upstream/table.svg |
| @@ -0,0 +1,16 @@ |
| +<svg |
| + xmlns="http://www.w3.org/2000/svg" |
| + width="24" |
| + height="24" |
| + viewBox="0 0 24 24" |
| + fill="none" |
| + stroke="currentColor" |
| + stroke-width="2" |
| + stroke-linecap="round" |
| + stroke-linejoin="round" |
| +> |
| + <path d="M12 3v18" /> |
| + <rect width="18" height="18" x="3" y="3" rx="2" /> |
| + <path d="M3 9h18" /> |
| + <path d="M3 15h18" /> |
| +</svg> |
diff --git a/design/oficina-icons/upstream/video.svg b/design/oficina-icons/upstream/video.svg| new file mode 100644 |
| index 0000000..c198f49 |
| --- /dev/null |
| +++ b/design/oficina-icons/upstream/video.svg |
| @@ -0,0 +1,14 @@ |
| +<svg |
| + xmlns="http://www.w3.org/2000/svg" |
| + width="24" |
| + height="24" |
| + viewBox="0 0 24 24" |
| + fill="none" |
| + stroke="currentColor" |
| + stroke-width="2" |
| + stroke-linecap="round" |
| + stroke-linejoin="round" |
| +> |
| + <path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /> |
| + <rect x="2" y="6" width="14" height="12" rx="2" /> |
| +</svg> |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index 9888270..f4a7dba 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -1,5 +1,14 @@ |
| # 🤖 Meet — design |
| +**2026-09-12 sharing update:** Paul's request for Share in the meeting view |
| +supersedes the invitation-token show-once rule below (§3.1 / ruling 16). |
| +Members may retrieve a current invitation via `POST /m/{sqid}/share`. |
| +Tokens remain hash-indexed, with an encrypted copy under the meeting-link |
| +purpose key. Existing hash-only links are preserved; first sharing creates a |
| +recoverable invitation alongside them. Replacement revokes all live links, |
| +and ending prevents further sharing. Token-bearing responses are `no-store`. |
| +Agent credentials remain hash-only and shown once. |
| + |
| Design, 2026-09-07. Direction approved by Paul in conversation; this is the |
| written form. **Revision 9**, 2026-09-10: slice 2 built the room, and |
| §15 records the ten places where what it built departs from revision 8, |
| @@ -2121,4 +2130,3 @@ truncated before the `~` — the badge's icon needs no separate |
| every icon "sits beside its own visible text label") is already |
| satisfied by the badge's own word, alongside AGENTS.md's own line — |
| "State — never colour alone — always a text label too." |
| - |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index 3a7957a..a670bed 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -444,6 +444,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Group(func(r chi.Router) { |
| r.Use(authPlugin.RequireSession) |
| r.Use(a.requirePerson) |
| + r.Get("/", a.index) |
| r.Get("/native/complete", a.nativeComplete) |
| r.Get("/native/authorize", a.nativeAuthorize) |
| r.Post("/native/authorize", a.nativeApprove) |
| @@ -464,9 +465,9 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Get("/account/home/confirm", a.home.bridge.ConfirmPage) |
| r.Post("/account/home/confirm", a.home.routes.ConfirmPost) |
| } |
| - r.Get("/", a.index) |
| r.Post("/meetings/start", a.startCall) |
| r.Post("/meetings", a.createMeeting) |
| + r.Post("/m/{sqid}/share", a.shareMeeting) |
| r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink) |
| r.Post("/meetings/{sqid}/links", a.replaceLink) |
| r.Get("/meetings/{sqid}/end", a.confirmEnd) |
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go| index a764386..a606213 100644 |
| --- a/internal/meet/handlers_meetings.go |
| +++ b/internal/meet/handlers_meetings.go |
| @@ -58,6 +58,11 @@ func (a *app) fmtStart(t *time.Time) string { |
| } |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| + p, ok := currentPerson(r) |
| + if !ok || !a.isMember(p) { |
| + http.Redirect(w, r, "/welcome", http.StatusSeeOther) |
| + return |
| + } |
| a.renderIndex(w, r, "", "") |
| } |
| @@ -143,8 +148,7 @@ func (a *app) startCall(w http.ResponseWriter, r *http.Request) { |
| } |
| // renderLinkOnce answers with a page, not a redirect: a token in a |
| -// query string lands in the edge's access logs (Sheets' ruling), and |
| -// the page is the one time the token exists in plaintext. |
| +// query string lands in the edge's access logs (Sheets' ruling). |
| func (a *app) renderLinkOnce(w http.ResponseWriter, r *http.Request, m Meeting, token string, replaced bool) { |
| w.Header().Set("Cache-Control", "no-store") |
| render(w, "link_once", a.page(r, map[string]any{ |
| @@ -153,6 +157,32 @@ func (a *app) renderLinkOnce(w http.ResponseWriter, r *http.Request, m Meeting, |
| })) |
| } |
| +func (a *app) shareMeeting(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + p, _ := currentPerson(r) |
| + token, err := a.store.shareLink(r.Context(), m.ID, actorFor(p)) |
| + if errors.Is(err, errMeetingEnded) { |
| + a.ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + if err != nil { |
| + a.logger.Error("meet: share meeting", "err", err) |
| + a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Referrer-Policy", "no-referrer") |
| + link := a.linkURL(token) |
| + if r.Header.Get("Accept") == "application/json" { |
| + streamJSON(w, http.StatusOK, map[string]any{"url": link}) |
| + return |
| + } |
| + render(w, "share", a.page(r, map[string]any{"Title": a.store.title(m), "Link": link})) |
| +} |
| + |
| // meetingForMember loads the meeting in the URL or answers 404. |
| func (a *app) meetingForMember(w http.ResponseWriter, r *http.Request) (Meeting, bool) { |
| m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid")) |
| @@ -175,7 +205,7 @@ func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) { |
| } |
| render(w, "confirm", a.page(r, map[string]any{ |
| "Title": "Replace the link?", |
| - "Body": "Everyone holding the current link for \"" + a.store.title(m) + "\" will get a not-found page. You will see the new link once.", |
| + "Body": "Everyone holding a current link for \"" + a.store.title(m) + "\" will get a not-found page. You can share the replacement from the meeting.", |
| "Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link", |
| "CancelHref": "/", "Sqid": m.Sqid, |
| })) |
diff --git a/internal/meet/home_router_test.go b/internal/meet/home_router_test.go| index 4382892..71058ca 100644 |
| --- a/internal/meet/home_router_test.go |
| +++ b/internal/meet/home_router_test.go |
| @@ -236,7 +236,10 @@ func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) { |
| if w := f.do("GET", landing, ""); w.Code != 200 { |
| t.Fatal("holding", w.Code) |
| } |
| - for _, path := range []string{"/", "/account/home"} { |
| + if w := f.do("GET", "/", ""); w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/welcome" { |
| + t.Fatal("guest start page did not lead to welcome", w.Code) |
| + } |
| + for _, path := range []string{"/account/home"} { |
| if w := f.do("GET", path, ""); w.Code != 404 { |
| t.Fatal("guest gained member privilege", path, w.Code) |
| } |
diff --git a/internal/meet/ids.go b/internal/meet/ids.go| index f7da34b..a4adcb6 100644 |
| --- a/internal/meet/ids.go |
| +++ b/internal/meet/ids.go |
| @@ -38,8 +38,8 @@ func newSqid() (string, error) { |
| const linkTokenBytes = 32 |
| -// newLinkToken mints a token and its hash. The token is returned |
| -// exactly once, to be shown exactly once (ruling 16). |
| +// newLinkToken mints a token and its lookup hash. The store seals invitations |
| +// that need to be shared again; callers never persist the plaintext token. |
| func newLinkToken() (token, hash string, err error) { |
| buf := make([]byte, linkTokenBytes) |
| if _, err := rand.Read(buf); err != nil { |
diff --git a/internal/meet/migrations/0003_alter_meeting_links.sql b/internal/meet/migrations/0003_alter_meeting_links.sql| new file mode 100644 |
| index 0000000..41420f9 |
| --- /dev/null |
| +++ b/internal/meet/migrations/0003_alter_meeting_links.sql |
| @@ -0,0 +1,2 @@ |
| +ALTER TABLE `meeting_links` ADD `token_sealed` blob; |
| + |
diff --git a/internal/meet/migrations/schema.sql b/internal/meet/migrations/schema.sql| index 03338d5..33efceb 100644 |
| --- a/internal/meet/migrations/schema.sql |
| +++ b/internal/meet/migrations/schema.sql |
| @@ -5,7 +5,7 @@ CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NU |
| CREATE TABLE `guest_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`person_id` integer NOT NULL,`granted_at` datetime,`expires_at` datetime,`admitted_at` datetime,`admitted_by` text); |
| -CREATE TABLE `meeting_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`token_hash` text NOT NULL,`created_by` text NOT NULL,`created_at` datetime,`revoked_at` datetime); |
| +CREATE TABLE `meeting_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`token_hash` text NOT NULL,`created_by` text NOT NULL,`created_at` datetime,`revoked_at` datetime, `token_sealed` blob); |
| CREATE TABLE `meetings` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text NOT NULL,`title_sealed` blob,`starts_at` datetime,`created_by` text NOT NULL,`created_at` datetime,`ended_at` datetime,`ended_by` text); |
diff --git a/internal/meet/models.go b/internal/meet/models.go| index 773506a..45e41c9 100644 |
| --- a/internal/meet/models.go |
| +++ b/internal/meet/models.go |
| @@ -43,16 +43,16 @@ type Meeting struct { |
| EndedBy string |
| } |
| -// MeetingLink holds the SHA-256 of a token that was shown once |
| -// (ruling 16). Several may exist per meeting so a revoked one can be |
| -// replaced. |
| +// MeetingLink looks up invitations by hash. New tokens are also sealed so |
| +// members can share the current invitation again; older rows remain hash-only. |
| type MeetingLink struct { |
| - ID int64 |
| - MeetingID int64 `gorm:"index;not null"` |
| - TokenHash string `gorm:"uniqueIndex;not null"` |
| - CreatedBy string `gorm:"not null"` |
| - CreatedAt time.Time |
| - RevokedAt *time.Time |
| + ID int64 |
| + MeetingID int64 `gorm:"index;not null"` |
| + TokenHash string `gorm:"uniqueIndex;not null"` |
| + TokenSealed []byte |
| + CreatedBy string `gorm:"not null"` |
| + CreatedAt time.Time |
| + RevokedAt *time.Time |
| } |
| // GuestGrant is a non-member's entry authority for one meeting. One |
diff --git a/internal/meet/render.go b/internal/meet/render.go| index 9682040..0060189 100644 |
| --- a/internal/meet/render.go |
| +++ b/internal/meet/render.go |
| @@ -18,7 +18,7 @@ var appFS embed.FS |
| var assets = rastrillo.NewAssets(appFS) |
| -var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home", "native"} |
| +var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home", "native", "share"} |
| var pages = map[string]*template.Template{} |
diff --git a/internal/meet/seal/seal.go b/internal/meet/seal/seal.go| index bc0a9a3..8224e4e 100644 |
| --- a/internal/meet/seal/seal.go |
| +++ b/internal/meet/seal/seal.go |
| @@ -29,6 +29,7 @@ const label = "oficina/meet:" |
| const ( |
| PurposeMeetingTitle = "meeting.title" |
| + PurposeMeetingLink = "meeting.link" |
| PurposePendingAddress = "pending.address" |
| ) |
diff --git a/internal/meet/static/brand/LICENSE.lucide b/internal/meet/static/brand/LICENSE.lucide| new file mode 100644 |
| index 0000000..718bb3f |
| --- /dev/null |
| +++ b/internal/meet/static/brand/LICENSE.lucide |
| @@ -0,0 +1,43 @@ |
| +ISC License |
| + |
| +Copyright (c) 2026 Lucide Icons and Contributors |
| + |
| +Permission to use, copy, modify, and/or distribute this software for any |
| +purpose with or without fee is hereby granted, provided that the above |
| +copyright notice and this permission notice appear in all copies. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES |
| +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF |
| +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR |
| +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES |
| +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN |
| +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF |
| +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
| + |
| +--- |
| + |
| +The following Lucide icons are derived from the Feather project: |
| + |
| +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out |
| + |
| +The MIT License (MIT) (for the icons listed above) |
| + |
| +Copyright (c) 2013-present Cole Bemis |
| + |
| +Permission is hereby granted, free of charge, to any person obtaining a copy |
| +of this software and associated documentation files (the "Software"), to deal |
| +in the Software without restriction, including without limitation the rights |
| +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| +copies of the Software, and to permit persons to whom the Software is |
| +furnished to do so, subject to the following conditions: |
| + |
| +The above copyright notice and this permission notice shall be included in all |
| +copies or substantial portions of the Software. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| +SOFTWARE. |
diff --git a/internal/meet/static/brand/apple-touch-icon.png b/internal/meet/static/brand/apple-touch-icon.png| new file mode 100644 |
| index 0000000..1354068 |
| Binary files /dev/null and b/internal/meet/static/brand/apple-touch-icon.png differ |
diff --git a/internal/meet/static/brand/brand.css b/internal/meet/static/brand/brand.css| new file mode 100644 |
| index 0000000..2e194c6 |
| --- /dev/null |
| +++ b/internal/meet/static/brand/brand.css |
| @@ -0,0 +1,19 @@ |
| +/* Oficina brand artwork. Source: oficina/estilo brand/. */ |
| +[estilo-brand] { |
| + display: inline-flex; |
| + align-items: center; |
| + gap: .5rem; |
| + min-block-size: 24px; |
| +} |
| +[estilo-brand-icon] { |
| + display: inline-block; |
| + flex-shrink: 0; |
| + inline-size: 1.75rem; |
| + block-size: 1.75rem; |
| + border-radius: 21%; |
| + vertical-align: middle; |
| +} |
| +[estilo-brand-icon="listing"] { |
| + inline-size: 2.5rem; |
| + block-size: 2.5rem; |
| +} |
diff --git a/internal/meet/static/brand/icon-32.png b/internal/meet/static/brand/icon-32.png| new file mode 100644 |
| index 0000000..e5b0218 |
| Binary files /dev/null and b/internal/meet/static/brand/icon-32.png differ |
diff --git a/internal/meet/static/brand/icon.svg b/internal/meet/static/brand/icon.svg| new file mode 100644 |
| index 0000000..a027150 |
| --- /dev/null |
| +++ b/internal/meet/static/brand/icon.svg |
| @@ -0,0 +1,2 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Meet"><title>Meet</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#087F8C"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /> |
| + <rect x="2" y="6" width="14" height="12" rx="2" /></g></svg> |
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css| index 81019b1..6b4fa68 100644 |
| --- a/internal/meet/static/meet.css |
| +++ b/internal/meet/static/meet.css |
| @@ -138,8 +138,9 @@ |
| font-weight: 600; |
| } |
| -.meet-tile-self video { |
| - /* A self view reads as a mirror; every other tile does not. */ |
| +.meet-preview, |
| +.meet-tile-self:not(.meet-tile-share) video { |
| + /* Mirror only our camera display, never remote video or shared screens. */ |
| transform: scaleX(-1); |
| } |
diff --git a/internal/meet/static/share-link.js b/internal/meet/static/share-link.js| new file mode 100644 |
| index 0000000..ed110c6 |
| --- /dev/null |
| +++ b/internal/meet/static/share-link.js |
| @@ -0,0 +1,26 @@ |
| +(() => { |
| + 'use strict'; |
| + const link = document.getElementById('invitation-link'); |
| + const actions = document.getElementById('share-actions'); |
| + const status = document.getElementById('share-status'); |
| + if (!link || !actions || !status) return; |
| + actions.hidden = false; |
| + document.getElementById('copy-invitation').addEventListener('click', async () => { |
| + try { |
| + await navigator.clipboard.writeText(link.href); |
| + status.textContent = 'Link copied.'; |
| + } catch { |
| + status.textContent = 'Select the invitation above and copy it.'; |
| + } |
| + }); |
| + if (navigator.share) { |
| + const share = document.getElementById('share-invitation'); |
| + share.hidden = false; |
| + share.addEventListener('click', async () => { |
| + try { await navigator.share({ url: link.href }); } |
| + catch (error) { |
| + if (error.name !== 'AbortError') status.textContent = 'Could not share. You can copy the link instead.'; |
| + } |
| + }); |
| + } |
| +})(); |
diff --git a/internal/meet/store.go b/internal/meet/store.go| index b7626e4..e1f0a1c 100644 |
| --- a/internal/meet/store.go |
| +++ b/internal/meet/store.go |
| @@ -101,8 +101,7 @@ func (s *store) title(m Meeting) string { |
| } |
| // createMeeting creates the meeting and its first link in one |
| -// transaction and returns the token, the only time it exists in |
| -// plaintext on the server side. |
| +// transaction. The token is sealed for subsequent member sharing. |
| func (s *store) createMeeting(ctx context.Context, title string, startsAt *time.Time, actor string) (Meeting, string, error) { |
| sealed, err := s.sealer.SealString(seal.PurposeMeetingTitle, title) |
| if err != nil { |
| @@ -112,6 +111,10 @@ func (s *store) createMeeting(ctx context.Context, title string, startsAt *time. |
| if err != nil { |
| return Meeting{}, "", err |
| } |
| + linkSealed, err := s.sealer.SealString(seal.PurposeMeetingLink, token) |
| + if err != nil { |
| + return Meeting{}, "", err |
| + } |
| var m Meeting |
| err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| for attempt := 0; ; attempt++ { |
| @@ -131,7 +134,7 @@ func (s *store) createMeeting(ctx context.Context, title string, startsAt *time. |
| return errors.New("meet: could not mint a unique sqid") |
| } |
| } |
| - return tx.Create(&MeetingLink{MeetingID: m.ID, TokenHash: hash, CreatedBy: actor, CreatedAt: s.now().UTC()}).Error |
| + return tx.Create(&MeetingLink{MeetingID: m.ID, TokenHash: hash, TokenSealed: linkSealed, CreatedBy: actor, CreatedAt: s.now().UTC()}).Error |
| }) |
| if err != nil { |
| return Meeting{}, "", err |
| @@ -270,7 +273,7 @@ func (s *store) liveLinkRow(ctx context.Context, l MeetingLink) (MeetingLink, Me |
| var errMeetingEnded = errors.New("meet: the meeting has ended") |
| // replaceLink revokes every live link for the meeting and mints one |
| -// new one, in one transaction, returning the new token once. |
| +// new one, in one transaction. The replacement is sealed for repeat sharing. |
| // |
| // The meeting's ended_at is re-read inside that transaction, matched in |
| // the same WHERE as the id: a meeting row is never deleted, so the one |
| @@ -283,6 +286,10 @@ func (s *store) replaceLink(ctx context.Context, meetingID int64, actor string) |
| if err != nil { |
| return "", err |
| } |
| + sealed, err := s.sealer.SealString(seal.PurposeMeetingLink, token) |
| + if err != nil { |
| + return "", err |
| + } |
| now := s.now().UTC() |
| err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| var m Meeting |
| @@ -295,7 +302,7 @@ func (s *store) replaceLink(ctx context.Context, meetingID int64, actor string) |
| if err := tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error; err != nil { |
| return err |
| } |
| - return tx.Create(&MeetingLink{MeetingID: meetingID, TokenHash: hash, CreatedBy: actor, CreatedAt: now}).Error |
| + return tx.Create(&MeetingLink{MeetingID: meetingID, TokenHash: hash, TokenSealed: sealed, CreatedBy: actor, CreatedAt: now}).Error |
| }) |
| if err != nil { |
| return "", err |
| @@ -303,6 +310,45 @@ func (s *store) replaceLink(ctx context.Context, meetingID int64, actor string) |
| return token, nil |
| } |
| +// shareLink is a member action. Older hash-only invitations cannot be opened; |
| +// mint one recoverable invitation alongside them, then reuse it. Replacement |
| +// and ending still revoke every live invitation in their own transactions. |
| +func (s *store) shareLink(ctx context.Context, meetingID int64, actor string) (string, error) { |
| + var token string |
| + err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + var m Meeting |
| + if err := tx.Where("id = ? AND ended_at IS NULL", meetingID).Take(&m).Error; err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return errMeetingEnded |
| + } |
| + return err |
| + } |
| + var link MeetingLink |
| + err := tx.Where("meeting_id = ? AND revoked_at IS NULL AND token_sealed IS NOT NULL", meetingID).Order("id DESC").Take(&link).Error |
| + if err == nil { |
| + token, err = s.sealer.OpenString(seal.PurposeMeetingLink, link.TokenSealed) |
| + if err != nil || token == "" || hashLinkToken(token) != link.TokenHash { |
| + return errors.New("meet: cannot open invitation") |
| + } |
| + return nil |
| + } |
| + if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + return err |
| + } |
| + var hash string |
| + token, hash, err = newLinkToken() |
| + if err != nil { |
| + return err |
| + } |
| + sealed, err := s.sealer.SealString(seal.PurposeMeetingLink, token) |
| + if err != nil { |
| + return err |
| + } |
| + return tx.Create(&MeetingLink{MeetingID: meetingID, TokenHash: hash, TokenSealed: sealed, CreatedBy: actor, CreatedAt: s.now().UTC()}).Error |
| + }) |
| + return token, err |
| +} |
| + |
| // endMeeting sets ended_at and revokes every link, and reports whether |
| // this call is the one that ended the meeting. False means the |
| // conditional UPDATE matched no row — the meeting was already ended, |
diff --git a/internal/meet/store_share_test.go b/internal/meet/store_share_test.go| new file mode 100644 |
| index 0000000..bbd0c3f |
| --- /dev/null |
| +++ b/internal/meet/store_share_test.go |
| @@ -0,0 +1,94 @@ |
| +package meet |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestShareReusesSealedInvitationAndPreservesLegacyLinks(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + m, original, err := s.createMeeting(ctx, "Share test", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + shared, err := s.shareLink(ctx, m.ID, "person:2") |
| + if err != nil || shared != original { |
| + t.Fatalf("share original: %v", err) |
| + } |
| + link, _, _, err := s.liveLink(ctx, shared) |
| + if err != nil || len(link.TokenSealed) == 0 || bytes.Contains(link.TokenSealed, []byte(shared)) { |
| + t.Fatal("invitation was not sealed") |
| + } |
| + // Model a migrated meeting: its original token cannot be recovered. |
| + if err := s.g.Model(&MeetingLink{}).Where("id = ?", link.ID).Update("token_sealed", nil).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + results := make(chan string, 8) |
| + failures := make(chan error, 8) |
| + for i := 0; i < 8; i++ { |
| + go func() { |
| + token, err := s.shareLink(ctx, m.ID, "person:1") |
| + results <- token |
| + failures <- err |
| + }() |
| + } |
| + replacement := "" |
| + for i := 0; i < 8; i++ { |
| + token := <-results |
| + if err := <-failures; err != nil { |
| + t.Fatal(err) |
| + } |
| + if replacement == "" { |
| + replacement = token |
| + } |
| + if token != replacement || token == original { |
| + t.Fatal("concurrent shares did not reuse one new invitation") |
| + } |
| + } |
| + for _, token := range []string{original, replacement} { |
| + if _, _, live, err := s.liveLink(ctx, token); err != nil || !live { |
| + t.Fatal("sharing invalidated a current invitation") |
| + } |
| + } |
| + var count int64 |
| + if err := s.g.Model(&MeetingLink{}).Where("meeting_id = ?", m.ID).Count(&count).Error; err != nil || count != 2 { |
| + t.Fatalf("links = %d, error %v", count, err) |
| + } |
| + fresh, err := s.replaceLink(ctx, m.ID, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, token := range []string{original, replacement} { |
| + if _, _, live, _ := s.liveLink(ctx, token); live { |
| + t.Fatal("replacement failed to revoke an invitation") |
| + } |
| + } |
| + shared, err = s.shareLink(ctx, m.ID, "person:1") |
| + if err != nil || shared != fresh { |
| + t.Fatal("share did not use the replacement") |
| + } |
| + if _, err := s.endMeeting(ctx, m.ID, "person:1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.shareLink(ctx, m.ID, "person:1"); !errors.Is(err, errMeetingEnded) { |
| + t.Fatalf("sharing ended meeting: %v", err) |
| + } |
| +} |
| + |
| +func TestShareRefusesCorruptedSealedInvitation(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + m, _, err := s.createMeeting(ctx, "Share test", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.g.Model(&MeetingLink{}).Where("meeting_id = ?", m.ID).Update("token_sealed", []byte("broken")).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.shareLink(ctx, m.ID, "person:1"); err == nil { |
| + t.Fatal("corrupt invitation was silently replaced") |
| + } |
| +} |
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html| index 1a61704..63cfc31 100644 |
| --- a/internal/meet/templates/join.html |
| +++ b/internal/meet/templates/join.html |
| @@ -8,6 +8,7 @@ |
| {{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| <p>You are {{.Name}}, joining as a {{.Standing}}.</p> |
| {{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| +{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share</button></form>{{end}} |
| {{if .OpenElsewhere}}<p rst-notice>This meeting is already open in another tab. <a href="/m/{{.Sqid}}/takeover">Use this tab instead</a>.</p>{{end}} |
| <noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.</p></noscript> |
| <div id="devices" data-sqid="{{.Sqid}}" data-join-href="{{if .OpenElsewhere}}/m/{{.Sqid}}/takeover{{else}}/m/{{.Sqid}}{{end}}" hidden> |
diff --git a/internal/meet/templates/layout.html b/internal/meet/templates/layout.html| index b3f019f..04b6d35 100644 |
| --- a/internal/meet/templates/layout.html |
| +++ b/internal/meet/templates/layout.html |
| @@ -1,6 +1,10 @@ |
| {{define "layout"}}<!doctype html> |
| <html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"> |
| <head> |
| +<link rel="icon" type="image/png" sizes="32x32" href="{{asset "static/brand/icon-32.png"}}"> |
| +<link rel="icon" type="image/svg+xml" href="{{asset "static/brand/icon.svg"}}"> |
| +<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}"> |
| +<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>{{block "title" .}}Meet{{end}}</title> |
| @@ -15,7 +19,7 @@ |
| <div rst-shell-topbar> |
| <a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a> |
| <header rst-shell-bar> |
| -<a rst-shell-brand href="/">Meet</a> |
| +<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Meet</a> |
| {{/* The account corner is the only thing this shell's tail ever |
| carries — Meet has no nav and no locale menu yet — so the whole |
| menu+tail pair is skipped outright for a signed-out request rather |
diff --git a/internal/meet/templates/link_once.html b/internal/meet/templates/link_once.html| index bfee90e..9f7c1f3 100644 |
| --- a/internal/meet/templates/link_once.html |
| +++ b/internal/meet/templates/link_once.html |
| @@ -3,7 +3,7 @@ |
| {{template "back-nav" dict "Href" "/" "Label" "Meetings"}} |
| {{template "page-header" dict "Title" .Title}} |
| {{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| -<p>{{if .Replaced}}The old link has stopped working. {{end}}This is the only time this link is shown. Copy it now and send it to the people you are meeting.</p> |
| +<p>{{if .Replaced}}The old links have stopped working. {{end}}Copy this link and send it to the people you are meeting. You can find it again using Share in the meeting.</p> |
| <p><input rst-input readonly value="{{.Link}}" size="80" aria-label="Meeting link"></p> |
| <p><a href="/m/{{.Sqid}}/join" rst-btn="primary">Go to the meeting</a></p> |
| {{end}} |
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html| index 7a45699..5f4b1cf 100644 |
| --- a/internal/meet/templates/room.html |
| +++ b/internal/meet/templates/room.html |
| @@ -10,6 +10,7 @@ |
| {{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| <p>You are {{.Name}}, joining as a {{.Standing}}.</p> |
| {{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| +{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share</button></form>{{end}} |
| <noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p></noscript> |
| <div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-relay="{{if .Relay}}1{{end}}" data-peer="" hidden> |
| <div id="tiles" class="meet-grid"></div> |
diff --git a/internal/meet/templates/share.html b/internal/meet/templates/share.html| new file mode 100644 |
| index 0000000..45b5465 |
| --- /dev/null |
| +++ b/internal/meet/templates/share.html |
| @@ -0,0 +1,14 @@ |
| +{{define "title"}}Share meeting{{end}} |
| +{{define "head"}}<script defer src="{{asset "static/share-link.js"}}"></script>{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict "Title" "Share meeting"}} |
| +<p>{{.Title}}</p> |
| +<p>Send this invitation to the people you want to join. Guests will wait to be let in.</p> |
| +<p><a id="invitation-link" href="{{.Link}}" style="overflow-wrap: anywhere">{{.Link}}</a></p> |
| +<div id="share-actions" hidden> |
| + <button type="button" id="copy-invitation" rst-btn>Copy link</button> |
| + <button type="button" id="share-invitation" rst-btn hidden>Share…</button> |
| +</div> |
| +<p id="share-status" role="status"></p> |
| +<p>You can close this tab when you’ve shared the invitation.</p> |
| +{{end}} |
diff --git a/internal/meettest/iphone_browser_test.go b/internal/meettest/iphone_browser_test.go| new file mode 100644 |
| index 0000000..55f8298 |
| --- /dev/null |
| +++ b/internal/meettest/iphone_browser_test.go |
| @@ -0,0 +1,102 @@ |
| +//go:build browser && darwin |
| + |
| +package meettest |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The iPhone UI talks to the same temporary server as the browser suite. Email |
| +// is captured locally. Nothing contacts production or sends actual mail. |
| +func TestIPhoneSafariAuthentication(t *testing.T) { |
| + simulator := os.Getenv("MEET_IOS_SIMULATOR") |
| + if simulator == "" { |
| + t.Skip("set MEET_IOS_SIMULATOR to an available iPhone simulator UUID") |
| + } |
| + for _, guest := range []bool{false, true} { |
| + name := "member" |
| + if guest { |
| + name = "guest" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + rig := newBrowserRig(t) |
| + room, token := rig.createMeeting(t) |
| + target := "/m/" + room |
| + if guest { |
| + rig.ctl.SetAllowlist(memberA) |
| + target = "/j/" + token |
| + } |
| + before := rig.mail.count() |
| + control := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "application/json") |
| + link := "" |
| + if rig.mail.count() > before { |
| + to, value := rig.mail.last() |
| + if to == memberB { |
| + link = value |
| + } |
| + } |
| + json.NewEncoder(w).Encode(map[string]string{"link": link}) |
| + })) |
| + defer control.Close() |
| + root := repoRootForBrowser(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) |
| + defer cancel() |
| + results := filepath.Join(root, "native/build/iphone-auth-"+name+"-"+time.Now().Format("150405")+".xcresult") |
| + cmd := exec.CommandContext(ctx, "xcodebuild", "-project", filepath.Join(root, "native/ios/MeetPhone.xcodeproj"), "-scheme", "MeetPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-derivedDataPath", filepath.Join(root, "native/build/ios-simulator"), "-resultBundlePath", results, "CODE_SIGNING_ALLOWED=NO", "-only-testing:MeetPhoneUITests/MeetPhoneUITests/testEmailBrowserHandoff", "test") |
| + cmd.Env = append(os.Environ(), "TEST_RUNNER_MEET_TEST_MEETING="+rig.origin+target, "TEST_RUNNER_MEET_TEST_CONTROL="+control.URL, "TEST_RUNNER_MEET_TEST_EMAIL="+memberB) |
| + // Xcode's detailed trace contains only synthetic test identities and is |
| + // retained locally for diagnosis, rather than flooding the normal gate log. |
| + logPath := filepath.Join(root, "native/build/iphone-auth-"+name+".log") |
| + log, err := os.Create(logPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer log.Close() |
| + cmd.Stdout = log |
| + cmd.Stderr = log |
| + if err := cmd.Run(); err != nil { |
| + t.Fatalf("iPhone Safari %s: %v (details: %s)", name, err, logPath) |
| + } |
| + t.Log("Safari email handoff passed on iPhone simulator:", name) |
| + }) |
| + } |
| +} |
| + |
| +func TestIPhoneNativeMedia(t *testing.T) { |
| + simulator := os.Getenv("MEET_IOS_SIMULATOR") |
| + if simulator == "" { |
| + t.Skip("set MEET_IOS_SIMULATOR to an available iPhone simulator UUID") |
| + } |
| + rig := newBrowserRig(t) |
| + room, _ := rig.createMeeting(t) |
| + rig.ctl.SetStreamTimings(time.Second, 3*time.Second) |
| + cookiesA, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieA}}) |
| + cookiesB, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| + fixture, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookiesA": string(cookiesA), "cookiesB": string(cookiesB)}) |
| + root := repoRootForBrowser(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, "xcodebuild", "-project", filepath.Join(root, "native/ios/MeetPhone.xcodeproj"), "-scheme", "MeetPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-derivedDataPath", filepath.Join(root, "native/build/ios-simulator"), "CODE_SIGNING_ALLOWED=NO", "-only-testing:MeetPhoneTests/MeetPhoneTests/testNativeVideoAndRenewal", "test") |
| + cmd.Env = append(os.Environ(), "TEST_RUNNER_MEET_TEST_MEDIA="+string(fixture)) |
| + logPath := filepath.Join(root, "native/build/iphone-media.log") |
| + log, err := os.Create(logPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer log.Close() |
| + cmd.Stdout = log |
| + cmd.Stderr = log |
| + if err := cmd.Run(); err != nil { |
| + t.Fatalf("iPhone native media: %v (details: %s)", err, logPath) |
| + } |
| + t.Log("Two iPhone-native WebRTC peers decoded video, kept their peers through stream renewals, and left cleanly") |
| +} |
diff --git a/internal/meettest/join_test.go b/internal/meettest/join_test.go| index b752440..e988a1c 100644 |
| --- a/internal/meettest/join_test.go |
| +++ b/internal/meettest/join_test.go |
| @@ -70,7 +70,7 @@ func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) { |
| if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK { |
| t.Fatalf("holding page as guest: %d", rec.Code) |
| } |
| - if rec := g.get("/"); rec.Code != http.StatusNotFound { |
| + if rec := g.get("/"); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" { |
| t.Fatal("guest can see the meeting list") |
| } |
| } |
diff --git a/internal/meettest/native_browser_test.go b/internal/meettest/native_browser_test.go| index 8c39cdf..0c35f5b 100644 |
| --- a/internal/meettest/native_browser_test.go |
| +++ b/internal/meettest/native_browser_test.go |
| @@ -22,10 +22,14 @@ func TestNativeMacCallsBrowser(t *testing.T) { |
| if guest { |
| name = "guest" |
| } |
| - t.Run(name, func(t *testing.T) { nativeMacCallsBrowser(t, guest) }) |
| + t.Run(name, func(t *testing.T) { nativeMacCallsBrowser(t, guest, false) }) |
| } |
| } |
| -func nativeMacCallsBrowser(t *testing.T, guest bool) { |
| +func TestNativeMacChat(t *testing.T) { |
| + nativeMacCallsBrowser(t, false, true) |
| +} |
| + |
| +func nativeMacCallsBrowser(t *testing.T, guest, chat bool) { |
| rig := newBrowserRig(t) |
| room, token := rig.createMeeting(t) |
| mode := "member" |
| @@ -46,7 +50,13 @@ func nativeMacCallsBrowser(t *testing.T, guest bool) { |
| t.Fatalf("guest link: %d", res.StatusCode) |
| } |
| } |
| - rig.ctl.SetStreamTimings(time.Second, 3*time.Second) |
| + // Leave time for the chat exchange before exercising stream renewal. |
| + // Chat is intentionally not replayed to a detached recipient. |
| + if !chat { |
| + rig.ctl.SetStreamTimings(time.Second, 10*time.Second) |
| + } else { |
| + mode = "chat" |
| + } |
| root, err := filepath.Abs("../..") |
| if err != nil { |
| t.Fatal(err) |
| @@ -59,7 +69,11 @@ func nativeMacCallsBrowser(t *testing.T, guest bool) { |
| defer cancel() |
| cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native.mjs"), rig.origin, room, binary, mode) |
| cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| - input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)}) |
| + fixture := map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies), "invitation": rig.origin + "/j/" + token} |
| + if chat { |
| + fixture["chatSnapshot"] = filepath.Join(root, "native/build/chat-incoming.png") |
| + } |
| + input, _ := json.Marshal(fixture) |
| cmd.Env = append(os.Environ(), "MEET_COOKIE_NAME="+rig.cookieName, "MEET_COOKIE_A="+rig.cookieA, "MEET_NATIVE_INPUT="+string(input)) |
| out, err := cmd.StdoutPipe() |
| if err != nil { |
diff --git a/internal/meettest/share_test.go b/internal/meettest/share_test.go| new file mode 100644 |
| index 0000000..e15f98a |
| --- /dev/null |
| +++ b/internal/meettest/share_test.go |
| @@ -0,0 +1,57 @@ |
| +package meettest |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestShareMeetingRequiresMemberAndKeepsInvitation(t *testing.T) { |
| + x := newHarness(t, "paul@example.com", "bea@example.com") |
| + creator := x.browser(t) |
| + creator.signIn("paul@example.com") |
| + sqid, token := createMeeting(t, creator, "Share this meeting", "") |
| + member := x.browser(t) |
| + member.signIn("bea@example.com") |
| + route := "/m/" + sqid + "/share" |
| + page := member.post(route, nil) |
| + if page.Code != 200 || !strings.Contains(page.Body.String(), token) || page.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatalf("member share page: %d", page.Code) |
| + } |
| + req := httptest.NewRequest("POST", route, strings.NewReader(`{}`)) |
| + req.Header.Set("Origin", testOrigin) |
| + req.Header.Set("Accept", "application/json") |
| + response := member.do(req) |
| + var result struct { |
| + URL string `json:"url"` |
| + } |
| + if response.Code != 200 || json.Unmarshal(response.Body.Bytes(), &result) != nil || result.URL != testOrigin+"/j/"+token { |
| + t.Fatal("native share did not return the current invitation") |
| + } |
| + if response.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("native share may be cached") |
| + } |
| + outsider := x.browser(t) |
| + outsider.get("/j/" + token) |
| + outsider.signIn("guest@example.com") |
| + if rec := outsider.get("/m/" + sqid + "/join"); rec.Code != 200 { |
| + t.Fatalf("guest fixture: %d", rec.Code) |
| + } |
| + if rec := outsider.post(route, nil); rec.Code != http.StatusNotFound { |
| + t.Fatalf("guest share: %d", rec.Code) |
| + } |
| + req = httptest.NewRequest("POST", route, nil) |
| + req.Header.Set("Origin", "https://elsewhere.example") |
| + if rec := member.do(req); rec.Code != http.StatusForbidden { |
| + t.Fatalf("cross-site share: %d", rec.Code) |
| + } |
| + if rec := member.post("/m/not-a-meeting/share", nil); rec.Code != 404 { |
| + t.Fatalf("missing meeting: %d", rec.Code) |
| + } |
| + creator.post("/meetings/"+sqid+"/end", nil) |
| + if rec := member.post(route, nil); rec.Code != 404 { |
| + t.Fatalf("ended share: %d", rec.Code) |
| + } |
| +} |
diff --git a/internal/meettest/signin_test.go b/internal/meettest/signin_test.go| index e2e055f..cd2a00b 100644 |
| --- a/internal/meettest/signin_test.go |
| +++ b/internal/meettest/signin_test.go |
| @@ -58,15 +58,15 @@ func TestNonMemberSessionSeesWelcomeNotTheList(t *testing.T) { |
| t.Fatalf("non-member landed on %s, want /welcome", dest) |
| } |
| rec := c.get("/") |
| - if rec.Code != http.StatusNotFound { |
| - t.Fatalf("non-member GET /: %d, want 404", rec.Code) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" { |
| + t.Fatalf("non-member GET /: %d %s, want 303 /welcome", rec.Code, rec.Header().Get("Location")) |
| } |
| - if !strings.Contains(rec.Body.String(), "rst-error") { |
| - t.Fatal("404 does not render the error-page partial") |
| - } |
| - if rec := c.get("/welcome"); !strings.Contains(rec.Body.String(), "anyone@example.com") { |
| + if rec := c.get("/welcome"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "anyone@example.com") { |
| t.Fatal("welcome page does not name the address") |
| } |
| + if rec := c.post("/meetings/start", nil); rec.Code != http.StatusNotFound { |
| + t.Fatalf("guest can access member action: %d", rec.Code) |
| + } |
| } |
| // Codex round 2, C2-4: mailredact_test.go proves the wrapper is safe by |
diff --git a/meetcore/room.go b/meetcore/room.go| index 9f9be93..b298496 100644 |
| --- a/meetcore/room.go |
| +++ b/meetcore/room.go |
| @@ -21,6 +21,7 @@ type Event struct { |
| State *Snapshot `json:"state,omitempty"` |
| Message *protocol.Message `json:"message,omitempty"` |
| ICE *ICEServers `json:"ice,omitempty"` |
| + Signal *protocol.Signal `json:"signal,omitempty"` |
| } |
| type Snapshot struct { |
| Phase string `json:"phase"` |
| @@ -76,7 +77,11 @@ func (r *Room) snapshotLocked() Snapshot { |
| return s |
| } |
| func (r *Room) Snapshot() Snapshot { r.mu.Lock(); defer r.mu.Unlock(); return r.snapshotLocked() } |
| -func (r *Room) publish() { s := r.Snapshot(); r.emit(Event{Type: "state", State: &s}) } |
| + |
| +// ShareLink asks the server for a current invitation using this session's |
| +// authority. It is blocking and belongs on the host's background executor. |
| +func (r *Room) ShareLink() (string, error) { return r.t.shareLink(r.ctx) } |
| +func (r *Room) publish() { s := r.Snapshot(); r.emit(Event{Type: "state", State: &s}) } |
| func (r *Room) Start() error { |
| r.mu.Lock() |
| if r.started || r.stopped { |
| @@ -433,16 +438,36 @@ func (r *Room) current(s protocol.Signal) bool { |
| return !r.stopped && s.Peer == r.state.Peer && (!targeted(s.Type) || (r.generationLocked(s.To) == s.Gen && !r.blocked[s.To])) |
| } |
| func (r *Room) sendLoop(l *lane) { |
| + // Cancellation removes this lane under mu before any new Send can use it. |
| + // Report every accepted chat still waiting in its queue; the UI has already |
| + // cleared the composer and must retain a visible failure instead of losing it. |
| + defer func() { |
| + for { |
| + select { |
| + case s := <-l.queue: |
| + if s.Type == protocol.TypeChat { |
| + r.emit(Event{Type: "send-failed", Signal: &s}) |
| + } |
| + default: |
| + return |
| + } |
| + } |
| + }() |
| for { |
| + if l.ctx.Err() != nil { |
| + return |
| + } |
| select { |
| case <-l.ctx.Done(): |
| return |
| case s := <-l.queue: |
| ctx, cancel := context.WithTimeout(l.ctx, 30*time.Second) |
| delay := 500 * time.Millisecond |
| + accepted := false |
| for r.current(s) { |
| err := r.t.post(ctx, s) |
| if err == nil { |
| + accepted = true |
| break |
| } |
| var refusal *HTTPError |
| @@ -468,6 +493,16 @@ func (r *Room) sendLoop(l *lane) { |
| delay = min(delay*2, 5*time.Second) |
| } |
| cancel() |
| + // Chat is not echoed by the hub. Report the HTTP outcome, not |
| + // merely queue acceptance, so native clients can show sent lines |
| + // and make failures visible without claiming delivery to peers. |
| + if s.Type == protocol.TypeChat { |
| + typ := "send-failed" |
| + if accepted { |
| + typ = "sent" |
| + } |
| + r.emit(Event{Type: typ, Signal: &s}) |
| + } |
| } |
| } |
| } |
diff --git a/meetcore/room_test.go b/meetcore/room_test.go| index 1e4e76b..617f50e 100644 |
| --- a/meetcore/room_test.go |
| +++ b/meetcore/room_test.go |
| @@ -12,6 +12,115 @@ import ( |
| "amadan.net/oficina/meet/protocol" |
| ) |
| +func TestChatOutcomeWaitsForServer(t *testing.T) { |
| + for _, status := range []int{http.StatusOK, http.StatusBadRequest, http.StatusRequestEntityTooLarge} { |
| + t.Run(fmt.Sprint(status), func(t *testing.T) { |
| + received := make(chan protocol.Signal, 1) |
| + release := make(chan struct{}) |
| + events := make(chan Event, 1) |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + var signal protocol.Signal |
| + if err := json.NewDecoder(req.Body).Decode(&signal); err != nil { |
| + t.Error(err) |
| + } |
| + received <- signal |
| + select { |
| + case <-release: |
| + w.WriteHeader(status) |
| + case <-req.Context().Done(): |
| + } |
| + })) |
| + defer srv.Close() |
| + r, err := New(srv.URL, "room", srv.Client(), func(e Event) { events <- e }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.started = true |
| + r.state.Peer = "self" |
| + defer r.Close() |
| + const payload = `{"text":"hello"}` |
| + if err := r.Send(protocol.TypeChat, "", 0, payload); err != nil { |
| + t.Fatal(err) |
| + } |
| + select { |
| + case signal := <-received: |
| + if signal.Type != protocol.TypeChat || signal.To != "" || signal.Seq != 0 || signal.Gen != 0 { |
| + t.Fatalf("invalid broadcast: %+v", signal) |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("chat was not posted") |
| + } |
| + select { |
| + case e := <-events: |
| + t.Fatalf("outcome before server response: %+v", e) |
| + default: |
| + } |
| + close(release) |
| + select { |
| + case e := <-events: |
| + want := "send-failed" |
| + if status == http.StatusOK { |
| + want = "sent" |
| + } |
| + if e.Type != want || e.Signal == nil || string(e.Signal.Payload) != payload { |
| + t.Fatalf("outcome = %+v, want %s with original text", e, want) |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("missing chat outcome") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestReregisterReportsEveryQueuedChatAsFailed(t *testing.T) { |
| + received := make(chan struct{}, 1) |
| + events := make(chan Event, 8) |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + // Hold the first POST so the remaining chats must still be queued. |
| + var signal protocol.Signal |
| + if err := json.NewDecoder(req.Body).Decode(&signal); err != nil { |
| + t.Error(err) |
| + } |
| + received <- struct{}{} |
| + <-req.Context().Done() |
| + })) |
| + defer server.Close() |
| + r, err := New(server.URL, "room", server.Client(), func(e Event) { |
| + if e.Signal != nil { |
| + events <- e |
| + } |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + r.started = true |
| + r.state.Peer = "old" |
| + for i := range 3 { |
| + if err := r.Send(protocol.TypeChat, "", 0, fmt.Sprintf(`{"text":"message %d"}`, i)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if i == 0 { |
| + select { |
| + case <-received: |
| + case <-time.After(time.Second): |
| + t.Fatal("first chat was not posted") |
| + } |
| + } |
| + } |
| + r.fresh() |
| + r.Close() // Wait for every cancellation callback, including the queue drain. |
| + if len(events) != 3 { |
| + t.Fatalf("got %d outcomes, want all three accepted chats", len(events)) |
| + } |
| + for i := range 3 { |
| + e := <-events |
| + if e.Type != "send-failed" || string(e.Signal.Payload) != fmt.Sprintf(`{"text":"message %d"}`, i) { |
| + t.Fatalf("outcome %d lost its text or order: %+v", i, e) |
| + } |
| + } |
| +} |
| + |
| func TestResumeRetainsParticipationAndDedupe(t *testing.T) { |
| var mu sync.Mutex |
| opens, joins := 0, 0 |
diff --git a/meetcore/transport.go b/meetcore/transport.go| index d29e437..78db9e1 100644 |
| --- a/meetcore/transport.go |
| +++ b/meetcore/transport.go |
| @@ -7,6 +7,7 @@ import ( |
| "bufio" |
| "bytes" |
| "context" |
| + "encoding/base64" |
| "encoding/json" |
| "errors" |
| "fmt" |
| @@ -211,3 +212,29 @@ func (t *transport) info(ctx context.Context) (Info, error) { |
| } |
| return info, nil |
| } |
| + |
| +func (t *transport) shareLink(ctx context.Context) (string, error) { |
| + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| + defer cancel() |
| + res, err := t.request(ctx, http.MethodPost, "share", nil, []byte(`{}`)) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer res.Body.Close() |
| + var result struct { |
| + URL string `json:"url"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&result); err != nil { |
| + return "", errors.New("invalid meeting invitation") |
| + } |
| + prefix := t.origin + "/j/" |
| + if !strings.HasPrefix(result.URL, prefix) { |
| + return "", errors.New("invalid meeting invitation") |
| + } |
| + token := strings.TrimPrefix(result.URL, prefix) |
| + decoded, err := base64.RawURLEncoding.DecodeString(token) |
| + if err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != token { |
| + return "", errors.New("invalid meeting invitation") |
| + } |
| + return result.URL, nil |
| +} |
diff --git a/meetcore/transport_test.go b/meetcore/transport_test.go| index 9c6b94a..54b9d74 100644 |
| --- a/meetcore/transport_test.go |
| +++ b/meetcore/transport_test.go |
| @@ -1,6 +1,7 @@ |
| package meetcore |
| import ( |
| + "encoding/json" |
| "errors" |
| "io" |
| "net/http" |
| @@ -11,6 +12,46 @@ import ( |
| "amadan.net/oficina/meet/protocol" |
| ) |
| +func TestShareInvitationAcceptsOnlySameServerInvitationURLs(t *testing.T) { |
| + const token = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" |
| + for _, shape := range []string{"valid", "foreign", "room", "fragment", "refused"} { |
| + t.Run(shape, func(t *testing.T) { |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + if req.Method != http.MethodPost || req.URL.Path != "/m/room/share" || req.Header.Get("Accept") != "application/json" { |
| + t.Error("wrong invitation request") |
| + } |
| + origin := req.Header.Get("Origin") |
| + link := origin + "/j/" + token |
| + switch shape { |
| + case "foreign": |
| + link = "https://elsewhere.example/j/" + token |
| + case "room": |
| + link = origin + "/m/room" |
| + case "fragment": |
| + link += "#unexpected" |
| + case "refused": |
| + w.WriteHeader(http.StatusForbidden) |
| + } |
| + json.NewEncoder(w).Encode(map[string]string{"url": link}) |
| + })) |
| + defer srv.Close() |
| + r, err := New(srv.URL, "room", srv.Client(), func(Event) {}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + link, err := r.ShareLink() |
| + if shape == "valid" { |
| + if err != nil || link != srv.URL+"/j/"+token { |
| + t.Fatalf("valid invitation: %v", err) |
| + } |
| + } else if err == nil || link != "" { |
| + t.Fatal("invalid or refused invitation accepted") |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestSSEFramingAndBounds(t *testing.T) { |
| for _, tc := range []struct { |
| name, input string |
diff --git a/native/LICENSE.lucide b/native/LICENSE.lucide| new file mode 100644 |
| index 0000000..718bb3f |
| --- /dev/null |
| +++ b/native/LICENSE.lucide |
| @@ -0,0 +1,43 @@ |
| +ISC License |
| + |
| +Copyright (c) 2026 Lucide Icons and Contributors |
| + |
| +Permission to use, copy, modify, and/or distribute this software for any |
| +purpose with or without fee is hereby granted, provided that the above |
| +copyright notice and this permission notice appear in all copies. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES |
| +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF |
| +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR |
| +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES |
| +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN |
| +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF |
| +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
| + |
| +--- |
| + |
| +The following Lucide icons are derived from the Feather project: |
| + |
| +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out |
| + |
| +The MIT License (MIT) (for the icons listed above) |
| + |
| +Copyright (c) 2013-present Cole Bemis |
| + |
| +Permission is hereby granted, free of charge, to any person obtaining a copy |
| +of this software and associated documentation files (the "Software"), to deal |
| +in the Software without restriction, including without limitation the rights |
| +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| +copies of the Software, and to permit persons to whom the Software is |
| +furnished to do so, subject to the following conditions: |
| + |
| +The above copyright notice and this permission notice shall be included in all |
| +copies or substantial portions of the Software. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| +SOFTWARE. |
diff --git a/native/README.md b/native/README.md| index 9b4db88..c096b7c 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -1,8 +1,6 @@ |
| -# Meet for Mac — first native milestone |
| +# Meet native apps |
| -This work starts at `eee686a` (`integrate/home-sso-release`), which integrates |
| -Home assertions with the current call and agent API. `origin/main` was still |
| -`a283c53` when fetched. The native app is a SwiftUI client of the same server. |
| +The SwiftUI Mac and iPhone apps use the same Meet server and shared Go core. |
| ## Build and run |
| @@ -17,7 +15,11 @@ Paste a meeting or invitation URL. Meet opens your default browser for the |
| existing Home/Meet sign-in. Open the emailed link normally, compare the code in |
| the browser with the app, and confirm **Sign in to Meet app**. The app continues |
| automatically; choose **Join meeting** and allow camera/microphone access. |
| -Meeting management stays on the web for this milestone. |
| +Creating and ending meetings, and replacing invitations, stay on the web for this milestone. |
| + |
| +Members can use **Share** in the call to retrieve the current invitation, copy |
| +it, or open the system sharing menu. This needs the server's |
| +`POST /m/{sqid}/share` endpoint and the encrypted-invitation migration. |
| There are no embedded webviews. SwiftUI renders the app and native WebRTC handles |
| media. The shared Go core waits for a five-minute, one-use authorization bound |
| @@ -28,12 +30,13 @@ authentication type is introduced. Credentials stay in memory for this milestone |
| **The server must include this branch's native authorization endpoints.** An |
| older server cannot complete native sign-in; the app reports that limitation. |
| -No production deployment has been performed. In-progress handoffs expire on |
| +The production handoff endpoints were deployed in `86ed9f3`. In-progress handoffs expire on |
| server restart. There are at most 256 pending handoffs per server process. |
| `make native-mac` builds for the host Mac and signs the local `.app` ad hoc. |
| -Developer ID signing, notarization, App Store packaging and an iOS target are |
| -not part of this first milestone. |
| +Developer ID signing, notarization and App Store packaging remain follow-up work. |
| +The iPhone target, device builds and Safari tests are described in |
| +[ios/README.md](ios/README.md). |
| ## Shared code |
| @@ -52,7 +55,8 @@ not part of this first milestone. |
| Go Mobile's cgo requirement is isolated in `native/go.mod` and the native build |
| script. The existing server continues to build with `CGO_ENABLED=0`. WebRTC is |
| pinned to stasel/WebRTC 152.0.0; SwiftPM verifies its published artifact checksum. |
| -Go Mobile is also pinned. Generated frameworks and binaries are ignored. |
| +Go Mobile is also pinned. The bridge includes Mac, iPhone and iOS Simulator |
| +slices. Generated frameworks, Xcode projects and binaries are ignored. |
| ## Verification |
| @@ -69,13 +73,28 @@ The native/browser gate requires the same Playwright/Chromium setup as |
| `make browser`, and working UDP. It creates two real magic-link sessions and a |
| meeting on an isolated server, starts Chromium and the separate native test |
| executable, then checks guest admission, actual incoming/outgoing media, clean departure and continuity through |
| -three-second SSE renewals. It never needs production credentials or hardware |
| +ten-second SSE renewals, bidirectional chat and the native sender's accepted-message |
| +history. Test speaker playback is disabled in both clients; audio checks use RTP |
| +counters, not audible output. It never needs production credentials or hardware |
| camera access. The Go Home test separately exercises a session minted through |
| Home assertions and explicit identity linking, including resume and meeting end. |
| +The native chat gate sends a sequence of browser messages and renders the shared |
| +native chat view to `native/build/chat-incoming.png` for visual inspection of the |
| +latest message. The window stays off-screen. Native chat follows new messages |
| +and keeps its history visible during signaling reconnects. |
| + |
| There is no claim that a simulator or synthetic test proves physical camera, |
| microphone permissions, audio-device switching, sleep/wake or Home passkeys. |
| Those need a real-device acceptance pass. Native screen sharing, background |
| calling and long-call TURN renewal parity are follow-up work. |
| See [VERIFICATION.md](VERIFICATION.md) for the completed gate record and acceptance limits. |
| + |
| +### App artwork |
| + |
| +The iPhone asset catalog and Mac application resource use the approved Oficina |
| +Meet icon. Both are generated copies of Estilo's `brand/png/meet-1024.png`; |
| +update them with `python3 brand/distribute.py meet /path/to/meet` from Estilo. |
| +The Mac sets its Dock icon from the packaged resource on launch. The Lucide |
| +notice is retained in `native/LICENSE.lucide`. |
diff --git a/native/VERIFICATION.md b/native/VERIFICATION.md| index 0506700..88c52c5 100644 |
| --- a/native/VERIFICATION.md |
| +++ b/native/VERIFICATION.md |
| @@ -1,3 +1,35 @@ |
| +# Native completion verification — 2026-09-12 |
| + |
| +Branch: `codex/iphone-and-mirrored-preview`, based on `86ed9f3`. |
| + |
| +- Final `make ci`, server race gate, and shared-core race gate pass. |
| +- Full Chromium call gate passes (539 seconds) for this web snapshot. |
| +- Final native/browser gate passes (81 seconds), including member/guest media, |
| + browser authorization, and bidirectional chat. |
| +- iPhone Safari member/guest authorization and native peer media tests pass |
| + on iOS 26.5 Simulator (154 seconds). Peers retain media through stream renewal |
| + and leave cleanly. Simulator media uses synthetic video and disables audio. |
| +- Mac app builds, its ad hoc signature verifies, and it is installed and |
| + relaunched at `/Applications/Meet.app` with the approved icon and license. |
| +- iPhone development build signs successfully with the personal development |
| + team and is installed on Paul's iPhone 16 Pro. Initial launch was blocked by |
| + the phone's lock screen; physical launch/permissions and audible audio await |
| + an unlocked-device acceptance pass. |
| +- Independent GPT-6 Astra source review approved the local-build milestone |
| + after fixing cancellation outcomes for queued chat and obsolete join/leave |
| + operations. A regression test verifies all accepted queued chat gets an |
| + explicit failure on re-registration; it passes under the race detector. |
| + |
| +The server update includes member-only repeat sharing of encrypted invitations |
| +and its additive migration. Older invitations remain valid. Native credentials |
| +remain in memory; calls are foreground-only on iPhone. Developer ID notarization, |
| +TestFlight, physical audio/device routing, background calling and native screen |
| +sharing remain outside this local-install milestone. |
| + |
| +The earlier milestone record follows for historical context. |
| + |
| +--- |
| + |
| # Mac milestone verification — 2026-09-11 |
| Base: fetched `origin/main` at `a283c53`, then fast-forwarded the working branch |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| index 1099984..a933498 100644 |
| --- a/native/apple/Package.swift |
| +++ b/native/apple/Package.swift |
| @@ -3,8 +3,9 @@ import PackageDescription |
| let package = Package( |
| name: "MeetNative", |
| - platforms: [.macOS(.v14)], |
| + platforms: [.macOS(.v14), .iOS(.v17)], |
| products: [ |
| + .library(name: "MeetKit", targets: ["MeetKit"]), |
| .executable(name: "MeetMac", targets: ["MeetMac"]), |
| .executable(name: "MeetInterop", targets: ["MeetInterop"]), |
| ], |
| @@ -15,7 +16,7 @@ let package = Package( |
| .binaryTarget(name: "MeetBridge", path: "../build/MeetBridge.xcframework"), |
| .target( |
| name: "MeetKit", dependencies: ["MeetBridge", .product(name: "WebRTC", package: "WebRTC")]), |
| - .executableTarget(name: "MeetMac", dependencies: ["MeetKit"]), |
| + .executableTarget(name: "MeetMac", dependencies: ["MeetKit"], resources: [.copy("Resources/AppIcon.png")]), |
| .executableTarget(name: "MeetInterop", dependencies: ["MeetKit"]), |
| ] |
| ) |
diff --git a/native/apple/Sources/MeetInterop/Interop.swift b/native/apple/Sources/MeetInterop/Interop.swift| index 6043735..465a6a6 100644 |
| --- a/native/apple/Sources/MeetInterop/Interop.swift |
| +++ b/native/apple/Sources/MeetInterop/Interop.swift |
| @@ -1,5 +1,7 @@ |
| import Foundation |
| import MeetKit |
| +import AppKit |
| +import SwiftUI |
| // A separate test executable: synthetic video and fixture credentials are never |
| // compiled into MeetMac. Input travels on stdin, not argv or a saved file. |
| @@ -7,6 +9,8 @@ struct Input: Decodable { |
| let origin: String |
| let room: String |
| let cookies: String |
| + let chatSnapshot: String? |
| + let invitation: String? |
| } |
| @main struct Interop { |
| @MainActor static func main() async { |
| @@ -14,7 +18,8 @@ struct Input: Decodable { |
| let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)), |
| let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1" |
| else { exit(2) } |
| - let call = Call() |
| + let call = Call(playAudio: false) |
| + let preview = input.chatSnapshot.map { ChatPreview(call: call, path: $0) } |
| do { |
| try await call.join( |
| origin: input.origin, room: input.room, cookies: input.cookies, capture: SyntheticVideo()) |
| @@ -23,9 +28,34 @@ struct Input: Decodable { |
| exit(1) |
| } |
| Task.detached { |
| - if let command = readLine(), command == "leave" { |
| - await call.leave() |
| - exit(0) |
| + while let command = readLine() { |
| + if command == "leave" { |
| + await call.leave() |
| + exit(0) |
| + } |
| + if command == "chat" { |
| + await call.sendChat("native chat probe") |
| + print("{\"chatCommandHandled\":true}") |
| + fflush(stdout) |
| + } |
| + if command == "snapshot" { |
| + let saved = await preview?.save() ?? false |
| + print("{\"chatSnapshotSaved\":\(saved)}") |
| + fflush(stdout) |
| + } |
| + if command == "share" { |
| + await call.loadInvitation() |
| + let invitation = await call.invitationURL?.absoluteString |
| + let refused = await !call.invitationError.isEmpty |
| + let ready = invitation != nil && invitation == input.invitation |
| + print("{\"invitationReady\":\(ready),\"invitationRefused\":\(refused)}") |
| + fflush(stdout) |
| + } |
| + if command == "share-snapshot" { |
| + let saved = await preview?.saveInvitation(call: call) ?? false |
| + print("{\"invitationSnapshotSaved\":\(saved)}") |
| + fflush(stdout) |
| + } |
| } |
| } |
| for _ in 0..<90 { |
| @@ -33,6 +63,9 @@ struct Input: Decodable { |
| let stats = await call.sampleStats() |
| if let data = try? JSONSerialization.data(withJSONObject: [ |
| "phase": call.state?.phase ?? "", "peer": call.state?.peer ?? "", "stats": stats, |
| + "receivedChatProbe": call.chat.contains { $0.hasSuffix(": browser chat probe") }, |
| + "sentChatProbe": call.chat.contains("You: native chat probe"), |
| + "receivedChatCount": call.receivedChatCount, |
| ]), let text = String(data: data, encoding: .utf8) { |
| print(text) |
| fflush(stdout) |
| @@ -42,3 +75,42 @@ struct Input: Decodable { |
| exit(0) |
| } |
| } |
| + |
| +// Render the shipping chat view against real received messages. This fixture |
| +// window sits off-screen and never activates or interrupts the user's call. |
| +@MainActor private final class ChatPreview { |
| + let window: NSWindow |
| + let view: NSHostingView<AnyView> |
| + let path: String |
| + init(call: Call, path: String) { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + self.path = path |
| + view = NSHostingView(rootView: AnyView( |
| + ChatView(call: call).padding(12).background(Color.white).environment(\.colorScheme, .light))) |
| + window = NSWindow( |
| + contentRect: NSRect(x: -2000, y: -2000, width: 600, height: 200), |
| + styleMask: [.borderless], backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.contentView = view |
| + window.orderFront(nil) |
| + } |
| + func save() -> Bool { |
| + view.layoutSubtreeIfNeeded() |
| + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } |
| + view.cacheDisplay(in: view.bounds, to: bitmap) |
| + guard let data = bitmap.representation(using: .png, properties: [:]) else { return false } |
| + do { try data.write(to: URL(fileURLWithPath: path)); return true } catch { return false } |
| + } |
| + func saveInvitation(call: Call) async -> Bool { |
| + view.rootView = AnyView(MeetingInvitationView(call: call, close: {}) |
| + .background(Color.white).environment(\.colorScheme, .light)) |
| + window.setContentSize(NSSize(width: 440, height: 340)) |
| + try? await Task.sleep(for: .milliseconds(250)) |
| + view.layoutSubtreeIfNeeded() |
| + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } |
| + view.cacheDisplay(in: view.bounds, to: bitmap) |
| + guard let data = bitmap.representation(using: .png, properties: [:]) else { return false } |
| + let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent("share-meeting.png") |
| + do { try data.write(to: target); return true } catch { return false } |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/Call.swift b/native/apple/Sources/MeetKit/Call.swift| index f4d07d8..a1fdfe9 100644 |
| --- a/native/apple/Sources/MeetKit/Call.swift |
| +++ b/native/apple/Sources/MeetKit/Call.swift |
| @@ -14,9 +14,11 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| self.epoch = epoch |
| } |
| func onEvent(_ event: String?) { |
| - guard let event, let data = event.data(using: .utf8), |
| - let decoded = try? JSONDecoder().decode(WireEvent.self, from: data) |
| - else { return } |
| + guard let event, let data = event.data(using: .utf8) else { return } |
| + guard let decoded = try? JSONDecoder().decode(WireEvent.self, from: data) else { |
| + DispatchQueue.main.async { [weak self] in self?.call?.invalidEvents += 1 } |
| + return |
| + } |
| DispatchQueue.main.async { [weak self] in |
| guard let self, let call = self.call, call.epoch == self.epoch else { return } |
| call.receive(decoded) |
| @@ -35,19 +37,37 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| @Published public private(set) var cameraOff = false |
| @Published public private(set) var floor = false |
| @Published public private(set) var chat: [String] = [] |
| + @Published public private(set) var receivedChatCount = 0 |
| + @Published public private(set) var invitationURL: URL? |
| + @Published public private(set) var invitationError = "" |
| + @Published public private(set) var loadingInvitation = false |
| fileprivate var epoch = UUID() |
| private var bridge: BridgeClient? |
| private var events: Events? |
| private lazy var media = MediaEngine(call: self) |
| private var ice: ICEConfig? |
| private var pendingMedia: [Message] = [] |
| + fileprivate var invalidEvents: UInt64 = 0 |
| + let playAudio: Bool |
| - public init() {} |
| + #if os(iOS) |
| + let customAudioDevice: RTCAudioDevice? |
| + public init(audioDevice: RTCAudioDevice? = nil, playAudio: Bool = true) { |
| + customAudioDevice = audioDevice |
| + self.playAudio = playAudio |
| + } |
| + #else |
| + public init(playAudio: Bool = true) { self.playAudio = playAudio } |
| + #endif |
| public func join(origin: String, room: String, cookies: String, capture: VideoCapture? = nil) |
| async throws |
| { |
| - if bridge != nil { await leave() } else { close() } |
| - epoch = UUID() |
| + let old = bridge |
| + bridge = nil |
| + close() |
| + let joiningEpoch = epoch |
| + if let old { await Task.detached { old.leave() }.value } |
| + guard epoch == joiningEpoch else { throw CancellationError() } |
| let listener = Events(call: self, epoch: epoch) |
| var error: NSError? |
| guard let client = BridgeNewClient(origin, room, cookies, listener, &error) else { |
| @@ -55,10 +75,10 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| events = listener |
| bridge = client |
| - let joiningEpoch = epoch |
| muted = false |
| cameraOff = false |
| chat = [] |
| + receivedChatCount = 0 |
| title = "Meet" |
| standing = "" |
| try await media.prepare(capture: capture) |
| @@ -77,6 +97,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| ice = nil |
| state = nil |
| pendingMedia = [] |
| + invitationURL = nil |
| + invitationError = "" |
| + loadingInvitation = false |
| if let old { DispatchQueue.global().async { old.close() } } |
| } |
| public func leave() async { |
| @@ -95,14 +118,44 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| media.localVideo?.isEnabled = !cameraOff |
| } |
| public func retryJoin() { send(type: "join", to: "", gen: 0, payload: "") } |
| + public func loadInvitation() async { |
| + guard let client = bridge, !loadingInvitation else { return } |
| + let currentEpoch = epoch |
| + invitationURL = nil |
| + invitationError = "" |
| + loadingInvitation = true |
| + defer { if epoch == currentEpoch { loadingInvitation = false } } |
| + do { |
| + let link = try await Task.detached { |
| + var error: NSError? |
| + let value = client.shareLink(&error) |
| + if let error { throw error } |
| + return value |
| + }.value |
| + guard epoch == currentEpoch else { return } |
| + invitationURL = URL(string: link) |
| + } catch { |
| + guard epoch == currentEpoch else { return } |
| + invitationError = "Could not get the invitation. Try again." |
| + } |
| + } |
| public func admit(_ peer: String) { |
| send(type: "admit", to: "", gen: 0, payload: json(["peer": peer]) ?? "") |
| } |
| - public func sendChat(_ text: String) { |
| - guard !text.isEmpty, let payload = json(["text": text]), payload.utf8.count <= 2059 else { |
| - return |
| + @discardableResult public func sendChat(_ text: String) -> Bool { |
| + guard !text.isEmpty else { return false } |
| + guard let payload = json(["text": text]), payload.utf8.count <= 2059 else { |
| + status = "Keep the message within 2 KB." |
| + return false |
| + } |
| + guard let bridge else { return false } |
| + do { |
| + try bridge.send("chat", to: "", generation: 0, payload: payload) |
| + return true |
| + } catch { |
| + status = "Could not send the message. Try again." |
| + return false |
| } |
| - send(type: "chat", to: "", gen: 0, payload: payload) |
| } |
| func send(type: String, to: String, gen: Int64, payload: String) { |
| do { try bridge?.send(type, to: to, generation: gen, payload: payload) } catch { |
| @@ -111,6 +164,10 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| fileprivate func receive(_ event: WireEvent) { |
| switch event.type { |
| + case "sent", "send-failed": |
| + guard let message = event.signal, message.type == "chat", |
| + let text = message.payload?.text else { return } |
| + appendChat("\(event.type == "sent" ? "You" : "Not sent"): \(text)") |
| case "info": |
| if let info = event.info { |
| title = info.title |
| @@ -159,8 +216,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| case "message": |
| guard let message = event.message else { return } |
| if message.type == "chat", let text = message.payload?.text { |
| - chat.append("\(message.name ?? "Participant"): \(text)") |
| - if chat.count > 200 { chat.removeFirst(chat.count - 200) } |
| + appendChat("\(message.name ?? "Participant"): \(text)") |
| + receivedChatCount += 1 |
| } |
| if ice == nil, ["offer", "answer", "candidate"].contains(message.type) { |
| if pendingMedia.count < 256 { pendingMedia.append(message) } |
| @@ -170,8 +227,19 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| default: break |
| } |
| } |
| + private func appendChat(_ line: String) { |
| + chat.append(line) |
| + if chat.count > 200 { chat.removeFirst(chat.count - 200) } |
| + } |
| func setRemote(_ track: RTCVideoTrack, peer: String) { remoteTracks[peer] = track } |
| func removeRemote(_ peer: String) { remoteTracks.removeValue(forKey: peer) } |
| func mediaFailed() { status = "A participant’s media could not connect." } |
| - public func sampleStats() async -> [String: UInt64] { await media.stats() } |
| + public func sampleStats() async -> [String: UInt64] { |
| + var values = await media.stats() |
| + values["hasICE"] = ice == nil ? 0 : 1 |
| + values["remoteTracks"] = UInt64(remoteTracks.count) |
| + values["invalidEvents"] = invalidEvents |
| + values["rosterPeers"] = UInt64(state?.peers.count ?? 0) |
| + return values |
| + } |
| } |
diff --git a/native/apple/Sources/MeetKit/Capture.swift b/native/apple/Sources/MeetKit/Capture.swift| index f1b5323..ca547f7 100644 |
| --- a/native/apple/Sources/MeetKit/Capture.swift |
| +++ b/native/apple/Sources/MeetKit/Capture.swift |
| @@ -22,11 +22,17 @@ import WebRTC |
| domain: "Meet", code: 2, |
| userInfo: [ |
| NSLocalizedDescriptionKey: |
| - "Allow camera and microphone access in System Settings to join." |
| + "Allow camera and microphone access in Settings to join." |
| ]) |
| } |
| } |
| - guard let device = RTCCameraVideoCapturer.captureDevices().first, |
| + let devices = RTCCameraVideoCapturer.captureDevices() |
| + #if os(iOS) |
| + let preferredDevice = devices.first { $0.position == .front } |
| + #else |
| + let preferredDevice = devices.first |
| + #endif |
| + guard let device = preferredDevice ?? devices.first, |
| let format = RTCCameraVideoCapturer.supportedFormats(for: device).min(by: { |
| abs(Int(CMVideoFormatDescriptionGetDimensions($0.formatDescription).width) - 640) |
| < abs(Int(CMVideoFormatDescriptionGetDimensions($1.formatDescription).width) - 640) |
diff --git a/native/apple/Sources/MeetKit/ChatView.swift b/native/apple/Sources/MeetKit/ChatView.swift| new file mode 100644 |
| index 0000000..dde36d3 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/ChatView.swift |
| @@ -0,0 +1,52 @@ |
| +import SwiftUI |
| + |
| +/// Shared native chat history and composer. History stays visible while the |
| +/// signaling stream reconnects; only sending depends on current admission. |
| +@MainActor public struct ChatView: View { |
| + @ObservedObject private var call: Call |
| + @State private var message = "" |
| + |
| + public init(call: Call) { self.call = call } |
| + |
| + public var body: some View { |
| + VStack(alignment: .leading, spacing: 12) { |
| + ScrollViewReader { proxy in |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 8) { |
| + if call.chat.isEmpty { |
| + Text("Messages sent during this call appear here.") |
| + .foregroundStyle(.secondary) |
| + } |
| + ForEach(Array(call.chat.enumerated()), id: \.offset) { _, line in |
| + Text(line).textSelection(.enabled) |
| + } |
| + Color.clear.frame(height: 1).id("chat-bottom") |
| + }.frame(maxWidth: .infinity, alignment: .leading) |
| + } |
| + .accessibilityIdentifier("chatHistory") |
| + .defaultScrollAnchor(.bottom) |
| + .onAppear { proxy.scrollTo("chat-bottom", anchor: .bottom) } |
| + .onChange(of: call.chat) { _, _ in |
| + proxy.scrollTo("chat-bottom", anchor: .bottom) |
| + } |
| + } |
| + if call.state?.phase == "reconnecting" { |
| + Text("Reconnecting. You can send again when the call reconnects.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + } |
| + HStack { |
| + TextField("Message to everyone", text: $message) |
| + .textFieldStyle(.roundedBorder) |
| + .accessibilityIdentifier("chatComposer") |
| + .onSubmit(send) |
| + Button("Send", action: send) |
| + .disabled(message.isEmpty || call.state?.phase != "admitted") |
| + } |
| + } |
| + } |
| + |
| + private func send() { |
| + guard call.state?.phase == "admitted" else { return } |
| + if call.sendChat(message) { message = "" } |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/Media.swift b/native/apple/Sources/MeetKit/Media.swift| index d662927..1621062 100644 |
| --- a/native/apple/Sources/MeetKit/Media.swift |
| +++ b/native/apple/Sources/MeetKit/Media.swift |
| @@ -14,9 +14,16 @@ import WebRTC |
| init(call: Call) { |
| self.call = call |
| RTCInitializeSSL() |
| - factory = RTCPeerConnectionFactory( |
| - encoderFactory: RTCDefaultVideoEncoderFactory(), |
| - decoderFactory: RTCDefaultVideoDecoderFactory()) |
| + #if os(iOS) |
| + factory = RTCPeerConnectionFactory( |
| + encoderFactory: RTCDefaultVideoEncoderFactory(), |
| + decoderFactory: RTCDefaultVideoDecoderFactory(), |
| + audioDevice: call.customAudioDevice) |
| + #else |
| + factory = RTCPeerConnectionFactory( |
| + encoderFactory: RTCDefaultVideoEncoderFactory(), |
| + decoderFactory: RTCDefaultVideoDecoderFactory()) |
| + #endif |
| } |
| func prepare(capture supplied: VideoCapture?) async throws { |
| close() |
| @@ -26,6 +33,19 @@ import WebRTC |
| let capture = supplied ?? CameraCapture() |
| self.capture = capture |
| try await capture.start(source: source) |
| + #if os(iOS) |
| + // A supplied audio device owns its session configuration (SDK contract). |
| + if call?.customAudioDevice == nil { |
| + let audio = RTCAudioSession.sharedInstance() |
| + audio.lockForConfiguration() |
| + defer { audio.unlockForConfiguration() } |
| + try audio.setCategory( |
| + AVAudioSession.Category.playAndRecord, |
| + mode: AVAudioSession.Mode.videoChat, |
| + options: [.defaultToSpeaker, .allowBluetoothHFP]) |
| + // WebRTC owns activation and interruption recovery as peers start/stop. |
| + } |
| + #endif |
| } |
| func closePeers() { |
| for (_, p) in peers { p.close() } |
| @@ -62,6 +82,7 @@ import WebRTC |
| func stats() async -> [String: UInt64] { |
| var values: [String: UInt64] = [ |
| "framesDecoded": 0, "audioBytesReceived": 0, "videoBytesSent": 0, "audioBytesSent": 0, |
| + "peerConnections": UInt64(peers.count), |
| ] |
| for peer in peers.values { |
| guard let pc = peer.pc else { continue } |
| @@ -100,6 +121,7 @@ import WebRTC |
| let id: String |
| let generation: Int64 |
| let polite: Bool |
| + nonisolated let playAudio: Bool |
| weak var engine: MediaEngine? |
| var pc: RTCPeerConnection? |
| private var work: Task<Void, Never>? |
| @@ -113,6 +135,7 @@ import WebRTC |
| self.generation = generation |
| self.polite = polite |
| self.engine = engine |
| + self.playAudio = engine.call?.playAudio ?? true |
| } |
| private func configuration(_ ice: ICEConfig) -> RTCConfiguration { |
| let c = RTCConfiguration() |
| @@ -280,6 +303,10 @@ import WebRTC |
| _ peerConnection: RTCPeerConnection, didAdd rtpReceiver: RTCRtpReceiver, |
| streams: [RTCMediaStream] |
| ) { |
| + // Keep decoding and RTP statistics available when a test disables speakers. |
| + if let audio = rtpReceiver.track as? RTCAudioTrack, !playAudio { |
| + audio.source.volume = 0 |
| + } |
| guard let track = rtpReceiver.track as? RTCVideoTrack else { return } |
| Task { @MainActor [weak self] in |
| guard let self, !self.closed else { return } |
diff --git a/native/apple/Sources/MeetKit/MeetModel.swift b/native/apple/Sources/MeetKit/MeetModel.swift| new file mode 100644 |
| index 0000000..86af864 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/MeetModel.swift |
| @@ -0,0 +1,137 @@ |
| +import Combine |
| +import Foundation |
| +@preconcurrency import MeetBridge |
| + |
| +#if os(iOS) |
| + import UIKit |
| +#else |
| + import AppKit |
| +#endif |
| + |
| +@MainActor public final class MeetModel: ObservableObject { |
| + @Published public var address = "" |
| + @Published public var error = "" |
| + @Published public var signin: URL? |
| + @Published public var authenticating = false |
| + @Published public var confirmationCode = "" |
| + private var authentication: BridgeAuthentication? |
| + private var authEpoch = UUID() |
| + private var callEpoch = UUID() |
| + @Published public var signedIn = false |
| + @Published public var joining = false |
| + @Published public var room = "" |
| + @Published public var origin = "" |
| + public let call = Call() |
| + private var cookies = "" |
| + public init() {} |
| + |
| + public func connect() { |
| + cancelSignIn() |
| + authenticating = true |
| + error = "" |
| + let epoch = authEpoch |
| + let address = self.address.trimmingCharacters(in: .whitespacesAndNewlines) |
| + Task { |
| + do { |
| + let auth = try await Task.detached { |
| + var error: NSError? |
| + guard let auth = BridgeBeginAuthentication(address, &error) else { |
| + throw error ?? NSError(domain: "Meet", code: 1) |
| + } |
| + return auth |
| + }.value |
| + guard epoch == authEpoch else { |
| + auth.cancel() |
| + return |
| + } |
| + authentication = auth |
| + guard let url = URL(string: auth.browserURL()), await openBrowser(url) else { |
| + auth.cancel() |
| + throw NSError( |
| + domain: "Meet", code: 2, |
| + userInfo: [NSLocalizedDescriptionKey: "Could not open your browser."]) |
| + } |
| + guard epoch == authEpoch else { |
| + auth.cancel() |
| + return |
| + } |
| + confirmationCode = auth.confirmationCode() |
| + signin = url |
| + let result = try await Task.detached { |
| + var error: NSError? |
| + let result = auth.wait(&error) |
| + if let error { throw error } |
| + return result |
| + }.value |
| + guard epoch == authEpoch else { return } |
| + let data = Data(result.utf8) |
| + let response = try JSONDecoder().decode(SignInResult.self, from: data) |
| + cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)! |
| + origin = response.origin |
| + room = response.room |
| + signedIn = true |
| + authenticating = false |
| + signin = nil |
| + authentication = nil |
| + #if os(macOS) |
| + NSApplication.shared.activate(ignoringOtherApps: true) |
| + #endif |
| + } catch { |
| + guard epoch == authEpoch else { return } |
| + self.error = error.localizedDescription |
| + cancelSignIn() |
| + } |
| + } |
| + } |
| + public func openBrowser(_ url: URL) async -> Bool { |
| + #if os(iOS) |
| + return await UIApplication.shared.open(url) |
| + #else |
| + return NSWorkspace.shared.open(url) |
| + #endif |
| + } |
| + public func cancelSignIn() { |
| + authEpoch = UUID() |
| + authentication?.cancel() |
| + authentication = nil |
| + authenticating = false |
| + confirmationCode = "" |
| + signin = nil |
| + } |
| + private struct SignInResult: Decodable { |
| + let origin: String |
| + let room: String |
| + let cookies: [Cookie] |
| + struct Cookie: Codable { |
| + let name: String |
| + let value: String |
| + } |
| + } |
| + public func join() { |
| + callEpoch = UUID() |
| + let operation = callEpoch |
| + let origin = self.origin, room = self.room, cookies = self.cookies |
| + joining = true |
| + error = "" |
| + Task { |
| + guard operation == callEpoch else { return } |
| + do { try await call.join(origin: origin, room: room, cookies: cookies) } catch { |
| + guard operation == callEpoch else { return } |
| + self.error = error.localizedDescription |
| + call.close() |
| + joining = false |
| + } |
| + } |
| + } |
| + public func leave() { |
| + callEpoch = UUID() |
| + let operation = callEpoch |
| + cancelSignIn() |
| + Task { |
| + guard operation == callEpoch else { return } |
| + await call.leave() |
| + guard operation == callEpoch else { return } |
| + joining = false |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/MeetingShareButton.swift b/native/apple/Sources/MeetKit/MeetingShareButton.swift| new file mode 100644 |
| index 0000000..cefebe8 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/MeetingShareButton.swift |
| @@ -0,0 +1,64 @@ |
| +import SwiftUI |
| + |
| +#if os(iOS) |
| + import UIKit |
| +#else |
| + import AppKit |
| +#endif |
| + |
| +@MainActor public struct MeetingShareButton: View { |
| + @ObservedObject private var call: Call |
| + @State private var showing = false |
| + |
| + public init(call: Call) { self.call = call } |
| + |
| + public var body: some View { |
| + if call.standing == "member" { |
| + Button("Share") { showing = true } |
| + .accessibilityLabel("Share meeting link") |
| + .sheet(isPresented: $showing) { |
| + MeetingInvitationView(call: call, close: { showing = false }) |
| + .task { await call.loadInvitation() } |
| + } |
| + } |
| + } |
| +} |
| + |
| +@MainActor public struct MeetingInvitationView: View { |
| + @ObservedObject private var call: Call |
| + private let close: () -> Void |
| + @State private var copied = false |
| + |
| + public init(call: Call, close: @escaping () -> Void) { |
| + self.call = call |
| + self.close = close |
| + } |
| + |
| + public var body: some View { |
| + VStack(alignment: .leading, spacing: 20) { |
| + Text("Share meeting").font(.title2.bold()) |
| + Text("Send this invitation to someone you want to join. Guests will wait to be let in.") |
| + if call.loadingInvitation { |
| + ProgressView("Getting invitation…") |
| + } else if let url = call.invitationURL { |
| + Text(url.absoluteString).font(.callout).textSelection(.enabled) |
| + HStack { |
| + ShareLink(item: url) { Text("Share…") } |
| + Button(copied ? "Copied" : "Copy link") { |
| + #if os(iOS) |
| + UIPasteboard.general.url = url |
| + copied = true |
| + #else |
| + NSPasteboard.general.clearContents() |
| + copied = NSPasteboard.general.setString(url.absoluteString, forType: .string) |
| + #endif |
| + } |
| + }.buttonStyle(.bordered) |
| + } else { |
| + Text(call.invitationError).foregroundStyle(.secondary) |
| + Button("Try again") { Task { await call.loadInvitation() } } |
| + } |
| + Button("Done", action: close) |
| + }.padding(24).frame(minWidth: 280, idealWidth: 380, maxWidth: 440) |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/Wire.swift b/native/apple/Sources/MeetKit/Wire.swift| index eedae46..6abe484 100644 |
| --- a/native/apple/Sources/MeetKit/Wire.swift |
| +++ b/native/apple/Sources/MeetKit/Wire.swift |
| @@ -21,6 +21,7 @@ struct WireEvent: Decodable { |
| let message: Message? |
| let ice: ICEConfig? |
| let info: MeetingInfo? |
| + let signal: Message? |
| } |
| struct Message: Decodable { |
| let type: String |
diff --git a/native/apple/Sources/MeetMac/MeetMac.swift b/native/apple/Sources/MeetMac/MeetMac.swift| index ef56e19..8967035 100644 |
| --- a/native/apple/Sources/MeetMac/MeetMac.swift |
| +++ b/native/apple/Sources/MeetMac/MeetMac.swift |
| @@ -5,7 +5,8 @@ import SwiftUI |
| import WebRTC |
| @main struct MeetMacApp: App { |
| - @StateObject private var model = AppModel() |
| + @NSApplicationDelegateAdaptor(MeetAppDelegate.self) private var appDelegate |
| + @StateObject private var model = MeetModel() |
| var body: some Scene { |
| Window("Meet", id: "main") { |
| ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| @@ -14,110 +15,17 @@ import WebRTC |
| } |
| } |
| -@MainActor final class AppModel: ObservableObject { |
| - @Published var address = "" |
| - @Published var error = "" |
| - @Published var signin: URL? |
| - @Published var authenticating = false |
| - @Published var confirmationCode = "" |
| - private var authentication: BridgeAuthentication? |
| - private var authEpoch = UUID() |
| - @Published var signedIn = false |
| - @Published var joining = false |
| - @Published var room = "" |
| - @Published var origin = "" |
| - let call = Call() |
| - private var cookies = "" |
| - func connect() { |
| - cancelSignIn() |
| - authenticating = true |
| - error = "" |
| - let epoch = authEpoch |
| - let address = self.address.trimmingCharacters(in: .whitespacesAndNewlines) |
| - Task { |
| - do { |
| - let auth = try await Task.detached { |
| - var error: NSError? |
| - guard let auth = BridgeBeginAuthentication(address, &error) else { |
| - throw error ?? NSError(domain: "Meet", code: 1) |
| - } |
| - return auth |
| - }.value |
| - guard epoch == authEpoch else { |
| - auth.cancel() |
| - return |
| - } |
| - authentication = auth |
| - guard let url = URL(string: auth.browserURL()), NSWorkspace.shared.open(url) else { |
| - auth.cancel() |
| - throw NSError( |
| - domain: "Meet", code: 2, |
| - userInfo: [NSLocalizedDescriptionKey: "Could not open your browser."]) |
| - } |
| - confirmationCode = auth.confirmationCode() |
| - signin = url |
| - let result = try await Task.detached { |
| - var error: NSError? |
| - let result = auth.wait(&error) |
| - if let error { throw error } |
| - return result |
| - }.value |
| - guard epoch == authEpoch else { return } |
| - let data = Data(result.utf8) |
| - let response = try JSONDecoder().decode(SignInResult.self, from: data) |
| - cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)! |
| - origin = response.origin |
| - room = response.room |
| - signedIn = true |
| - authenticating = false |
| - signin = nil |
| - authentication = nil |
| - NSApplication.shared.activate(ignoringOtherApps: true) |
| - } catch { |
| - guard epoch == authEpoch else { return } |
| - self.error = error.localizedDescription |
| - cancelSignIn() |
| - } |
| - } |
| - } |
| - func cancelSignIn() { |
| - authEpoch = UUID() |
| - authentication?.cancel() |
| - authentication = nil |
| - authenticating = false |
| - confirmationCode = "" |
| - signin = nil |
| - } |
| - private struct SignInResult: Decodable { |
| - let origin: String |
| - let room: String |
| - let cookies: [Cookie] |
| - struct Cookie: Codable { |
| - let name: String |
| - let value: String |
| - } |
| - } |
| - func join() { |
| - joining = true |
| - Task { |
| - do { try await call.join(origin: origin, room: room, cookies: cookies) } catch { |
| - self.error = error.localizedDescription |
| - call.close() |
| - joining = false |
| - } |
| - } |
| - } |
| - func leave() { |
| - cancelSignIn() |
| - Task { |
| - await call.leave() |
| - joining = false |
| +final class MeetAppDelegate: NSObject, NSApplicationDelegate { |
| + func applicationDidFinishLaunching(_ notification: Notification) { |
| + if let url = Bundle.main.url(forResource: "AppIcon", withExtension: "png") |
| + ?? Bundle.module.url(forResource: "AppIcon", withExtension: "png") { |
| + NSApp.applicationIconImage = NSImage(contentsOf: url) |
| } |
| } |
| } |
| struct ContentView: View { |
| - @ObservedObject var model: AppModel |
| + @ObservedObject var model: MeetModel |
| var body: some View { |
| VStack(alignment: .leading, spacing: 20) { |
| if model.joining { |
| @@ -161,7 +69,6 @@ struct ContentView: View { |
| struct CallView: View { |
| @ObservedObject var call: Call |
| let leave: () -> Void |
| - @State private var message = "" |
| var body: some View { |
| VStack(alignment: .leading, spacing: 16) { |
| HStack { |
| @@ -177,7 +84,7 @@ struct CallView: View { |
| } |
| ScrollView { |
| LazyVGrid(columns: [GridItem(.adaptive(minimum: 260))], spacing: 16) { |
| - if let local = call.localTrack { VideoTile(track: local, label: "You") } |
| + if let local = call.localTrack { VideoTile(track: local, label: "You", mirrored: true) } |
| ForEach(call.state?.peers.filter { $0.peer != call.state?.peer } ?? []) { peer in |
| if let track = call.remoteTracks[peer.peer] { |
| VideoTile(track: track, label: peer.name) |
| @@ -200,35 +107,24 @@ struct CallView: View { |
| HStack { |
| Button(call.muted ? "Unmute microphone" : "Mute microphone") { call.mute() } |
| Button(call.cameraOff ? "Turn camera on" : "Turn camera off") { call.toggleCamera() } |
| + MeetingShareButton(call: call) |
| Spacer() |
| Button("Leave meeting", role: .destructive, action: leave) |
| } |
| - if call.state?.phase == "admitted" { |
| - ScrollView { |
| - VStack(alignment: .leading) { |
| - ForEach(Array(call.chat.enumerated()), id: \.offset) { _, line in |
| - Text(line).textSelection(.enabled) |
| - } |
| - }.frame(maxWidth: .infinity, alignment: .leading) |
| - }.frame(maxHeight: 90) |
| - HStack { |
| - TextField("Message to everyone", text: $message).onSubmit(send) |
| - Button("Send", action: send).disabled(message.isEmpty) |
| - } |
| - } |
| + Text("Chat").font(.headline) |
| + ChatView(call: call).frame(minHeight: 120, maxHeight: 180) |
| } |
| } |
| - private func send() { |
| - call.sendChat(message) |
| - message = "" |
| - } |
| } |
| struct VideoTile: View { |
| let track: RTCVideoTrack |
| let label: String |
| + var mirrored = false |
| var body: some View { |
| VStack(alignment: .leading) { |
| - VideoView(track: track).frame(height: 200).background(.black).clipShape( |
| + VideoView(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1).frame(height: 200).background( |
| + .black |
| + ).clipShape( |
| RoundedRectangle(cornerRadius: 12)) |
| Text(label).font(.headline) |
| }.accessibilityElement(children: .combine) |
diff --git a/native/apple/Sources/MeetMac/Resources/AppIcon.png b/native/apple/Sources/MeetMac/Resources/AppIcon.png| new file mode 100644 |
| index 0000000..29baeff |
| Binary files /dev/null and b/native/apple/Sources/MeetMac/Resources/AppIcon.png differ |
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go| index 47f3367..56b5bdb 100644 |
| --- a/native/bridge/bridge.go |
| +++ b/native/bridge/bridge.go |
| @@ -67,7 +67,8 @@ func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, er |
| } |
| return &Client{room: r}, nil |
| } |
| -func (c *Client) Start() error { return c.room.Start() } |
| +func (c *Client) Start() error { return c.room.Start() } |
| +func (c *Client) ShareLink() (string, error) { return c.room.ShareLink() } |
| func (c *Client) Send(typ, to string, generation int64, payload string) error { |
| if generation < 0 { |
| return errors.New("invalid generation") |
diff --git a/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png| new file mode 100644 |
| index 0000000..29baeff |
| Binary files /dev/null and b/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ |
diff --git a/native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json| new file mode 100644 |
| index 0000000..0a42994 |
| --- /dev/null |
| +++ b/native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json |
| @@ -0,0 +1 @@ |
| +{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}} |
diff --git a/native/ios/Assets.xcassets/Contents.json b/native/ios/Assets.xcassets/Contents.json| new file mode 100644 |
| index 0000000..f223229 |
| --- /dev/null |
| +++ b/native/ios/Assets.xcassets/Contents.json |
| @@ -0,0 +1 @@ |
| +{"info":{"author":"xcode","version":1}} |
diff --git a/native/ios/README.md b/native/ios/README.md| new file mode 100644 |
| index 0000000..3b38501 |
| --- /dev/null |
| +++ b/native/ios/README.md |
| @@ -0,0 +1,74 @@ |
| +# Meet for iPhone |
| + |
| +A SwiftUI iOS 17+ app using the same Go authentication/signaling core and native |
| +WebRTC engine as Meet for Mac. No embedded webview is used. The Xcode project is |
| +generated from `project.yml`; edit that specification and the Swift sources. |
| + |
| +## Build |
| + |
| +Requires Xcode, Go, and XcodeGen (`brew install xcodegen`). Dependencies are pinned |
| +in the shared Swift package and the isolated native Go module. |
| + |
| +```sh |
| +make iphone |
| +# For a signed physical-device build: |
| +MEET_DEVELOPMENT_TEAM=YOUR_TEAM_ID make iphone-device |
| +``` |
| + |
| +Build products are `native/build/ios-simulator/Build/Products/Debug-iphonesimulator/MeetPhone.app` |
| +and `native/build/ios-device/Build/Products/Debug-iphoneos/MeetPhone.app`. |
| +The device build uses automatic development signing with your selected team; it |
| +does not upload an app to App Store Connect or TestFlight. |
| + |
| +Generate/open the Xcode project for normal device development: |
| + |
| +```sh |
| +xcodegen generate --spec native/ios/project.yml |
| +open native/ios/MeetPhone.xcodeproj |
| +``` |
| + |
| +Select your development team and connected iPhone, then Run. Alternatively, |
| +install a signed app using `xcrun devicectl device install app --device DEVICE_ID` |
| +followed by the device build's `.app` path. The iPhone must be connected and |
| +available to Xcode, with Developer Mode enabled. |
| + |
| +## First phone milestone |
| + |
| +- Paste a meeting or invitation link; sign in in Safari/the default browser. |
| +- Open the email link normally, compare the browser's code with Meet's code, |
| + approve, then return to Meet. Credentials are kept in memory. |
| +- Join using the front camera and microphone; the local video display is mirrored. |
| + Sent video and remote video retain their original orientation. |
| +- Native remote video, microphone/camera controls, guest admission, chat, and leave. |
| +- Chat is always available beside the call controls. Its button shows new-message |
| + counts; opening it shows the latest messages and a composer without scrolling |
| + past the video tiles. Opening chat keeps the call active. |
| +- Members have a Share button beside Chat, with Copy link and the system share menu. |
| +- Camera/microphone permission refusals appear in the app. Retry after changing |
| + permission in Settings. The simulator has no physical camera. |
| + |
| +Calls are foreground-only in this milestone. Switching away ends the call; |
| +sign-in survives switching to the browser. Screen sharing, camera switching, |
| +CallKit/background calling, stored credentials, and distribution are follow-up |
| +work. Physical camera, audible audio, Bluetooth routing, rotation while capturing, |
| +and interruption recovery need a real-device acceptance pass. |
| + |
| +## Verification |
| + |
| +```sh |
| +xcrun simctl list devices available |
| +# Choose an iPhone simulator UUID: |
| +MEET_IOS_SIMULATOR=YOUR_SIMULATOR_UUID make iphone-browser |
| +``` |
| + |
| +The Go harness creates isolated databases, meetings, and captured magic-link |
| +emails. iPhone UI tests use the real Safari handoff for members and guests and |
| +compare the authorization codes before returning to the app. No production |
| +credentials are used and no real email is sent. |
| + |
| +The native media test creates two iOS WebRTC peers with a synthetic camera, |
| +checks decoded video in both directions, continuity through three-second stream |
| +renewals, and removal of the departed peer. Hardware audio is disabled in this |
| +simulator-only test: it neither records the host microphone nor claims to prove |
| +physical audio quality. Synthetic capture is compiled only into test targets. |
| +Xcode logs and result bundles are retained under `native/build/`. |
diff --git a/native/ios/Sources/MeetPhone.swift b/native/ios/Sources/MeetPhone.swift| new file mode 100644 |
| index 0000000..f273899 |
| --- /dev/null |
| +++ b/native/ios/Sources/MeetPhone.swift |
| @@ -0,0 +1,196 @@ |
| +import MeetKit |
| +import SwiftUI |
| +import WebRTC |
| + |
| +@main struct MeetPhoneApp: App { |
| + @StateObject private var model = MeetModel() |
| + @Environment(\.scenePhase) private var scenePhase |
| + var body: some Scene { |
| + WindowGroup { |
| + PhoneHome(model: model) |
| + .onChange(of: scenePhase) { _, phase in |
| + // This milestone is foreground-only. Browser sign-in is kept alive; |
| + // an active call leaves cleanly instead of silently sending audio. |
| + if phase == .background && model.joining { model.leave() } |
| + UIApplication.shared.isIdleTimerDisabled = phase == .active && model.joining |
| + } |
| + .onChange(of: model.joining) { _, joining in |
| + UIApplication.shared.isIdleTimerDisabled = joining |
| + } |
| + } |
| + } |
| +} |
| + |
| +struct PhoneHome: View { |
| + @ObservedObject var model: MeetModel |
| + var body: some View { |
| + NavigationStack { |
| + Group { |
| + if model.joining { |
| + PhoneCall(call: model.call, leave: model.leave) |
| + } else { |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 24) { |
| + if model.authenticating { |
| + Text("Finish signing in in your browser").font(.title2.bold()) |
| + Text( |
| + "Open the emailed link normally. Compare this code with your browser before approving sign-in, then return to Meet." |
| + ) |
| + if !model.confirmationCode.isEmpty { |
| + Text(model.confirmationCode).font(.largeTitle.monospaced()).textSelection( |
| + .enabled) |
| + } |
| + ProgressView("Waiting for sign-in") |
| + if let url = model.signin { |
| + Button("Open browser again") { Task { _ = await model.openBrowser(url) } } |
| + } |
| + Button("Cancel", action: model.cancelSignIn) |
| + } else if model.signedIn { |
| + Text("Ready to join").font(.largeTitle.bold()) |
| + Text("Your front camera and microphone will be requested when you join.") |
| + Text("Meeting \(model.room)").foregroundStyle(.secondary) |
| + Button("Join meeting", action: model.join).buttonStyle(.borderedProminent) |
| + Button("Choose another meeting") { model.signedIn = false } |
| + Text("Keep Meet open during your call. Switching to another app ends the call.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + } else { |
| + Text("Meet face to face").font(.largeTitle.bold()) |
| + Text("Join your team’s meeting from your iPhone.").foregroundStyle(.secondary) |
| + VStack(alignment: .leading, spacing: 8) { |
| + Text("Meeting link").font(.headline) |
| + TextField("Meeting or invitation link", text: $model.address) |
| + .textFieldStyle(.roundedBorder).keyboardType(.URL) |
| + .textInputAutocapitalization(.never).autocorrectionDisabled() |
| + .accessibilityIdentifier("meetingLink").onSubmit(model.connect) |
| + } |
| + Button("Continue", action: model.connect).buttonStyle(.borderedProminent) |
| + } |
| + if !model.error.isEmpty { |
| + Text(model.error).foregroundStyle(.red).accessibilityLabel("Error: \(model.error)") |
| + } |
| + }.frame(maxWidth: 600, alignment: .leading).padding(24) |
| + } |
| + } |
| + }.navigationTitle("Meet").navigationBarTitleDisplayMode(.inline) |
| + } |
| + } |
| +} |
| + |
| +struct PhoneCall: View { |
| + @ObservedObject var call: Call |
| + let leave: () -> Void |
| + @State private var showingChat = false |
| + @State private var readChatCount = 0 |
| + var body: some View { |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 16) { |
| + Text(call.title).font(.title2.bold()) |
| + Text(call.status).accessibilityAddTraits(.updatesFrequently) |
| + Text(call.standing.capitalized).foregroundStyle(.secondary) |
| + if call.floor { |
| + Text("Direct connections only. Some networks may be unable to connect.").font(.footnote) |
| + } |
| + if let local = call.localTrack { |
| + PhoneVideoTile(track: local, label: "You", mirrored: true) |
| + } |
| + ForEach(call.state?.peers.filter { $0.peer != call.state?.peer } ?? []) { peer in |
| + if let track = call.remoteTracks[peer.peer] { |
| + PhoneVideoTile(track: track, label: peer.name) |
| + } else { |
| + HStack { |
| + ProgressView() |
| + Text(peer.name) |
| + }.frame(minHeight: 80) |
| + } |
| + } |
| + ForEach(call.state?.waiting ?? []) { person in |
| + HStack { |
| + Text("\(person.name) is waiting") |
| + Spacer() |
| + Button("Let in") { call.admit(person.peer) } |
| + } |
| + } |
| + if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| + }.frame(maxWidth: 700).padding() |
| + }.safeAreaInset(edge: .bottom) { |
| + VStack(spacing: 12) { |
| + HStack { |
| + Button(chatButtonTitle) { |
| + readChatCount = call.receivedChatCount |
| + showingChat = true |
| + }.buttonStyle(.bordered).accessibilityIdentifier("openChat") |
| + MeetingShareButton(call: call).buttonStyle(.bordered) |
| + } |
| + ViewThatFits(in: .horizontal) { |
| + HStack { mediaControls } |
| + VStack(alignment: .leading) { mediaControls } |
| + } |
| + Button("Leave meeting", role: .destructive, action: leave).buttonStyle(.bordered) |
| + }.frame(maxWidth: .infinity).padding().background(.bar) |
| + } |
| + .sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) { |
| + NavigationStack { |
| + ChatView(call: call).padding() |
| + .navigationTitle("Chat").navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .confirmationAction) { |
| + Button("Done") { showingChat = false } |
| + } |
| + } |
| + }.presentationDetents([.medium, .large]) |
| + } |
| + .onChange(of: call.receivedChatCount) { _, count in |
| + if showingChat { readChatCount = count } |
| + } |
| + } |
| + @ViewBuilder private var mediaControls: some View { |
| + Button(call.muted ? "Unmute microphone" : "Mute microphone") { call.mute() }.buttonStyle( |
| + .bordered) |
| + Button(call.cameraOff ? "Turn camera on" : "Turn camera off") { call.toggleCamera() } |
| + .buttonStyle(.bordered) |
| + } |
| + private var chatButtonTitle: String { |
| + let unread = max(0, call.receivedChatCount - readChatCount) |
| + return unread == 0 ? "Chat" : "Chat (\(unread) new)" |
| + } |
| +} |
| + |
| +struct PhoneVideoTile: View { |
| + let track: RTCVideoTrack |
| + let label: String |
| + var mirrored = false |
| + var body: some View { |
| + VStack(alignment: .leading) { |
| + PhoneVideo(track: track) |
| + .scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| + .frame(height: 220).background(.black).clipShape(RoundedRectangle(cornerRadius: 12)) |
| + Text(label).font(.headline) |
| + }.accessibilityElement(children: .combine) |
| + } |
| +} |
| +struct PhoneVideo: UIViewRepresentable { |
| + let track: RTCVideoTrack |
| + func makeCoordinator() -> Coordinator { Coordinator() } |
| + func makeUIView(context: Context) -> RTCMTLVideoView { |
| + let view = RTCMTLVideoView(frame: .zero) |
| + view.videoContentMode = .scaleAspectFit |
| + context.coordinator.attach(track, to: view) |
| + return view |
| + } |
| + func updateUIView(_ view: RTCMTLVideoView, context: Context) { |
| + context.coordinator.attach(track, to: view) |
| + } |
| + static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) { |
| + coordinator.track?.remove(view) |
| + } |
| + final class Coordinator { |
| + var track: RTCVideoTrack? |
| + func attach(_ next: RTCVideoTrack, to view: RTCMTLVideoView) { |
| + if track !== next { |
| + track?.remove(view) |
| + track = next |
| + next.add(view) |
| + } |
| + } |
| + } |
| +} |
diff --git a/native/ios/Tests/MeetPhoneTests.swift b/native/ios/Tests/MeetPhoneTests.swift| new file mode 100644 |
| index 0000000..ba8af8e |
| --- /dev/null |
| +++ b/native/ios/Tests/MeetPhoneTests.swift |
| @@ -0,0 +1,61 @@ |
| +import MeetKit |
| +import WebRTC |
| +import XCTest |
| + |
| +final class MeetPhoneTests: XCTestCase { |
| + @MainActor func testNativeVideoAndRenewal() async throws { |
| + guard let text = ProcessInfo.processInfo.environment["MEET_TEST_MEDIA"], |
| + let data = text.data(using: .utf8) |
| + else { |
| + throw XCTSkip("Run the Go iPhone media harness for temporary sessions and a meeting.") |
| + } |
| + struct Fixture: Decodable { |
| + let origin: String |
| + let room: String |
| + let cookiesA: String |
| + let cookiesB: String |
| + } |
| + let input = try JSONDecoder().decode(Fixture.self, from: data) |
| + // No hardware capture or playback in this simulator video test. |
| + let a = Call(audioDevice: SilentAudioDevice()) |
| + let b = Call(audioDevice: SilentAudioDevice()) |
| + defer { |
| + a.close() |
| + b.close() |
| + } |
| + try await a.join( |
| + origin: input.origin, room: input.room, cookies: input.cookiesA, capture: SyntheticVideo()) |
| + a.mute() |
| + try await b.join( |
| + origin: input.origin, room: input.room, cookies: input.cookiesB, capture: SyntheticVideo()) |
| + b.mute() |
| + var decodedA: UInt64 = 0 |
| + var decodedB: UInt64 = 0 |
| + for _ in 0..<80 { |
| + decodedA = await a.sampleStats()["framesDecoded", default: 0] |
| + decodedB = await b.sampleStats()["framesDecoded", default: 0] |
| + if decodedA > 15 && decodedB > 15 { break } |
| + try await Task.sleep(for: .milliseconds(500)) |
| + } |
| + XCTAssertGreaterThan(decodedA, 15, "First iPhone peer must decode the other peer's video") |
| + XCTAssertGreaterThan(decodedB, 15, "Second iPhone peer must decode the other peer's video") |
| + XCTAssertEqual(a.state?.phase, "admitted") |
| + XCTAssertEqual(b.state?.phase, "admitted") |
| + let peerA = a.state?.peer |
| + let peerB = b.state?.peer |
| + try await Task.sleep(for: .seconds(8)) |
| + let renewedA = await a.sampleStats() |
| + let renewedB = await b.sampleStats() |
| + XCTAssertGreaterThan(renewedA["framesDecoded", default: 0], decodedA) |
| + XCTAssertGreaterThan(renewedB["framesDecoded", default: 0], decodedB) |
| + XCTAssertEqual(a.state?.peer, peerA) |
| + XCTAssertEqual(b.state?.peer, peerB) |
| + await b.leave() |
| + for _ in 0..<20 { |
| + if a.remoteTracks.isEmpty { break } |
| + try await Task.sleep(for: .milliseconds(250)) |
| + } |
| + XCTAssertTrue(a.remoteTracks.isEmpty, "Departure must remove the remote track") |
| + await a.leave() |
| + } |
| +} |
diff --git a/native/ios/Tests/SilentAudioDevice.swift b/native/ios/Tests/SilentAudioDevice.swift| new file mode 100644 |
| index 0000000..0ef363b |
| --- /dev/null |
| +++ b/native/ios/Tests/SilentAudioDevice.swift |
| @@ -0,0 +1,54 @@ |
| +import Foundation |
| +import WebRTC |
| + |
| +// Test-only audio device. No samples are captured or rendered. WebRTC invokes |
| +// this protocol serially on its audio thread, as specified by RTCAudioDevice. |
| +final class SilentAudioDevice: NSObject, RTCAudioDevice { |
| + let deviceInputSampleRate = 48000.0 |
| + let deviceOutputSampleRate = 48000.0 |
| + let inputIOBufferDuration = 0.01 |
| + let outputIOBufferDuration = 0.01 |
| + let inputNumberOfChannels = 1 |
| + let outputNumberOfChannels = 1 |
| + let inputLatency = 0.0 |
| + let outputLatency = 0.0 |
| + var isInitialized = false |
| + var isPlayoutInitialized = false |
| + var isRecordingInitialized = false |
| + var isPlaying = false |
| + var isRecording = false |
| + func initialize(with delegate: RTCAudioDeviceDelegate) -> Bool { |
| + isInitialized = true |
| + return true |
| + } |
| + func terminateDevice() -> Bool { |
| + isInitialized = false |
| + isPlaying = false |
| + isRecording = false |
| + return true |
| + } |
| + func initializePlayout() -> Bool { |
| + isPlayoutInitialized = true |
| + return true |
| + } |
| + func startPlayout() -> Bool { |
| + isPlaying = true |
| + return true |
| + } |
| + func stopPlayout() -> Bool { |
| + isPlaying = false |
| + return true |
| + } |
| + func initializeRecording() -> Bool { |
| + isRecordingInitialized = true |
| + return true |
| + } |
| + func startRecording() -> Bool { |
| + isRecording = true |
| + return true |
| + } |
| + func stopRecording() -> Bool { |
| + isRecording = false |
| + return true |
| + } |
| +} |
diff --git a/native/ios/UITests/MeetPhoneUITests.swift b/native/ios/UITests/MeetPhoneUITests.swift| new file mode 100644 |
| index 0000000..9637aa0 |
| --- /dev/null |
| +++ b/native/ios/UITests/MeetPhoneUITests.swift |
| @@ -0,0 +1,76 @@ |
| +import XCTest |
| + |
| +final class MeetPhoneUITests: XCTestCase { |
| + @MainActor func testInvalidMeetingLink() { |
| + let app = XCUIApplication() |
| + app.launch() |
| + let input = app.textFields["meetingLink"] |
| + XCTAssertTrue(input.waitForExistence(timeout: 15)) |
| + input.tap() |
| + input.typeText("not a meeting") |
| + app.buttons["Continue"].tap() |
| + XCTAssertTrue( |
| + app.staticTexts.containing( |
| + NSPredicate(format: "label CONTAINS[c] %@", "meeting or invitation link") |
| + ).firstMatch.waitForExistence(timeout: 10)) |
| + XCTAssertTrue(app.textFields["meetingLink"].exists) |
| + } |
| + |
| + @MainActor func testEmailBrowserHandoff() async throws { |
| + let env = ProcessInfo.processInfo.environment |
| + guard let meeting = env["MEET_TEST_MEETING"], let control = env["MEET_TEST_CONTROL"], |
| + let address = env["MEET_TEST_EMAIL"] |
| + else { |
| + throw XCTSkip("Run the Go iPhone browser harness for a temporary server and captured email.") |
| + } |
| + let app = XCUIApplication() |
| + app.launch() |
| + let input = app.textFields["meetingLink"] |
| + XCTAssertTrue(input.waitForExistence(timeout: 15)) |
| + input.tap() |
| + input.typeText(meeting) |
| + app.buttons["Continue"].tap() |
| + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") |
| + let email = safari.textFields["Email address"] |
| + if !email.waitForExistence(timeout: 10), safari.buttons["Continue"].exists { |
| + safari.buttons["Continue"].tap() |
| + } |
| + XCTAssertTrue(email.waitForExistence(timeout: 20)) |
| + email.tap() |
| + email.typeText(address) |
| + safari.buttons["Send me a sign-in link"].tap() |
| + var link: URL? |
| + for _ in 0..<20 { |
| + let (data, _) = try await URLSession.shared.data(from: URL(string: control)!) |
| + if let response = try JSONSerialization.jsonObject(with: data) as? [String: String], |
| + let text = response["link"], !text.isEmpty |
| + { |
| + link = URL(string: text) |
| + break |
| + } |
| + try await Task.sleep(for: .milliseconds(250)) |
| + } |
| + let verification = try XCTUnwrap(link) |
| + XCUIDevice.shared.system.open(verification) |
| + let approve = safari.buttons["Sign in to Meet app"] |
| + XCTAssertTrue(approve.waitForExistence(timeout: 20)) |
| + app.activate() |
| + let code = app.staticTexts.matching( |
| + NSPredicate(format: "label MATCHES %@", "[A-F0-9]{4}-[A-F0-9]{4}") |
| + ).firstMatch |
| + XCTAssertTrue(code.waitForExistence(timeout: 10)) |
| + let expected = code.label |
| + safari.activate() |
| + XCTAssertTrue( |
| + safari.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", expected)).firstMatch |
| + .exists) |
| + approve.tap() |
| + app.activate() |
| + XCTAssertTrue(app.buttons["Join meeting"].waitForExistence(timeout: 20)) |
| + XCTAssertTrue(app.staticTexts["Ready to join"].exists) |
| + let attachment = XCTAttachment(screenshot: app.screenshot()) |
| + attachment.name = "iPhone signed in through Safari" |
| + attachment.lifetime = .keepAlways |
| + add(attachment) |
| + } |
| +} |
diff --git a/native/ios/project.yml b/native/ios/project.yml| new file mode 100644 |
| index 0000000..9288c26 |
| --- /dev/null |
| +++ b/native/ios/project.yml |
| @@ -0,0 +1,72 @@ |
| +name: MeetPhone |
| +options: |
| + bundleIdPrefix: net.amadan.oficina |
| + deploymentTarget: |
| + iOS: '17.0' |
| +packages: |
| + MeetNative: |
| + path: ../apple |
| +settings: |
| + base: |
| + SWIFT_VERSION: '5.0' |
| + CODE_SIGN_STYLE: Automatic |
| + MARKETING_VERSION: '0.2.0' |
| + CURRENT_PROJECT_VERSION: '1' |
| +targets: |
| + MeetPhone: |
| + type: application |
| + platform: iOS |
| + sources: |
| + - Sources |
| + - Assets.xcassets |
| + - path: ../LICENSE.lucide |
| + buildPhase: resources |
| + dependencies: |
| + - package: MeetNative |
| + product: MeetKit |
| + settings: |
| + base: |
| + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| + PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.meet.ios |
| + TARGETED_DEVICE_FAMILY: '1,2' |
| + GENERATE_INFOPLIST_FILE: YES |
| + INFOPLIST_KEY_CFBundleDisplayName: Meet |
| + INFOPLIST_KEY_NSCameraUsageDescription: Meet uses your camera so people in your meeting can see you. |
| + INFOPLIST_KEY_NSMicrophoneUsageDescription: Meet uses your microphone so people in your meeting can hear you. |
| + INFOPLIST_KEY_UILaunchScreen_Generation: YES |
| + INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown |
| + MeetPhoneTests: |
| + type: bundle.unit-test |
| + platform: iOS |
| + sources: |
| + - Tests |
| + - ../apple/Sources/MeetInterop/SyntheticVideo.swift |
| + dependencies: |
| + - target: MeetPhone |
| + - package: MeetNative |
| + product: MeetKit |
| + settings: |
| + base: |
| + GENERATE_INFOPLIST_FILE: YES |
| + MeetPhoneUITests: |
| + type: bundle.ui-testing |
| + platform: iOS |
| + sources: [UITests] |
| + dependencies: |
| + - target: MeetPhone |
| + settings: |
| + base: |
| + GENERATE_INFOPLIST_FILE: YES |
| + TEST_TARGET_NAME: MeetPhone |
| + info: |
| + path: ../build/ios-test-info.plist |
| + properties: |
| + NSAppTransportSecurity: |
| + NSAllowsArbitraryLoads: true |
| +schemes: |
| + MeetPhone: |
| + build: |
| + targets: |
| + MeetPhone: all |
| + test: |
| + targets: [MeetPhoneUITests, MeetPhoneTests] |
diff --git a/native/scripts/build-bridge.sh b/native/scripts/build-bridge.sh| index 89d463b..3f462ca 100755 |
| --- a/native/scripts/build-bridge.sh |
| +++ b/native/scripts/build-bridge.sh |
| @@ -4,4 +4,4 @@ cd "$(dirname "$0")/.." |
| export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net" |
| mkdir -p build/bin |
| CGO_ENABLED=0 GOBIN="$PWD/build/bin" go install golang.org/x/mobile/cmd/gomobile golang.org/x/mobile/cmd/gobind |
| -PATH="$PWD/build/bin:$PATH" gomobile bind -target=macos -macosversion=14.0 -o build/MeetBridge.xcframework ./bridge |
| +PATH="$PWD/build/bin:$PATH" gomobile bind -target=macos,ios,iossimulator -macosversion=14.0 -iosversion=17.0 -o build/MeetBridge.xcframework ./bridge |
diff --git a/native/scripts/build-iphone.sh b/native/scripts/build-iphone.sh| new file mode 100755 |
| index 0000000..ea0761c |
| --- /dev/null |
| +++ b/native/scripts/build-iphone.sh |
| @@ -0,0 +1,19 @@ |
| +#!/bin/sh |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| +./scripts/build-bridge.sh |
| +xcodegen generate --spec ios/project.yml |
| +case "${1:-simulator}" in |
| + simulator) |
| + xcodebuild -project ios/MeetPhone.xcodeproj -scheme MeetPhone \ |
| + -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios-simulator \ |
| + CODE_SIGNING_ALLOWED=NO build |
| + ;; |
| + device) |
| + : "${MEET_DEVELOPMENT_TEAM:?Set MEET_DEVELOPMENT_TEAM to your Apple development team ID}" |
| + xcodebuild -project ios/MeetPhone.xcodeproj -scheme MeetPhone \ |
| + -destination 'generic/platform=iOS' -derivedDataPath build/ios-device \ |
| + "DEVELOPMENT_TEAM=$MEET_DEVELOPMENT_TEAM" -allowProvisioningUpdates build |
| + ;; |
| + *) echo 'Usage: build-iphone.sh [simulator|device]' >&2; exit 2 ;; |
| +esac |
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py| index 5732405..12eb118 100644 |
| --- a/native/scripts/bundle-mac.py |
| +++ b/native/scripts/bundle-mac.py |
| @@ -8,11 +8,28 @@ root = Path(__file__).resolve().parent.parent |
| bundle = root / 'build/Meet.app/Contents' |
| (bundle / 'MacOS').mkdir(parents=True, exist_ok=True) |
| (bundle / 'Frameworks').mkdir(parents=True, exist_ok=True) |
| +(bundle / 'Resources').mkdir(parents=True, exist_ok=True) |
| shutil.copy2(root / 'apple/.build/debug/MeetMac', bundle / 'MacOS/MeetMac') |
| +icon = root / 'apple/Sources/MeetMac/Resources/AppIcon.png' |
| +shutil.copy2(icon, bundle / 'Resources/AppIcon.png') |
| +shutil.copy2(root / 'LICENSE.lucide', bundle / 'Resources/LICENSE.lucide') |
| +# Finder reads the ICNS before the app launches; the PNG also supplies the |
| +# runtime Dock icon. Generate each required representation from the master. |
| +iconset = root / 'build/AppIcon.iconset' |
| +iconset.mkdir(parents=True, exist_ok=True) |
| +for size in (16, 32, 128, 256, 512): |
| + for scale in (1, 2): |
| + name = f'icon_{size}x{size}' + ('@2x' if scale == 2 else '') + '.png' |
| + subprocess.run(['sips', '-z', str(size * scale), str(size * scale), |
| + str(icon), '--out', str(iconset / name)], |
| + check=True, stdout=subprocess.DEVNULL) |
| +subprocess.run(['iconutil', '-c', 'icns', str(iconset), '-o', |
| + str(bundle / 'Resources/AppIcon.icns')], check=True) |
| info = { |
| 'CFBundleName': 'Meet', 'CFBundleDisplayName': 'Meet', |
| 'CFBundleIdentifier': 'net.amadan.oficina.meet', |
| 'CFBundleExecutable': 'MeetMac', 'CFBundlePackageType': 'APPL', |
| + 'CFBundleIconFile': 'AppIcon.icns', |
| 'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1', |
| 'LSMinimumSystemVersion': '14.0', 'NSHighResolutionCapable': True, |
| 'NSCameraUsageDescription': 'Meet uses your camera to share video in a meeting.', |
diff --git a/native/scripts/generate-iphone-icon.swift b/native/scripts/generate-iphone-icon.swift| new file mode 100644 |
| index 0000000..62a8bcf |
| --- /dev/null |
| +++ b/native/scripts/generate-iphone-icon.swift |
| @@ -0,0 +1,9 @@ |
| +import Foundation |
| + |
| +// Export the approved Oficina artwork already held by the asset catalog. |
| +// Regenerate it from oficina/estilo with brand/distribute.py meet <checkout>. |
| +let script = URL(fileURLWithPath: #filePath).standardizedFileURL |
| +let native = script.deletingLastPathComponent().deletingLastPathComponent() |
| +let source = native.appendingPathComponent("ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png") |
| +let png = try Data(contentsOf: source) |
| +try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1])) |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index f8c15ea..6aa3d61 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -286,6 +286,16 @@ async function observe(read, keys, opts) { |
| // Each side is {page, label, keys} — every frame key that side should |
| // be receiving, the other peer's camera included. |
| async function watchMedia(sides, at, opts) { |
| + for (const side of sides) { |
| + const correct = await side.page.evaluate(() => [...document.querySelectorAll('.meet-tile')].every(tile => { |
| + const video = tile.querySelector('video'); |
| + if (!video) return true; |
| + const transform = new DOMMatrix(getComputedStyle(video).transform); |
| + const expected = tile.classList.contains('meet-tile-self') && !tile.classList.contains('meet-tile-share') ? -1 : 1; |
| + return transform.a === expected; |
| + })); |
| + check(correct, side.label + ': only the local camera display is mirrored'); |
| + } |
| const keys = []; |
| for (const side of sides) { |
| for (const key of side.keys) keys.push(side.label + ' | ' + key); |
| @@ -1150,6 +1160,7 @@ const run = { |
| return p && p.hasStream && p.width > 0 ? p : false; |
| }, 15000); |
| check(preview, 'the preview is decoding real frames (' + JSON.stringify(preview || (await previewState(page))) + ')'); |
| + check(await page.locator('#preview').evaluate(video => new DOMMatrix(getComputedStyle(video).transform).a === -1), 'the holding camera preview is mirrored'); |
| if (preview) { |
| const rising = await until(async () => { |
| const p = await previewState(page); |
diff --git a/test/browser/native.mjs b/test/browser/native.mjs| index b0e6f49..07776a6 100644 |
| --- a/test/browser/native.mjs |
| +++ b/test/browser/native.mjs |
| @@ -2,11 +2,13 @@ import { chromium } from 'playwright'; |
| import { spawn } from 'node:child_process'; |
| import { createInterface } from 'node:readline'; |
| import assert from 'node:assert/strict'; |
| +import { fileURLToPath } from 'node:url'; |
| const [origin, room, binary, mode] = process.argv.slice(2); |
| const browser = await chromium.launch({ headless: true, args: [ |
| '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', |
| '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns', |
| + '--mute-audio', |
| ] }); |
| const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); |
| await context.addCookies([{ name: process.env.MEET_COOKIE_NAME, value: process.env.MEET_COOKIE_A, url: origin }]); |
| @@ -21,15 +23,30 @@ await page.addInitScript(() => { |
| let native; |
| try { |
| await page.goto(`${origin}/m/${room}`); |
| + await page.waitForFunction(() => !!window.meet); |
| + console.log('browser before native', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length}))); |
| native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); |
| native.stdin.write(process.env.MEET_NATIVE_INPUT + '\n'); |
| let sample = null; |
| + let chatCommandHandled = false; |
| + let chatSnapshotSaved = false; |
| + let invitationReady = false; |
| + let invitationRefused = false; |
| + let invitationSnapshotSaved = false; |
| let exited = false; |
| native.on('exit', () => { exited = true; }); |
| // Native logs must never print credentials, SDP or candidates. Keep only |
| // the gate's explicit numeric observations and suppress library diagnostics. |
| createInterface({ input: native.stdout }).on('line', line => { |
| - try { const value = JSON.parse(line); if (value.stats) sample = value; } catch {} |
| + try { |
| + const value = JSON.parse(line); |
| + if (value.chatSnapshotSaved) chatSnapshotSaved = true; |
| + if (value.chatCommandHandled) chatCommandHandled = true; |
| + if (value.invitationReady) invitationReady = true; |
| + if (value.invitationRefused) invitationRefused = true; |
| + if (value.invitationSnapshotSaved) invitationSnapshotSaved = true; |
| + if (value.stats) { sample = value; console.log(JSON.stringify(value)); } |
| + } catch {} |
| }); |
| native.stderr.resume(); |
| const wait = async (predicate, label, seconds = 35) => { |
| @@ -39,6 +56,7 @@ try { |
| if (await predicate()) return; |
| await new Promise(r => setTimeout(r, 250)); |
| } |
| + console.log('browser at failure', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length, body: document.body.innerText}))); |
| throw new Error(label + ': ' + JSON.stringify(sample)); |
| }; |
| if (mode === 'guest') { |
| @@ -72,17 +90,67 @@ try { |
| }); |
| await wait(async () => await browserAudio() > 0, 'browser receives native audio'); |
| console.log('PASS browser decoded native video and received native audio'); |
| + await page.locator('[data-chat-text]').fill('browser chat probe'); |
| + await page.locator('[data-chat-send]').click(); |
| + await wait(() => sample?.receivedChatProbe === true, 'native displays browser chat'); |
| + native.stdin.write('chat\n'); |
| + await wait(() => chatCommandHandled, 'native handles send-chat command'); |
| + await page.locator('[data-chat-log]').getByText('native chat probe', { exact: true }).waitFor(); |
| + await wait(() => sample?.sentChatProbe === true, 'native displays accepted outgoing chat'); |
| + console.log('PASS chat delivered from browser to native and native to browser'); |
| + if (mode === 'chat') { |
| + native.stdin.write('share\n'); |
| + await wait(() => invitationReady, 'native member retrieves the original invitation'); |
| + const expectedInvitation = JSON.parse(process.env.MEET_NATIVE_INPUT).invitation; |
| + const popupPromise = context.waitForEvent('page'); |
| + await page.getByRole('button', { name: 'Share meeting link', exact: true }).click(); |
| + const sharePage = await popupPromise; |
| + await sharePage.waitForLoadState(); |
| + assert.equal(await sharePage.locator('#invitation-link').getAttribute('href'), expectedInvitation); |
| + // Exercise Copy without changing the operator's system clipboard. |
| + await sharePage.evaluate(() => Object.defineProperty(navigator, 'clipboard', { |
| + value: { writeText: async value => { window.copiedInvitation = value; } }, |
| + })); |
| + await sharePage.getByRole('button', { name: 'Copy link', exact: true }).click(); |
| + assert.equal(await sharePage.evaluate(() => window.copiedInvitation), expectedInvitation); |
| + await sharePage.screenshot({ path: fileURLToPath(new URL('../../native/build/share-web.png', import.meta.url)) }); |
| + await sharePage.close(); |
| + const plain = await browser.newContext({ javaScriptEnabled: false }); |
| + await plain.addCookies([{ name: process.env.MEET_COOKIE_NAME, value: process.env.MEET_COOKIE_A, url: origin }]); |
| + const plainPage = await plain.newPage(); |
| + await plainPage.goto(`${origin}/m/${room}`); |
| + const plainPopup = plain.waitForEvent('page'); |
| + await plainPage.getByRole('button', { name: 'Share meeting link', exact: true }).click(); |
| + const plainShare = await plainPopup; |
| + await plainShare.waitForLoadState(); |
| + assert.equal(await plainShare.locator('#invitation-link').getAttribute('href'), expectedInvitation); |
| + await plain.close(); |
| + console.log('PASS native sharing, web Share and Copy, and scriptless sharing preserve the invitation'); |
| + for (let n = 1; n <= 12; n++) { |
| + await page.locator('[data-chat-text]').fill(`Incoming browser message ${n}`); |
| + await page.locator('[data-chat-send]').click(); |
| + await wait(() => sample?.receivedChatCount === n + 1, `native receives browser message ${n}`); |
| + } |
| + native.stdin.write('snapshot\n'); |
| + await wait(() => chatSnapshotSaved, 'native chat screenshot saved'); |
| + native.stdin.write('share-snapshot\n'); |
| + await wait(() => invitationSnapshotSaved, 'native invitation screenshot saved'); |
| + console.log('PASS all 12 browser messages reached native chat; rendered screenshot saved'); |
| + } else { |
| + native.stdin.write('share\n'); |
| + await wait(() => mode === 'guest' ? invitationRefused : invitationReady, 'native sharing follows current membership'); |
| const peer = sample.peer; |
| const beforeNative = sample.stats.framesDecoded; |
| const beforeBrowser = await remoteFrames(); |
| const beforeAudio = await browserAudio(); |
| const beforeNativeAudio = sample.stats.audioBytesReceived; |
| - // The test server renews streams every three seconds. Observe fresh frames |
| + // The test server renews streams every ten seconds. Observe fresh frames |
| // well after two renewals, and require the same native participation. |
| - await new Promise(r => setTimeout(r, 8000)); |
| + await new Promise(r => setTimeout(r, 22000)); |
| await wait(async () => sample.stats.framesDecoded > beforeNative + 20 && await remoteFrames() > beforeBrowser + 20 && await browserAudio() > beforeAudio && sample.stats.audioBytesReceived > beforeNativeAudio, 'media survives stream renewal'); |
| assert.equal(sample.peer, peer, 'resume must preserve the native participation'); |
| console.log('PASS native/browser media continues across SSE renewals'); |
| + } |
| const left = new Promise((resolve, reject) => { |
| native.once('exit', code => code === 0 ? resolve() : reject(new Error('native leave failed'))); |
| }); |