Vancetope Facelift — Specification
Status: v1. This spec is binding for the Capacitor shell under
client_web/packages/facelift-bridge/, the custom pluginclient_web/packages/facelift-account-webview/, as well as for the Facelift-specific code paths inclient_web/packages/vance-face/andclient_web/packages/shared/. Ongoing decisions + unstable plans (Share Extension, Push, …) are located underplanning/vance-facelift*.md.
1. Goals and Scope
vance-facelift is a native iOS shell around the deployed Vancetope
web UI. Instead of writing a mobile client from scratch, a Capacitor
wrapper loads the existing vance-face website per account in an
isolated WKWebView. Multi-identity, native bridges (Share, Voice, Push,
Files, Camera, …), and security-specific affordances (PIN/Biometric-Lock)
reside in the wrapper; the editor logic remains in the unchanged Vue website.
What Facelift is:
- A thin native wrapper (iOS, later Android) around the existing
Vue web UI stack. A single code path for editor features —
browser, Facelift, future desktop wrapper share the same
vance-facebundle files. - A replacement for the React Native skeleton under
client_web/packages/vance-fingers/. Fingers will no longer be developed; mobile features will land in Facelift. - An App Store distribution with clear native added value (multi- identity isolation, Share Extension, PIN lock, Files export, …) — sufficiently non-trivial to meet Apple Review Rule 4.2.
What Facelift v1 explicitly is not:
- No editor reimplementation. The website remains the source of truth; the wrapper does nothing with Chat / Inbox / Documents / … itself.
- No offline mode. WebView has internet or displays the website’s error page.
- No Auth stack in the wrapper. Login happens in the website; the wrapper knows brain URLs + optional display names + (for Phase 2 via Share Extension) a bearer token mirrored from login.
- No WebView added value beyond user-typing-URL-bar. Facelift is focused on Vancetope deployments; wrapper-side validation prevents non-Vancetope URLs from being saved (§6).
2. Module Structure
client_web/packages/
├── facelift-bridge/ @vance/facelift-bridge
├── facelift-account-webview/ @vance/facelift-account-webview
├── shared/ @vance/shared ← isFacelift(), requestBackToPicker(), …
└── vance-face/ @vance/vance-face ← the hosted website; knows Facelift
via UA-suffix detect, otherwise unchanged
2.1 @vance/facelift-bridge — the Wrapper
Capacitor app (Capacitor 6, iOS 17 deployment target, Vue 3 + Vite + Vue Router 4 + Tailwind). Bundle: only the Picker/Manage/Lock UI.
src/main.ts— Boot.configurePlatformis NOT wired; the wrapper does not host website logic, only the native bridges on top.src/router.ts— Hash history. Routes:/shell,/manage,/add,/edit/:id,/lock/setup,/lock/unlock. Auth guard (Lock) checksisUnlocked()and otherwise routes to/lock/unlockor/lock/setup.src/accounts/accountStore.ts— Persistent account list in@capacitor/preferences.Account = { id (UUID), faceUrl, displayName, createdAt, lastUsedAt }—faceUrlis the URL of thevance-facedeployment; the Brain is same-origin accessible via/brain/*proxy within it. Auto-activation on add.src/lock/lockStore.ts— PIN hash + salt in Preferences, Unlocked flag in-memory (§7).src/views/ShellView.vue— Host for the native per-account WKWebView. Persistent header (account switcher + Home + Reload- Manage); native WebView fills the rest (
VanceAccountWebViewplugin).
- Manage); native WebView fills the rest (
ios-template/— committed source files for native iOS setup, pushed into the Xcode project byscripts/cap-add-ios.sh:App.entitlements(App Group)VanceShareExtension/{ShareViewController.swift, Info.plist, MainInterface.storyboard, VanceShareExtension.entitlements}
scripts/cap-add-ios.sh— automates the one-time patches (deployment target up to 17, URL scheme + permission strings in Info.plist, App Group entitlement, Share Extension target via Rubyxcodeprojgem, App Icon + Splash via@capacitor/assets).scripts/integrate-share-extension.rb— programmatically adds the Share Extension target to the Xcode project.
Deps: @capacitor/core/ios/preferences/splash-screen/status-bar,
@vance/facelift-account-webview, vue + vue-router + pinia,
@capacitor/assets (dev). No Auth/REST/WS packages — intentionally.
2.2 @vance/facelift-account-webview — the Custom Plugin
Workspace-internal Capacitor plugin (Swift + TS-Proxy + .podspec). Responsible for:
- Multi-Identity WebView Hosting: one WKWebView per account with
its own
WKWebsiteDataStore(forIdentifier: accountUUID)→ true Cookie/IndexedDB/Service Worker isolation even with multiple users on the same Brain origin (§3.1). - JS Bridge (see §3.3) — Capacitor plugin methods for the wrapper Vue code, plus WKScriptMessageHandler for the website.
- WKNavigationDelegate for
vance-facelift://URL scheme capture (§4). - UA Suffix Injection (§5).
- Bridge User Script Injection (§3.3).
- Biometric API (
LAContextwrapper) — no external plugin, ~30 LoC Swift. - App Group File IO for the Share Extension (§8).
Sources/VanceAccountWebViewPlugin/VanceAccountWebViewPlugin.swift
is the only Swift file. Pod is named VanceFaceliftAccountWebview
(Cocoapods naming convention; not to be confused with vance-facelift
as a URL scheme).
2.3 Dependency Rules (Strict)
| Package | May depend on |
|---|---|
@vance/facelift-account-webview |
@capacitor/core (peer). No other workspace package. |
@vance/facelift-bridge |
@vance/facelift-account-webview, @capacitor/*, Vue/Pinia/Router. NOT on @vance/shared, @vance/vance-face, @vance/generated — the wrapper has no Vancetope business logic. |
@vance/shared |
unchanged. Contains the isFacelift() / requestBackToPicker() helpers that the website uses, but no wrapper imports. |
@vance/vance-face |
unchanged. Detects Facelift via isFacelift() from @vance/shared, calls window.vanceFacelift.* defensively. Does not fail if the bridge is missing (= in the browser). |
3. Per-Account WebView Model
3.1 Isolation
Each account in accountStore has a UUID (crypto.randomUUID()).
The plugin creates a WKWebView per UUID with
WKWebsiteDataStore(forIdentifier: UUID(accountId)!) — iOS 17+ feature
for persistent, named data stores. Cookies, IndexedDB, LocalStorage,
Service Workers live separately per UUID.
Consequence: two accounts on the identical Brain origin (e.g., the same
Tenant with different users, or multiple Tenants on
eddie.mhus.de) do not see each other. When switching accounts
via the bottom sheet, the other WebView is only isHidden=
true — sessions / scroll positions / active WS connections continue
to live.
Lifecycle:
present(accountId, url, bounds)— first time: WebView is created- URL loaded. Subsequent calls: show cached WebView + adjust bounds if necessary. No reload on re-show.
dismiss()— hide, cache remains.setBounds({top, left, width, height})— change frame (header resize, device rotation, bottom sheet animation).reload()—.reload()on the current WebView.navigateHome({accountId, url})— reload visible WebView, without touching the DataStore.remove({accountId})— clear WebView +WKWebsiteDataStore. remove(forIdentifier:). Called when removing an account in/manageso that the next reuse of the UUID gets an empty cookie jar.
3.2 Geometry
Wrapper header (Vue, in the Capacitor main WebView) persistently
sits on top. The plugin places the account WebView below the
header. ShellView.vue measures the header height via ResizeObserver +
window.resize and calls setBounds(...) through.
iOS Capacitor config: contentInset: 'never' so that CSS y=0 coincides
with UIKit y=0 — otherwise the native WebView overlaps the header
by the safe-area-top height. JS floor 110pt in currentBounds() as
a cold-start defensive (env(safe-area-inset-top) can return 0 in the very first
frame).
3.3 The window.vanceFacelift-Bridge
For each WebView, the plugin injects a WKUserScript at atDocumentStart:
window.vanceFacelift = {
accountId: '<uuid>', // substituted per WebView
exportFile(opts), // { name, mime, base64 } → iOS Files picker
setShareCredentials(opts), // { faceUrl, tenant, username, token, refreshToken? }
setProjectSnapshot(projects) // [{name, title?}, …]
};
Calls are passed to Swift via webkit.messageHandlers.vanceFacelift.postMessage(...);
Swift dispatches by action field.
Contract:
- The bridge is a fire-and-forget API (no result promises in v1). On success, the native action runs; on error, Swift logs to the Xcode console.
accountIdis read-only — the website thus knows its own wrapper UUID without pulling it from storage.- The bridge is only available in Facelift.
@vance/shared’sisFacelift()(User-Agent match) is the gate — iffalse,window.vanceFaceliftis undefined. - Bridge extensions are additive: new actions without schema bump are allowed, as long as existing calls continue to work.
4. vance-facelift://-URL Scheme
Registered in Info.plist via CFBundleURLTypes. WebView
navigation to vance-facelift://<action>[?params] is intercepted
by the plugin’s WKNavigationDelegate, canceled, and emitted as a
urlOpen event to JS. ShellView.vue listens + routes.
v1 Actions (host segment is the action name):
| URL | Effect in Wrapper |
|---|---|
vance-facelift://back-to-picker |
Native WebView dismiss(), Router → /manage. |
vance-facelift://add-account |
dismiss, Router → /add. |
vance-facelift://switch-account |
Open bottom sheet (account switcher). |
Contract:
- The website uses these URLs as “raise an event” — typically in
buttons / menu items that are only visible under Facelift
(cf.
EditorTopbar.vue’s User menu). - In the browser, these clicks are no-op (the scheme is not registered).
- New actions: Wrapper must extend the switch-case in
ShellView.handleFaceliftUrl(). Unknown actions are logged + ignored.
5. Facelift Detection
Per-account WebView sets WKWebViewConfiguration.applicationName-
ForUserAgent = "VanceFacelift/<version>". iOS appends this suffix
to the normal Safari UA:
Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15
(KHTML, like Gecko) Mobile/15E148 VanceFacelift/0.1.0
Detection Contract (@vance/shared/facelift/index.ts):
export function isFacelift(): boolean;
export function getFaceliftVersion(): string | null;
export function requestBackToPicker(): void;
export function requestSwitchAccount(): void;
export function requestAddAccount(): void;
isFacelift() matches /\bVanceFacelift\//. Brain-side identical via
User-Agent HTTP header — the Brain may respond differently to Facelift
(cookie settings, optionalized routes, etc.), but v1 does not do this.
request*() helpers fire `window.location.href = ‘vance-facelift://