oficina / meet Public

Complete native iPhone calls, shared chat and invitation sharing with Oficina icons

Paul Campbell pushed by paul@keymail.dev 95829cdbb61d0b236db8e1d162a28e1154b14084 parent 86ed9f3
136 files changed, +2445 −201
  • .gitignore +2 −0
  • AGENTS.md +3 −0
  • Makefile +10 −1
  • design/oficina-icons/INTEGRATION.md +71 −0
  • design/oficina-icons/LICENSE.lucide +43 −0
  • design/oficina-icons/README.md +47 −0
  • design/oficina-icons/generate.mjs +58 −0
  • design/oficina-icons/icons.json +9 −0
  • design/oficina-icons/png/calendar-1024.png binary
  • design/oficina-icons/png/calendar-16.png binary
  • design/oficina-icons/png/calendar-180.png binary
  • design/oficina-icons/png/calendar-192.png binary
  • design/oficina-icons/png/calendar-32.png binary
  • design/oficina-icons/png/calendar-48.png binary
  • design/oficina-icons/png/calendar-512.png binary
  • design/oficina-icons/png/docs-1024.png binary
  • design/oficina-icons/png/docs-16.png binary
  • design/oficina-icons/png/docs-180.png binary
  • design/oficina-icons/png/docs-192.png binary
  • design/oficina-icons/png/docs-32.png binary
  • design/oficina-icons/png/docs-48.png binary
  • design/oficina-icons/png/docs-512.png binary
  • design/oficina-icons/png/meet-1024.png binary
  • design/oficina-icons/png/meet-16.png binary
  • design/oficina-icons/png/meet-180.png binary
  • design/oficina-icons/png/meet-192.png binary
  • design/oficina-icons/png/meet-32.png binary
  • design/oficina-icons/png/meet-48.png binary
  • design/oficina-icons/png/meet-512.png binary
  • design/oficina-icons/png/memoria-1024.png binary
  • design/oficina-icons/png/memoria-16.png binary
  • design/oficina-icons/png/memoria-180.png binary
  • design/oficina-icons/png/memoria-192.png binary
  • design/oficina-icons/png/memoria-32.png binary
  • design/oficina-icons/png/memoria-48.png binary
  • design/oficina-icons/png/memoria-512.png binary
  • design/oficina-icons/png/oficina-1024.png binary
  • design/oficina-icons/png/oficina-16.png binary
  • design/oficina-icons/png/oficina-180.png binary
  • design/oficina-icons/png/oficina-192.png binary
  • design/oficina-icons/png/oficina-32.png binary
  • design/oficina-icons/png/oficina-48.png binary
  • design/oficina-icons/png/oficina-512.png binary
  • design/oficina-icons/png/sheets-1024.png binary
  • design/oficina-icons/png/sheets-16.png binary
  • design/oficina-icons/png/sheets-180.png binary
  • design/oficina-icons/png/sheets-192.png binary
  • design/oficina-icons/png/sheets-32.png binary
  • design/oficina-icons/png/sheets-48.png binary
  • design/oficina-icons/png/sheets-512.png binary
  • design/oficina-icons/png/slides-1024.png binary
  • design/oficina-icons/png/slides-16.png binary
  • design/oficina-icons/png/slides-180.png binary
  • design/oficina-icons/png/slides-192.png binary
  • design/oficina-icons/png/slides-32.png binary
  • design/oficina-icons/png/slides-48.png binary
  • design/oficina-icons/png/slides-512.png binary
  • design/oficina-icons/preview.png binary
  • design/oficina-icons/preview.svg +97 −0
  • design/oficina-icons/svg/calendar.svg +10 −0
  • design/oficina-icons/svg/docs.svg +5 −0
  • design/oficina-icons/svg/meet.svg +2 −0
  • design/oficina-icons/svg/memoria.svg +4 −0
  • design/oficina-icons/svg/oficina.svg +3 −0
  • design/oficina-icons/svg/sheets.svg +4 −0
  • design/oficina-icons/svg/slides.svg +3 −0
  • design/oficina-icons/upstream/book-search.svg +16 −0
  • design/oficina-icons/upstream/calendar-days.svg +22 −0
  • design/oficina-icons/upstream/file-text.svg +17 −0
  • design/oficina-icons/upstream/panels-top-left.svg +15 −0
  • design/oficina-icons/upstream/presentation.svg +15 −0
  • design/oficina-icons/upstream/table.svg +16 −0
  • design/oficina-icons/upstream/video.svg +14 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +9 −1
  • internal/meet/app.go +2 −1
  • internal/meet/handlers_meetings.go +33 −3
  • internal/meet/home_router_test.go +4 −1
  • internal/meet/ids.go +2 −2
  • internal/meet/migrations/0003_alter_meeting_links.sql +2 −0
  • internal/meet/migrations/schema.sql +1 −1
  • internal/meet/models.go +9 −9
  • internal/meet/render.go +1 −1
  • internal/meet/seal/seal.go +1 −0
  • internal/meet/static/brand/LICENSE.lucide +43 −0
  • internal/meet/static/brand/apple-touch-icon.png binary
  • internal/meet/static/brand/brand.css +19 −0
  • internal/meet/static/brand/icon-32.png binary
  • internal/meet/static/brand/icon.svg +2 −0
  • internal/meet/static/meet.css +3 −2
  • internal/meet/static/share-link.js +26 −0
  • internal/meet/store.go +51 −5
  • internal/meet/store_share_test.go +94 −0
  • internal/meet/templates/join.html +1 −0
  • internal/meet/templates/layout.html +5 −1
  • internal/meet/templates/link_once.html +1 −1
  • internal/meet/templates/room.html +1 −0
  • internal/meet/templates/share.html +14 −0
  • internal/meettest/iphone_browser_test.go +102 −0
  • internal/meettest/join_test.go +1 −1
  • internal/meettest/native_browser_test.go +18 −4
  • internal/meettest/share_test.go +57 −0
  • internal/meettest/signin_test.go +6 −6
  • meetcore/room.go +36 −1
  • meetcore/room_test.go +109 −0
  • meetcore/transport.go +27 −0
  • meetcore/transport_test.go +41 −0
  • native/LICENSE.lucide +43 −0
  • native/README.md +29 −10
  • native/VERIFICATION.md +32 −0
  • native/apple/Package.swift +3 −2
  • native/apple/Sources/MeetInterop/Interop.swift +76 −4
  • native/apple/Sources/MeetKit/Call.swift +82 −14
  • native/apple/Sources/MeetKit/Capture.swift +8 −2
  • native/apple/Sources/MeetKit/ChatView.swift +52 −0
  • native/apple/Sources/MeetKit/Media.swift +30 −3
  • native/apple/Sources/MeetKit/MeetModel.swift +137 −0
  • native/apple/Sources/MeetKit/MeetingShareButton.swift +64 −0
  • native/apple/Sources/MeetKit/Wire.swift +1 −0
  • native/apple/Sources/MeetMac/MeetMac.swift +16 −120
  • native/apple/Sources/MeetMac/Resources/AppIcon.png binary
  • native/bridge/bridge.go +2 −1
  • native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png binary
  • native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +1 −0
  • native/ios/Assets.xcassets/Contents.json +1 −0
  • native/ios/README.md +74 −0
  • native/ios/Sources/MeetPhone.swift +196 −0
  • native/ios/Tests/MeetPhoneTests.swift +61 −0
  • native/ios/Tests/SilentAudioDevice.swift +54 −0
  • native/ios/UITests/MeetPhoneUITests.swift +76 −0
  • native/ios/project.yml +72 −0
  • native/scripts/build-bridge.sh +1 −1
  • native/scripts/build-iphone.sh +19 −0
  • native/scripts/bundle-mac.py +17 −0
  • native/scripts/generate-iphone-icon.swift +9 −0
  • test/browser/call.mjs +11 −0
  • test/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')));
});