Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# Capacitor / native build artifacts. Source files committed for the
# scaffold; everything below is regenerated by `cap sync` + native builds.
node_modules/
dist/
build/
# iOS
ios/App/Pods/
ios/App/build/
ios/App/Podfile.lock
ios/App/App/public/
ios/DerivedData/
*.xcuserdata
# Android
android/.gradle/
android/build/
android/app/build/
android/app/src/main/assets/public/
android/local.properties
android/captured-screenshots/
*.keystore
!debug.keystore
# Generated icons / splash (regenerated from brand assets)
ios/App/App/Assets.xcassets/AppIcon.appiconset/*.png
ios/App/App/Assets.xcassets/Splash.imageset/*.png
android/app/src/main/res/mipmap-*/ic_launcher*.png
android/app/src/main/res/drawable*/splash.png
+102
View File
@@ -0,0 +1,102 @@
# @toolctrl/mobile — Capacitor 7 native wrapper
This workspace bundles the React Router v7 web app at `apps/web/` as a
native iOS + Android shell via [Capacitor 7](https://capacitorjs.com/).
It is **not** a separate UI codebase — there is exactly one product UI
(the web app). This package only owns:
- `capacitor.config.ts` — bundle id, web-dir, plugin config
- `ios/` and `android/` native project skeletons (generated by `cap add`)
- `src/auth.ts` — mobile session persistence wrapper around `@toolctrl/api-client-ts`
- `src/sentry.ts` — Sentry-Capacitor bootstrap (mirrors `apps/web/app/sentry.client.ts`)
- `src/pushRegistration.ts` — APNs/FCM token registration against the backend
- Generated app icons and splash screens (under `ios/` and `android/`)
## Status
**Code scaffold + push backend** is shipped under task #65 (Phase 08).
Actual native builds, Xcode signing, App-Store / Play-Store submission are
operator-owned tasks and are explicitly deferred — see
[`docs/operations/capacitor.md`](../../docs/operations/capacitor.md).
## Local development
```bash
# 1. Install workspace deps (from repo root)
pnpm install
# 2. Build the web bundle that the native wrapper will load
pnpm --filter @toolctrl/mobile build:web
# 3. Sync web assets + plugins into the native projects
pnpm --filter @toolctrl/mobile sync
# 4. Open in Xcode / Android Studio
pnpm --filter @toolctrl/mobile open:ios
pnpm --filter @toolctrl/mobile open:android
```
### Live reload (dev workflow)
For tight iteration loops point the wrapper at a running dev origin
instead of the bundled `webDir`:
```bash
export MOBILE_LIVE_RELOAD_URL=http://localhost:3000 # iOS simulator
# export MOBILE_LIVE_RELOAD_URL=http://10.0.2.2:3000 # Android emulator
pnpm --filter @toolctrl/mobile sync
```
This injects the URL into `capacitor.config.ts > server.url`. On the
emulator/simulator the app then loads from your dev box every reload.
**Never set this for production builds.**
## Plugins
| Plugin | Purpose |
|---|---|
| `@capacitor/app` | App lifecycle + custom URL scheme handling |
| `@capacitor/browser` | In-app browser for the login flow |
| `@capacitor/status-bar` | Native status-bar theming |
| `@capacitor/splash-screen` | Splash on cold start |
| `@capacitor/push-notifications` | APNs + FCM token + display |
| `@capacitor/local-notifications` | Foreground reminders + UAT smoke |
| `@capacitor/preferences` | UserDefaults (iOS) / SharedPreferences (Android) session persistence |
`@capacitor/preferences` is not encrypted storage. Keep bearer material
short-lived here, or replace the auth storage adapter with a native
secure-storage plugin before persisting long-lived refresh tokens.
## Why the native folders look minimal
When this scaffold was committed the agent did **not** have a working
local Xcode / Android-Studio install, so the iOS and Android projects
ship as the minimum subset required for `cap sync` to succeed:
- `apps/mobile/ios/App/App/Info.plist`, `App.xcodeproj/project.pbxproj`,
AppDelegate stubs
- `apps/mobile/android/app/build.gradle`, `AndroidManifest.xml`,
`MainActivity.java`, `settings.gradle`
An operator with the native tooling installed should run
```bash
cd apps/mobile
rm -rf ios android
npx cap add ios
npx cap add android
npx cap sync
```
once to overwrite these skeletons with the canonical CLI output. Bundle
id, app name and webDir stay identical — Capacitor reads them from
`capacitor.config.ts`.
## See also
- [`docs/improvement-plan/08-mobile-cli-storybook-sdk/capacitor.md`](../../docs/improvement-plan/08-mobile-cli-storybook-sdk/capacitor.md)
full spec + deviation log
- [`docs/operations/capacitor.md`](../../docs/operations/capacitor.md)
build / signing / submission runbook
@@ -0,0 +1,51 @@
# Android native project (skeleton)
Generated by hand for task #65 (Phase 08). The committed structure is
intentionally minimal — operators with Android Studio installed should run:
```bash
cd apps/mobile
rm -rf android
npx cap add android
npx cap sync android
```
…to overwrite this skeleton with the canonical Capacitor CLI output.
Application id, app name and webDir are sourced from
`apps/mobile/capacitor.config.ts` and stay stable across regenerations.
## Layout
```
android/
├── build.gradle # Top-level build config (AGP 8.7.2)
├── settings.gradle # Module includes + plugin paths
├── capacitor.settings.gradle # Per-plugin Gradle modules
├── gradle.properties # JVM heap + AndroidX
├── gradle/wrapper/
│ └── gradle-wrapper.properties # Gradle 8.11.1
└── app/
├── build.gradle # applicationId, sdk, deps, FCM hook
├── capacitor.build.gradle # Plugin module includes
└── src/main/
├── AndroidManifest.xml # Permissions, intent-filters, FileProvider
├── java/de/toolctrl/app/MainActivity.java # BridgeActivity
└── res/
├── values/strings.xml
├── values/styles.xml
├── values/colors.xml
├── xml/file_paths.xml
└── xml/network_security_config.xml
```
## Signing & store submission
Operator-owned. See `docs/operations/capacitor.md` for the full
Play-Console runbook (keystore generation, Gradle signing config,
service-account JSON, internal-track upload).
## Push notifications
`google-services.json` must be dropped into `android/app/` before any
release build. The plugin is wired conditionally in `app/build.gradle`
so debug builds without the file still compile.
@@ -0,0 +1,56 @@
apply plugin: 'com.android.application'
android {
namespace 'de.toolctrl.app'
compileSdk 34
defaultConfig {
applicationId 'de.toolctrl.app'
minSdkVersion 23
targetSdkVersion 34
versionCode 1
versionName '1.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
aaptOptions {
// Files and dirs to omit from the APK
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
repositories {
flatDir {
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation project(':capacitor-android')
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push notifications will not be available.")
}
@@ -0,0 +1,24 @@
// Generated by `npx cap sync android`. Hand-scaffolded skeleton here.
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor.settings.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
implementation project(':sentry-capacitor')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for any HTTP traffic + push registration with FCM. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- API 33+: runtime POST_NOTIFICATIONS permission for displaying pushes. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Biometric prompt (Touch / Fingerprint). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- Optional: camera for avatar / document capture (matches NSCameraUsageDescription on iOS). -->
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name="de.toolctrl.app.MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Custom URL scheme: toolctrl:// -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="toolctrl" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
</application>
</manifest>
@@ -0,0 +1,10 @@
package de.toolctrl.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
// The Capacitor 7 BridgeActivity wires plugin lifecycle, web-view
// configuration and deep-link handling. We do not override anything
// here; native customization (eg. status-bar color overrides) lives
// in AndroidManifest.xml + res/values/styles.xml.
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splash_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">ToolCtrl</string>
<string name="title_activity_main">ToolCtrl</string>
<string name="package_name">de.toolctrl.app</string>
<string name="custom_url_scheme">toolctrl</string>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">#2563eb</item>
<item name="colorPrimaryDark">#1e40af</item>
<item name="colorAccent">#2563eb</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="my_images" path="."/>
<cache-path name="my_cache_images" path="."/>
</paths>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- HTTPS by default. The committed skeleton permits cleartext only to
localhost and the Android emulator host loopback (10.0.2.2) so
live-reload can reach `pnpm --filter @toolctrl/web dev`. -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<debug-overrides>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</debug-overrides>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,23 @@
// Top-level build file. Hand-scaffolded skeleton for task #65;
// regenerated by `npx cap add android` on the operator machine.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.2'
classpath 'com.google.gms:google-services:4.4.2'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,27 @@
// Generated by `npx cap sync android` on the operator machine. This
// hand-scaffolded variant lists the plugins pinned in
// apps/mobile/package.json so the Android Studio project file system
// is consistent before the canonical regeneration.
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../../node_modules/@capacitor/app/android')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../../../node_modules/@capacitor/browser/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../../../node_modules/@capacitor/local-notifications/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../../node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../../../node_modules/@capacitor/push-notifications/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/@capacitor/splash-screen/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../../../node_modules/@capacitor/status-bar/android')
include ':sentry-capacitor'
project(':sentry-capacitor').projectDir = new File('../../../node_modules/@sentry/capacitor/android')
@@ -0,0 +1,4 @@
# Gradle project-level properties. Hand-scaffolded for task #65.
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
android.enableJetifier=true
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../../node_modules/@capacitor/android/capacitor')
apply from: 'capacitor.settings.gradle'
@@ -0,0 +1,97 @@
/**
* Capacitor 7 configuration for the toolctrl mobile wrapper.
*
* The mobile app re-uses the React Router v7 build artifact produced by
* `pnpm --filter @toolctrl/web build` (written to `apps/web/build/client`)
* as its `webDir`. In development the Capacitor runtime can point at a
* live web origin via `MOBILE_LIVE_RELOAD_URL` (for example
* `http://localhost:3000`, `http://10.0.2.2:3000`, or a staging URL) so
* iterating on the UI does not require a full native rebuild.
*
* AppId is fixed to `de.toolctrl.app` — must match the iOS Bundle-ID and
* the Android applicationId in `apps/mobile/android/app/build.gradle`.
*
* @see docs/improvement-plan/08-mobile-cli-storybook-sdk/capacitor.md
* @see docs/operations/capacitor.md
*/
import type { CapacitorConfig } from "@capacitor/cli";
export function readLiveReloadUrl(rawValue = process.env.MOBILE_LIVE_RELOAD_URL): string | undefined {
const trimmed = rawValue?.trim();
if (!trimmed) return undefined;
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed)) {
throw new Error("MOBILE_LIVE_RELOAD_URL must be an absolute http(s) URL, e.g. http://10.0.2.2:3000.");
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error("MOBILE_LIVE_RELOAD_URL must be an absolute http(s) URL, e.g. http://10.0.2.2:3000.");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("MOBILE_LIVE_RELOAD_URL must use http:// for local dev or https:// for staging.");
}
return trimmed;
}
const liveReloadUrl = readLiveReloadUrl();
const config: CapacitorConfig = {
appId: "de.toolctrl.app",
appName: "ToolCtrl",
// Web assets are produced by the @toolctrl/web React Router v7 build.
// Path is relative to this config file (apps/mobile/) — the build
// artifact lives under apps/web/build/client.
webDir: "../web/build/client",
// Server config is only emitted when MOBILE_LIVE_RELOAD_URL is set.
// Production builds (CI / store submissions) must run with this env var
// unset so the bundle uses the embedded webDir assets.
server: liveReloadUrl
? {
url: liveReloadUrl,
// Allow http only when the URL is explicitly http:// (dev mode);
// production live-reload against staging should be https://.
cleartext: liveReloadUrl.startsWith("http://"),
}
: undefined,
ios: {
// Keep native WebView scrolling enabled while letting iOS manage safe
// area insets. Status-bar styling is managed by @capacitor/status-bar.
scrollEnabled: true,
contentInset: "automatic",
},
android: {
// Keep mixed content disabled. Cleartext live-reload exceptions are
// constrained in network_security_config.xml; see docs/operations/capacitor.md.
allowMixedContent: false,
},
plugins: {
SplashScreen: {
launchShowDuration: 1500,
launchAutoHide: true,
backgroundColor: "#ffffff",
androidScaleType: "CENTER_CROP",
splashFullScreen: true,
splashImmersive: true,
},
StatusBar: {
style: "default",
overlaysWebView: false,
},
PushNotifications: {
presentationOptions: ["badge", "sound", "alert"],
},
Preferences: {
// Group name maps to the iOS UserDefaults suite and the Android
// SharedPreferences file. Keep stable across releases to avoid
// orphaning stored session state.
group: "de.toolctrl.app.secure",
},
},
};
export default config;
@@ -0,0 +1,22 @@
// !$*UTF8*$!
//
// Hand-scaffolded Xcode project skeleton for task #65 (Phase 08).
//
// This file is intentionally minimal — an operator with Xcode installed
// should run `cd apps/mobile && rm -rf ios && npx cap add ios` once to
// generate the canonical pbxproj. The skeleton here is enough for
// `cap sync` to recognize the platform directory and for git to track
// the project shape.
//
// Bundle ID: de.toolctrl.app (matches capacitor.config.ts > appId)
// Deployment Target: iOS 14.0
// Swift Version: 5.0
//
// See docs/operations/capacitor.md for full setup steps.
{
archiveVersion = 1;
classes = {};
objectVersion = 56;
objects = {};
rootObject = "";
}
@@ -0,0 +1,38 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
func applicationWillResignActive(_ application: UIApplication) {}
func applicationDidEnterBackground(_ application: UIApplication) {}
func applicationWillEnterForeground(_ application: UIApplication) {}
func applicationDidBecomeActive(_ application: UIApplication) {}
func applicationWillTerminate(_ application: UIApplication) {}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
// Custom URL scheme entry point for deep-links (toolctrl://...).
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Universal-Links entry point.
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
// MARK: - Push notifications
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
}
@@ -0,0 +1,14 @@
{
"images": [
{ "idiom": "iphone", "scale": "2x", "size": "20x20" },
{ "idiom": "iphone", "scale": "3x", "size": "20x20" },
{ "idiom": "iphone", "scale": "2x", "size": "29x29" },
{ "idiom": "iphone", "scale": "3x", "size": "29x29" },
{ "idiom": "iphone", "scale": "2x", "size": "40x40" },
{ "idiom": "iphone", "scale": "3x", "size": "40x40" },
{ "idiom": "iphone", "scale": "2x", "size": "60x60" },
{ "idiom": "iphone", "scale": "3x", "size": "60x60" },
{ "idiom": "ios-marketing", "scale": "1x", "size": "1024x1024" }
],
"info": { "author": "xcode", "version": 1 }
}
@@ -0,0 +1,3 @@
{
"info": { "author": "xcode", "version": 1 }
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>ToolCtrl</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Used for avatar uploads and document capture inside ToolCtrl.</string>
<key>NSFaceIDUsageDescription</key>
<string>Used to unlock your ToolCtrl session without re-entering your password.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Used to attach images to records and messages.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
@@ -0,0 +1,34 @@
# Podfile generated by hand as part of the apps/mobile scaffold for
# task #65 (Phase 08). An operator with Xcode installed should run
# `cd apps/mobile && npx cap sync ios && cd ios/App && pod install`
# which will rewrite this file in canonical form and lock dependencies
# via Podfile.lock (gitignored — regenerated by the install step).
require_relative '../../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires a clean build
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../../node_modules/@capacitor/app'
pod 'CapacitorBrowser', :path => '../../../node_modules/@capacitor/browser'
pod 'CapacitorLocalNotifications', :path => '../../../node_modules/@capacitor/local-notifications'
pod 'CapacitorPreferences', :path => '../../../node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../../node_modules/@capacitor/push-notifications'
pod 'CapacitorSplashScreen', :path => '../../../node_modules/@capacitor/splash-screen'
pod 'CapacitorStatusBar', :path => '../../../node_modules/@capacitor/status-bar'
pod 'SentryCapacitor', :path => '../../../node_modules/@sentry/capacitor'
end
target 'App' do
capacitor_pods
end
post_install do |installer|
assertDeploymentTarget(installer)
end
+41
View File
@@ -0,0 +1,41 @@
# iOS native project (skeleton)
Generated by hand for task #65 (Phase 08). The committed structure
is intentionally minimal — operators with Xcode installed should run:
```bash
cd apps/mobile
rm -rf ios
npx cap add ios
npx cap sync ios
cd ios/App
pod install
open App.xcworkspace
```
…to overwrite this skeleton with the canonical Capacitor CLI output.
Bundle id, app name and webDir are sourced from
`apps/mobile/capacitor.config.ts` and stay stable across regenerations.
## Layout
```
ios/
└── App/
├── App.xcodeproj/ # Xcode project (skeleton)
├── App/
│ ├── AppDelegate.swift # Push + universal-link entry points
│ ├── Info.plist # Permissions, ATS, bundle metadata
│ ├── Base.lproj/
│ │ ├── LaunchScreen.storyboard
│ │ └── Main.storyboard (CAPBridgeViewController)
│ └── Assets.xcassets/
│ └── AppIcon.appiconset/Contents.json
└── Podfile # Capacitor + plugin pod deps
```
## Signing & store submission
Operator-owned. See `docs/operations/capacitor.md` for the full
TestFlight / App-Store-Connect runbook (Apple-ID, signing certificate,
provisioning profile, App-Store-Connect API key, transporter upload).
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@toolctrl/mobile",
"version": "0.0.0",
"private": true,
"description": "Capacitor 7 native wrapper for the toolctrl web app — iOS + Android shell that consumes @toolctrl/api-client-ts and the existing apps/web build artifact.",
"type": "module",
"sideEffects": false,
"scripts": {
"cap": "cap",
"sync": "cap sync",
"open:ios": "cap open ios",
"open:android": "cap open android",
"build:web": "pnpm --filter @toolctrl/web build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run src"
},
"dependencies": {
"@capacitor/app": "7.0.1",
"@capacitor/browser": "7.0.1",
"@capacitor/core": "7.4.3",
"@capacitor/local-notifications": "7.0.1",
"@capacitor/preferences": "7.0.1",
"@capacitor/push-notifications": "7.0.1",
"@capacitor/splash-screen": "7.0.1",
"@capacitor/status-bar": "7.0.1",
"@sentry/capacitor": "1.5.0",
"@toolctrl/api-client-ts": "workspace:*"
},
"devDependencies": {
"@capacitor/cli": "7.4.3",
"@capacitor/ios": "7.4.3",
"@capacitor/android": "7.4.3",
"@types/node": "^25.0.0",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
+188
View File
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureSessionStorage,
rehydrateAuth,
persistSession,
clearSession,
createMobileApiClient,
type MobileSession,
type SessionStorage,
} from "./auth.js";
function createMemoryStorage(): SessionStorage & { _value: MobileSession | null } {
const state: { value: MobileSession | null } = { value: null };
return {
async read() {
return state.value;
},
async write(session) {
state.value = session;
},
async clear() {
state.value = null;
},
get _value() {
return state.value;
},
};
}
describe("rehydrateAuth", () => {
beforeEach(() => {
configureSessionStorage(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns null when no storage adapter is configured and Capacitor is absent", async () => {
// Force the default path; node + no Capacitor + no localStorage seed.
// Some node test runners surface a `localStorage` global without the
// full API surface, so we only call methods we know exist.
const ls = (globalThis as { localStorage?: { removeItem?: (k: string) => void } }).localStorage;
if (typeof ls?.removeItem === "function") {
ls.removeItem("toolctrl.session.v1");
}
const result = await rehydrateAuth();
expect(result).toBeNull();
});
it("returns the stored session when one exists", async () => {
const storage = createMemoryStorage();
configureSessionStorage(storage);
await persistSession({ token: "abc", userId: "u-1", tenantId: "t-1" });
const result = await rehydrateAuth();
expect(result).toEqual({ token: "abc", userId: "u-1", tenantId: "t-1" });
});
it("swallows storage errors and returns null", async () => {
configureSessionStorage({
async read() {
throw new Error("keychain locked");
},
async write() {
/* noop */
},
async clear() {
/* noop */
},
});
expect(await rehydrateAuth()).toBeNull();
});
it("rehydrates a valid session from the web localStorage fallback", async () => {
const localStorage = createLocalStorageStub({
"toolctrl.session.v1": JSON.stringify({
token: "tok-local",
userId: "user-local",
tenantId: "tenant-local",
expiresAt: 1_800_000_000_000,
}),
});
vi.stubGlobal("localStorage", localStorage);
await expect(rehydrateAuth()).resolves.toEqual({
token: "tok-local",
userId: "user-local",
tenantId: "tenant-local",
expiresAt: 1_800_000_000_000,
});
});
it("ignores corrupt or incomplete localStorage sessions", async () => {
const localStorage = createLocalStorageStub({
"toolctrl.session.v1": JSON.stringify({ token: "", userId: "user-local", tenantId: "tenant-local" }),
});
vi.stubGlobal("localStorage", localStorage);
await expect(rehydrateAuth()).resolves.toBeNull();
localStorage.setItem("toolctrl.session.v1", "not-json");
await expect(rehydrateAuth()).resolves.toBeNull();
});
});
describe("persistSession / clearSession", () => {
it("writes the session via the configured storage and clears it on demand", async () => {
const storage = createMemoryStorage();
configureSessionStorage(storage);
await persistSession({ token: "x", userId: "u", tenantId: null });
expect(storage._value).toEqual({ token: "x", userId: "u", tenantId: null });
await clearSession();
expect(storage._value).toBeNull();
});
it("uses the web localStorage fallback when no native storage bridge is available", async () => {
configureSessionStorage(null);
const localStorage = createLocalStorageStub();
vi.stubGlobal("localStorage", localStorage);
await persistSession({ token: "tok-web", userId: "user-web", tenantId: "" });
expect(JSON.parse(localStorage.getItem("toolctrl.session.v1") ?? "{}")).toEqual({
token: "tok-web",
userId: "user-web",
tenantId: "",
});
await expect(rehydrateAuth()).resolves.toEqual({ token: "tok-web", userId: "user-web", tenantId: null });
await clearSession();
expect(localStorage.getItem("toolctrl.session.v1")).toBeNull();
});
});
describe("createMobileApiClient", () => {
it("creates a client whose Authorization header rehydrates from storage on each call", async () => {
const storage = createMemoryStorage();
configureSessionStorage(storage);
let observedAuth: string | null = null;
const fakeFetch: typeof fetch = async (_input, init) => {
const headers = new Headers(init?.headers);
observedAuth = headers.get("authorization");
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
};
const client = await createMobileApiClient({
baseUrl: "https://api.example.test",
storage,
fetchImpl: fakeFetch,
});
expect(client).toBeDefined();
// First call has no session => bearer is empty (Headers normalizes
// trailing whitespace, so the observed value is just "Bearer").
await client.request({ method: "GET", path: "/v1/status" });
expect(observedAuth).toBe("Bearer");
// Persist a session => the same client now forwards the live token
await persistSession({ token: "tok-42", userId: "u-1", tenantId: "t-1" });
await client.request({ method: "GET", path: "/v1/status" });
expect(observedAuth).toBe("Bearer tok-42");
});
});
function createLocalStorageStub(seed: Record<string, string> = {}): Storage {
const values = new Map(Object.entries(seed));
return {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return [...values.keys()][index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
};
}
+216
View File
@@ -0,0 +1,216 @@
/**
* Native auth wrapper for the Capacitor mobile shell.
*
* Bridges three concerns:
*
* 1. Persistent session-token storage via `@capacitor/preferences` (backed by
* UserDefaults on iOS and SharedPreferences on Android — see the plugin's
* docs). Web fallback uses plain `localStorage` so the same code path works
* in the Vite dev server. This adapter is not encrypted storage; use
* short-lived bearer material or swap in a native secure-storage adapter
* before persisting long-lived refresh tokens.
* 2. Construction of a `@toolctrl/api-client-ts` client wired to the
* stored token via the `getToken` callback the SDK exposes — this is
* the only sanctioned way to inject a refresh-aware bearer.
* 3. Login / logout helpers that the React Router v7 web app calls from
* its `nativeAuth.server` adapter when `Capacitor.isNativePlatform()`
* returns true.
*
* The plugin import is intentionally dynamic — Vitest under node resolves
* `@capacitor/preferences` against a fallback so unit tests can swap
* implementations without dragging the native bridge into the bundle.
*
* @see ./auth.test.ts for the contract surface
* @see packages/api-client-ts/src/client.ts ToolctrlAuthConfig
*/
import { createToolctrlClient, type ToolctrlClient } from "@toolctrl/api-client-ts";
const SESSION_PREF_KEY = "toolctrl.session.v1";
export interface MobileSession {
/** Bearer token forwarded as `Authorization: Bearer <token>`. */
token: string;
/** Owning user id (denormalized so we can render an account page offline). */
userId: string;
/** Active tenant scope; `null` when the user is in tenant-picker state. */
tenantId: string | null;
/** Optional expiry epoch-ms — when set, callers should refresh before this. */
expiresAt?: number;
}
/**
* Pluggable secure-storage adapter. The default implementation lazy-loads
* `@capacitor/preferences`; tests inject a Map-backed adapter via
* `configureSessionStorage()`.
*/
export interface SessionStorage {
read(): Promise<MobileSession | null>;
write(session: MobileSession): Promise<void>;
clear(): Promise<void>;
}
let storageOverride: SessionStorage | null = null;
/** Test-seam — swap the storage implementation. */
export function configureSessionStorage(adapter: SessionStorage | null): void {
storageOverride = adapter;
}
async function getStorage(): Promise<SessionStorage> {
if (storageOverride) return storageOverride;
return defaultCapacitorStorage;
}
/**
* Default storage adapter. Uses dynamic import so the native bridge is
* only required at runtime — unit tests that never call `getStorage()`
* never load the native package.
*/
const defaultCapacitorStorage: SessionStorage = {
async read(): Promise<MobileSession | null> {
const prefs = await loadPreferencesPlugin();
if (!prefs) return readFromLocalStorage();
const { value } = await prefs.get({ key: SESSION_PREF_KEY });
if (!value) return null;
return parseSession(value);
},
async write(session: MobileSession): Promise<void> {
const prefs = await loadPreferencesPlugin();
const serialized = JSON.stringify(session);
if (!prefs) {
writeToLocalStorage(serialized);
return;
}
await prefs.set({ key: SESSION_PREF_KEY, value: serialized });
},
async clear(): Promise<void> {
const prefs = await loadPreferencesPlugin();
if (!prefs) {
removeFromLocalStorage();
return;
}
await prefs.remove({ key: SESSION_PREF_KEY });
},
};
interface PreferencesPlugin {
get(opts: { key: string }): Promise<{ value: string | null }>;
set(opts: { key: string; value: string }): Promise<void>;
remove(opts: { key: string }): Promise<void>;
}
async function loadPreferencesPlugin(): Promise<PreferencesPlugin | null> {
// Skip the native plugin entirely when Capacitor reports a non-native
// platform (incl. our Vitest node-runner unit tests). The web proxy
// of @capacitor/preferences throws an "UNIMPLEMENTED" error if we
// call get/set without a native bridge attached.
try {
const core: { Capacitor?: { isNativePlatform?: () => boolean } } = await import(
/* @vite-ignore */ "@capacitor/core"
);
if (typeof core.Capacitor?.isNativePlatform === "function" && !core.Capacitor.isNativePlatform()) {
return null;
}
} catch {
return null;
}
try {
const mod: { Preferences?: PreferencesPlugin } = await import(
/* @vite-ignore */ "@capacitor/preferences"
);
return mod.Preferences ?? null;
} catch {
return null;
}
}
function parseSession(value: string): MobileSession | null {
try {
const parsed: unknown = JSON.parse(value);
if (!parsed || typeof parsed !== "object") return null;
const record = parsed as Record<string, unknown>;
if (typeof record.token !== "string" || record.token === "") return null;
if (typeof record.userId !== "string" || record.userId === "") return null;
const tenantId = typeof record.tenantId === "string" && record.tenantId !== "" ? record.tenantId : null;
const expiresAt = typeof record.expiresAt === "number" ? record.expiresAt : undefined;
return { token: record.token, userId: record.userId, tenantId, expiresAt };
} catch {
return null;
}
}
function readFromLocalStorage(): MobileSession | null {
if (typeof globalThis === "undefined") return null;
const ls = (globalThis as { localStorage?: Storage }).localStorage;
if (!ls || typeof ls.getItem !== "function") return null;
const value = ls.getItem(SESSION_PREF_KEY);
return value ? parseSession(value) : null;
}
function writeToLocalStorage(value: string): void {
if (typeof globalThis === "undefined") return;
const ls = (globalThis as { localStorage?: Storage }).localStorage;
if (ls && typeof ls.setItem === "function") {
ls.setItem(SESSION_PREF_KEY, value);
}
}
function removeFromLocalStorage(): void {
if (typeof globalThis === "undefined") return;
const ls = (globalThis as { localStorage?: Storage }).localStorage;
if (ls && typeof ls.removeItem === "function") {
ls.removeItem(SESSION_PREF_KEY);
}
}
/**
* Loads the stored session (if any) at app cold-start. Errors are swallowed
* — a missing/corrupt session simply lands the user on the login screen.
*/
export async function rehydrateAuth(): Promise<MobileSession | null> {
try {
const storage = await getStorage();
return await storage.read();
} catch {
return null;
}
}
export async function persistSession(session: MobileSession): Promise<void> {
const storage = await getStorage();
await storage.write(session);
}
export async function clearSession(): Promise<void> {
const storage = await getStorage();
await storage.clear();
}
export interface MobileClientOptions {
/** API base URL, e.g. `https://api.toolctrl.de`. */
baseUrl: string;
/** Override the default storage adapter — primarily for tests. */
storage?: SessionStorage;
/** Custom fetch implementation (Capacitor-Http bridge in production). */
fetchImpl?: typeof fetch;
}
/**
* Builds a `ToolctrlClient` whose `Authorization` header is rehydrated
* from persistent mobile storage on every call. The client transparently handles
* the "no session yet" case by emitting an empty bearer that the API
* rejects with 401 — callers should branch on that to show the login UI.
*/
export async function createMobileApiClient(options: MobileClientOptions): Promise<ToolctrlClient> {
const storage = options.storage ?? (await getStorage());
return createToolctrlClient({
baseUrl: options.baseUrl,
auth: {
getToken: async () => {
const session = await storage.read();
return session?.token ?? "";
},
},
fetch: options.fetchImpl,
});
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { readLiveReloadUrl } from "../capacitor.config.js";
describe("readLiveReloadUrl", () => {
it("returns undefined when live reload is not configured", () => {
expect(readLiveReloadUrl(undefined)).toBeUndefined();
expect(readLiveReloadUrl(" ")).toBeUndefined();
});
it("preserves valid http and https URLs", () => {
expect(readLiveReloadUrl(" http://10.0.2.2:3000 ")).toBe("http://10.0.2.2:3000");
expect(readLiveReloadUrl("https://staging.example.test")).toBe("https://staging.example.test");
});
it("rejects unsupported protocols", () => {
expect(() => readLiveReloadUrl("ftp://example.test")).toThrow(/http/);
});
it("rejects relative URLs", () => {
expect(() => readLiveReloadUrl("localhost:3000")).toThrow(/absolute/);
});
});
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { bootstrapMobileShell, type MobileBootstrapDeps } from "./index.js";
import type { MobileSession } from "./auth.js";
import type { registerPushNotifications } from "./pushRegistration.js";
const baseOptions = {
apiBaseUrl: "https://api.example.test",
environment: "test",
release: "mobile-test",
sentryDsn: "https://sentry.example/1",
};
function createDeps(session: MobileSession | null): Required<MobileBootstrapDeps> {
return {
initSentry: vi.fn(async () => true),
rehydrate: vi.fn(async () => session),
registerPush: vi.fn<typeof registerPushNotifications>(async () => ({ status: "skipped", reason: "web-platform" })),
};
}
describe("bootstrapMobileShell", () => {
it("does not request push registration without a stored session", async () => {
const deps = createDeps(null);
const result = await bootstrapMobileShell(baseOptions, deps);
expect(result).toEqual({ sentryInitialized: true, hasStoredSession: false });
expect(deps.initSentry).toHaveBeenCalledWith({
dsn: baseOptions.sentryDsn,
environment: baseOptions.environment,
release: baseOptions.release,
});
expect(deps.rehydrate).toHaveBeenCalledOnce();
expect(deps.registerPush).not.toHaveBeenCalled();
});
it("fires push registration after a stored session is rehydrated", async () => {
const deps = createDeps({ token: "tok_1", userId: "user_1", tenantId: "tenant_1" });
const result = await bootstrapMobileShell(baseOptions, deps);
expect(result).toEqual({ sentryInitialized: true, hasStoredSession: true });
expect(deps.registerPush).toHaveBeenCalledWith({ apiBaseUrl: baseOptions.apiBaseUrl });
});
it("does not request push registration for a stored session without an API base URL", async () => {
const deps = createDeps({ token: "tok_1", userId: "user_1", tenantId: "tenant_1" });
const result = await bootstrapMobileShell({ ...baseOptions, apiBaseUrl: "" }, deps);
expect(result).toEqual({ sentryInitialized: true, hasStoredSession: true });
expect(deps.registerPush).not.toHaveBeenCalled();
});
it("swallows bootstrap dependency failures so the WebView can still start", async () => {
const deps: Required<MobileBootstrapDeps> = {
initSentry: vi.fn(async () => {
throw new Error("sentry unavailable");
}),
rehydrate: vi.fn(async () => {
throw new Error("keychain locked");
}),
registerPush: vi.fn<typeof registerPushNotifications>(async () => ({ status: "registered", platform: "ios", pushProvider: "apns" })),
};
const result = await bootstrapMobileShell(baseOptions, deps);
expect(result).toEqual({ sentryInitialized: false, hasStoredSession: false });
expect(deps.registerPush).not.toHaveBeenCalled();
});
it("does not await or surface push registration failures", async () => {
const deps = createDeps({ token: "tok_1", userId: "user_1", tenantId: null });
vi.mocked(deps.registerPush).mockRejectedValue(new Error("native prompt failed"));
await expect(bootstrapMobileShell(baseOptions, deps)).resolves.toEqual({ sentryInitialized: true, hasStoredSession: true });
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* Entry point for the Capacitor native wrapper.
*
* The actual UI is the React Router v7 web app at `apps/web/`. This
* file only handles bootstrap concerns that are specific to the native
* shell — Sentry init, auth-token rehydration from Capacitor Preferences,
* push-notification permission flow.
*
* It is intentionally side-effect free in test environments; the
* `bootstrapMobileShell()` export is invoked from the platform
* AppDelegate.swift / MainActivity.java bridge once the WebView is
* available.
*
* @see ./auth.ts
* @see ./sentry.ts
* @see ./pushRegistration.ts
*/
import { initMobileSentry } from "./sentry.js";
import { rehydrateAuth, type MobileSession } from "./auth.js";
import { registerPushNotifications } from "./pushRegistration.js";
export interface MobileBootstrapOptions {
/** Sentry DSN, falls back to the same DSN as the web client. */
sentryDsn?: string;
/** API base URL for the TS-SDK client. */
apiBaseUrl: string;
/** Build identifier used as Sentry release. */
release?: string;
/** Environment tag (`production` / `staging` / `development`). */
environment?: string;
}
export interface MobileBootstrapResult {
sentryInitialized: boolean;
hasStoredSession: boolean;
}
export interface MobileBootstrapDeps {
initSentry?: typeof initMobileSentry;
rehydrate?: typeof rehydrateAuth;
registerPush?: typeof registerPushNotifications;
}
/**
* Boots the native shell. Idempotent — safe to call from re-entrant
* lifecycle hooks. Errors in any sub-step are swallowed so the WebView
* always starts; failures are reported via Sentry when the SDK is up.
*/
export async function bootstrapMobileShell(options: MobileBootstrapOptions, deps: MobileBootstrapDeps = {}): Promise<MobileBootstrapResult> {
const initSentry = deps.initSentry ?? initMobileSentry;
const rehydrate = deps.rehydrate ?? rehydrateAuth;
const registerPush = deps.registerPush ?? registerPushNotifications;
const sentryInitialized = await swallowBootstrapStep(false, () =>
initSentry({
dsn: options.sentryDsn,
release: options.release,
environment: options.environment,
})
);
const session = await swallowBootstrapStep<MobileSession | null>(null, () => rehydrate());
const hasStoredSession = Boolean(session);
// Push registration is opt-in: only register when we already have an
// auth token, otherwise we'd ask for the permission prompt on the
// very first cold start which is poor UX. The web app's login flow
// is responsible for calling `registerPushNotifications()` after
// authentication completes.
if (hasStoredSession && options.apiBaseUrl) {
void registerPush({ apiBaseUrl: options.apiBaseUrl }).catch(() => undefined);
}
return { sentryInitialized, hasStoredSession };
}
async function swallowBootstrapStep<T>(fallback: T, task: () => Promise<T>): Promise<T> {
try {
return await task();
} catch {
return fallback;
}
}
@@ -0,0 +1,271 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { type MobileSession, type SessionStorage } from "./auth.js";
import { registerPushNotifications, type PushNotificationsBridge } from "./pushRegistration.js";
function createMemoryStorage(session: MobileSession | null): SessionStorage {
return {
async read() {
return session;
},
async write(nextSession) {
session = nextSession;
},
async clear() {
session = null;
},
};
}
function createFailingStorage(): SessionStorage {
return {
async read() {
throw new Error("keychain locked");
},
async write() {
/* noop */
},
async clear() {
/* noop */
},
};
}
function createPushPlugin(
options: {
currentPermission?: "prompt" | "prompt-with-rationale" | "granted" | "denied";
requestedPermission?: "prompt" | "prompt-with-rationale" | "granted" | "denied";
token?: string;
failRegistration?: boolean;
} = {}
): PushNotificationsBridge & { calls: string[] } {
const calls: string[] = [];
let registrationListener: ((token: { value: string }) => void) | null = null;
let errorListener: ((error: unknown) => void) | null = null;
return {
calls,
async checkPermissions() {
calls.push("checkPermissions");
return { receive: options.currentPermission ?? "granted" };
},
async requestPermissions() {
calls.push("requestPermissions");
return { receive: options.requestedPermission ?? "granted" };
},
async register() {
calls.push("register");
queueMicrotask(() => {
if (options.failRegistration) {
errorListener?.(new Error("native registration failed"));
return;
}
registrationListener?.({ value: options.token ?? "native-token-1" });
});
},
async addListener(eventName, listenerFunc) {
calls.push(`addListener:${eventName}`);
if (eventName === "registration") {
registrationListener = listenerFunc as (token: { value: string }) => void;
} else {
errorListener = listenerFunc as (error: unknown) => void;
}
return {
async remove() {
calls.push(`remove:${eventName}`);
},
};
},
};
}
describe("registerPushNotifications", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("keeps apiBaseUrl as a required caller contract", async () => {
await expect(registerPushNotifications({ apiBaseUrl: "" })).rejects.toThrow("apiBaseUrl is required");
});
it("skips defensively when running on web", async () => {
const pushPlugin = createPushPlugin();
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test",
platform: "web",
pushPlugin,
storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: "t-1" }),
});
expect(result).toEqual({ status: "skipped", reason: "web-platform" });
expect(pushPlugin.calls).toEqual([]);
});
it("skips when no mobile session is stored", async () => {
const pushPlugin = createPushPlugin();
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test",
platform: "ios",
pushPlugin,
storage: createMemoryStorage(null),
});
expect(result).toEqual({ status: "skipped", reason: "no-session" });
expect(pushPlugin.calls).toEqual([]);
});
it("skips when the stored session cannot be read", async () => {
const pushPlugin = createPushPlugin();
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test",
platform: "ios",
pushPlugin,
storage: createFailingStorage(),
});
expect(result).toEqual({ status: "skipped", reason: "no-session" });
expect(pushPlugin.calls).toEqual([]);
});
it("skips unsupported native platforms before touching the push bridge", async () => {
const pushPlugin = createPushPlugin();
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test",
platform: "electron" as "ios",
pushPlugin,
storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: null }),
});
expect(result).toEqual({ status: "skipped", reason: "unsupported-platform" });
expect(pushPlugin.calls).toEqual([]);
});
it("requests permission and skips when the user denies push access", async () => {
const pushPlugin = createPushPlugin({ currentPermission: "prompt", requestedPermission: "denied" });
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test",
platform: "android",
pushPlugin,
storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: null }),
});
expect(result).toEqual({ status: "skipped", reason: "permission-denied" });
expect(pushPlugin.calls).toEqual(["checkPermissions", "requestPermissions"]);
});
it("registers an iOS APNs token through the mobile API client", async () => {
const requests: Array<{ url: string; authorization: string | null; body: unknown }> = [];
const fakeFetch: typeof fetch = async (input, init) => {
requests.push({
url: String(input),
authorization: new Headers(init?.headers).get("authorization"),
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return new Response(JSON.stringify({ success: true, data: { id: "dt-1" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const pushPlugin = createPushPlugin({ currentPermission: "prompt", requestedPermission: "granted", token: " apns-token-1 " });
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test/",
appVersion: "1.2.3",
platform: "ios",
pushPlugin,
storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: "t-1" }),
fetchImpl: fakeFetch,
});
expect(result).toEqual({ status: "registered", platform: "ios", pushProvider: "apns" });
expect(pushPlugin.calls).toEqual([
"checkPermissions",
"requestPermissions",
"addListener:registration",
"addListener:registrationError",
"register",
"remove:registration",
"remove:registrationError",
]);
expect(requests).toEqual([
{
url: "https://api.example.test/v1/devices/register",
authorization: "Bearer bearer-1",
body: {
token: "apns-token-1",
userId: "u-1",
tenantId: "t-1",
platform: "ios",
provider: "apns",
pushProvider: "apns",
appVersion: "1.2.3",
},
},
]);
});
it("registers Android with the FCM provider", async () => {
const bodies: unknown[] = [];
const fakeFetch: typeof fetch = async (_input, init) => {
bodies.push(init?.body ? JSON.parse(String(init.body)) : null);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const pushPlugin = createPushPlugin({ token: "fcm-token-1" });
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test",
platform: "android",
pushPlugin,
storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: null }),
fetchImpl: fakeFetch,
});
expect(result).toEqual({ status: "registered", platform: "android", pushProvider: "fcm" });
expect(pushPlugin.calls).not.toContain("requestPermissions");
expect(bodies).toEqual([
{
token: "fcm-token-1",
userId: "u-1",
tenantId: null,
platform: "android",
provider: "fcm",
pushProvider: "fcm",
},
]);
});
it("skips when the native registration callback fails", async () => {
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test",
platform: "ios",
pushPlugin: createPushPlugin({ failRegistration: true }),
storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: "t-1" }),
timeoutMs: 10,
});
expect(result).toEqual({ status: "skipped", reason: "registration-failed" });
});
it("skips when native registration returns an empty token", async () => {
const pushPlugin = createPushPlugin({ token: " " });
const result = await registerPushNotifications({
apiBaseUrl: "https://api.example.test",
platform: "ios",
pushPlugin,
storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: "t-1" }),
timeoutMs: 10,
});
expect(result).toEqual({ status: "skipped", reason: "registration-failed" });
expect(pushPlugin.calls).toEqual([
"checkPermissions",
"addListener:registration",
"addListener:registrationError",
"register",
"remove:registration",
"remove:registrationError",
]);
});
});
@@ -0,0 +1,212 @@
import { createMobileApiClient, rehydrateAuth, type MobileSession, type SessionStorage } from "./auth.js";
type DevicePlatform = "ios" | "android";
type PushProvider = "apns" | "fcm";
type PermissionState = "prompt" | "prompt-with-rationale" | "granted" | "denied";
export interface PushRegistrationOptions {
apiBaseUrl: string;
/** Deprecated direct-token hook kept for old callers; persisted mobile auth is preferred. */
bearerToken?: string;
/** Optional app version forwarded to the device-token endpoint. */
appVersion?: string;
/** Override the default auth storage adapter, primarily for tests. */
storage?: SessionStorage;
/** Custom fetch implementation, primarily for tests or a Capacitor HTTP bridge. */
fetchImpl?: typeof fetch;
/** Native bridge override, primarily for tests. */
pushPlugin?: PushNotificationsBridge;
/** Platform override, primarily for tests. */
platform?: "ios" | "android" | "web";
/** Native registration timeout. Defaults to 15 seconds. */
timeoutMs?: number;
}
export type PushRegistrationResult =
| { status: "registered"; platform: DevicePlatform; pushProvider: PushProvider }
| {
status: "skipped";
reason:
| "capacitor-unavailable"
| "web-platform"
| "unsupported-platform"
| "push-plugin-unavailable"
| "no-session"
| "permission-denied"
| "registration-failed";
};
export interface PushNotificationsBridge {
checkPermissions(): Promise<{ receive: PermissionState }>;
requestPermissions(): Promise<{ receive: PermissionState }>;
register(): Promise<void>;
addListener(eventName: "registration", listenerFunc: (token: { value: string }) => void): Promise<PluginListenerHandle>;
addListener(eventName: "registrationError", listenerFunc: (error: unknown) => void): Promise<PluginListenerHandle>;
}
interface PluginListenerHandle {
remove(): Promise<void>;
}
interface CapacitorRuntime {
isNativePlatform?: () => boolean;
getPlatform?: () => string;
}
const DEFAULT_TIMEOUT_MS = 15_000;
const DEVICE_REGISTER_PATH = "/v1/devices/register";
export async function registerPushNotifications(options: PushRegistrationOptions): Promise<PushRegistrationResult> {
const apiBaseUrl = options.apiBaseUrl.trim();
if (!apiBaseUrl) {
throw new Error("apiBaseUrl is required to register push notifications.");
}
const session = await readMobileSession(options.storage);
if (!session?.token || !session.userId) {
return { status: "skipped", reason: "no-session" };
}
const platformResult = options.platform ?? (await readNativePlatform());
if (platformResult === null) {
return { status: "skipped", reason: "capacitor-unavailable" };
}
if (platformResult === "web") {
return { status: "skipped", reason: "web-platform" };
}
const platform = normalizeDevicePlatform(platformResult);
if (!platform) {
return { status: "skipped", reason: "unsupported-platform" };
}
const pushPlugin = options.pushPlugin ?? (await loadPushNotificationsPlugin());
if (!pushPlugin) {
return { status: "skipped", reason: "push-plugin-unavailable" };
}
const permission = await ensurePushPermission(pushPlugin);
if (permission !== "granted") {
return { status: "skipped", reason: "permission-denied" };
}
let token: string;
try {
token = await waitForNativePushToken(pushPlugin, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
} catch {
return { status: "skipped", reason: "registration-failed" };
}
const pushProvider = providerForPlatform(platform);
const client = await createMobileApiClient({
baseUrl: apiBaseUrl,
storage: options.storage,
fetchImpl: options.fetchImpl,
});
await client.request({
method: "POST",
path: DEVICE_REGISTER_PATH,
body: {
token,
userId: session.userId,
tenantId: session.tenantId,
platform,
provider: pushProvider,
pushProvider,
appVersion: options.appVersion,
},
});
return { status: "registered", platform, pushProvider };
}
async function readMobileSession(storage?: SessionStorage): Promise<MobileSession | null> {
try {
return storage ? await storage.read() : await rehydrateAuth();
} catch {
return null;
}
}
async function readNativePlatform(): Promise<string | null> {
try {
const mod: { Capacitor?: CapacitorRuntime } = await import(/* @vite-ignore */ "@capacitor/core");
const capacitor = mod.Capacitor;
if (!capacitor) return null;
if (typeof capacitor.isNativePlatform === "function" && !capacitor.isNativePlatform()) {
return "web";
}
return typeof capacitor.getPlatform === "function" ? capacitor.getPlatform() : null;
} catch {
return null;
}
}
async function loadPushNotificationsPlugin(): Promise<PushNotificationsBridge | null> {
try {
const mod: { PushNotifications?: PushNotificationsBridge } = await import(
/* @vite-ignore */ "@capacitor/push-notifications"
);
return mod.PushNotifications ?? null;
} catch {
return null;
}
}
function normalizeDevicePlatform(platform: string): DevicePlatform | null {
if (platform === "ios" || platform === "android") return platform;
return null;
}
function providerForPlatform(platform: DevicePlatform): PushProvider {
return platform === "ios" ? "apns" : "fcm";
}
async function ensurePushPermission(pushPlugin: PushNotificationsBridge): Promise<PermissionState> {
const current = await pushPlugin.checkPermissions();
if (current.receive === "granted") {
return current.receive;
}
const requested = await pushPlugin.requestPermissions();
return requested.receive;
}
async function waitForNativePushToken(pushPlugin: PushNotificationsBridge, timeoutMs: number): Promise<string> {
const handles: PluginListenerHandle[] = [];
let timer: ReturnType<typeof setTimeout> | undefined;
let resolveToken: (value: string) => void = () => undefined;
let rejectToken: (reason?: unknown) => void = () => undefined;
const tokenPromise = new Promise<string>((resolve, reject) => {
resolveToken = resolve;
rejectToken = reject;
timer = setTimeout(() => reject(new Error("Push registration timed out.")), timeoutMs);
});
const listenersReady = (async () => {
handles.push(
await pushPlugin.addListener("registration", (token) => {
const value = token.value.trim();
if (value) {
resolveToken(value);
} else {
rejectToken(new Error("Push registration returned an empty token."));
}
}),
);
handles.push(await pushPlugin.addListener("registrationError", rejectToken));
})();
try {
await listenersReady;
await pushPlugin.register();
return await tokenPromise;
} catch (error) {
rejectToken(error);
void tokenPromise.catch(() => undefined);
throw error;
} finally {
if (timer) clearTimeout(timer);
await Promise.all(handles.map((handle) => handle.remove().catch(() => undefined)));
}
}
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { initMobileSentry, __resetMobileSentryForTests } from "./sentry.js";
beforeEach(() => {
__resetMobileSentryForTests();
});
describe("initMobileSentry", () => {
it("returns false when no DSN is provided", async () => {
const result = await initMobileSentry({ dsn: undefined, release: undefined, environment: undefined });
expect(result).toBe(false);
});
it("returns false on the web platform even with a DSN", async () => {
const load = vi.fn();
const result = await initMobileSentry(
{ dsn: "https://abc@sentry.example/1", release: "v1", environment: "prod" },
{ load, getPlatform: async () => "web" }
);
expect(result).toBe(false);
expect(load).not.toHaveBeenCalled();
});
it("returns false when the SDK fails to load", async () => {
const result = await initMobileSentry(
{ dsn: "https://abc@sentry.example/1", release: "v1", environment: "prod" },
{ load: async () => null, getPlatform: async () => "ios" }
);
expect(result).toBe(false);
});
it("initializes the SDK with the documented config on native platforms", async () => {
const init = vi.fn();
const setTag = vi.fn();
const result = await initMobileSentry(
{ dsn: "https://abc@sentry.example/1", release: "1.0.0", environment: "staging", tracesSampleRate: 0.25 },
{ load: async () => ({ init, setTag }), getPlatform: async () => "android" }
);
expect(result).toBe(true);
expect(init).toHaveBeenCalledWith(
expect.objectContaining({
dsn: "https://abc@sentry.example/1",
release: "1.0.0",
environment: "staging",
sendDefaultPii: false,
tracesSampleRate: 0.25,
})
);
expect(setTag).toHaveBeenCalledWith("platform", "android");
});
it("falls back to the default 0.1 sample rate when none is provided", async () => {
const init = vi.fn();
await initMobileSentry(
{ dsn: "https://abc@sentry.example/1", release: undefined, environment: undefined },
{ load: async () => ({ init, setTag: vi.fn() }), getPlatform: async () => "ios" }
);
expect(init).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 0.1 }));
});
it("is idempotent — a second call short-circuits", async () => {
const init = vi.fn();
const deps = { load: async () => ({ init, setTag: vi.fn() }), getPlatform: async () => "ios" as const };
await initMobileSentry({ dsn: "https://abc@sentry.example/1", release: undefined, environment: undefined }, deps);
await initMobileSentry({ dsn: "https://abc@sentry.example/1", release: undefined, environment: undefined }, deps);
expect(init).toHaveBeenCalledTimes(1);
});
it("clamps a sample rate above 1.0 down to 1.0", async () => {
const init = vi.fn();
await initMobileSentry(
{ dsn: "https://abc@sentry.example/1", release: undefined, environment: undefined, tracesSampleRate: 5 },
{ load: async () => ({ init, setTag: vi.fn() }), getPlatform: async () => "ios" }
);
expect(init).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 1 }));
});
it("clamps negative and non-finite sample rates to safe values", async () => {
const negativeInit = vi.fn();
await initMobileSentry(
{ dsn: "https://abc@sentry.example/1", release: undefined, environment: undefined, tracesSampleRate: -1 },
{ load: async () => ({ init: negativeInit, setTag: vi.fn() }), getPlatform: async () => "ios" }
);
expect(negativeInit).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 0 }));
__resetMobileSentryForTests();
const nanInit = vi.fn();
await initMobileSentry(
{ dsn: "https://abc@sentry.example/1", release: undefined, environment: undefined, tracesSampleRate: Number.NaN },
{ load: async () => ({ init: nanInit, setTag: vi.fn() }), getPlatform: async () => "ios" }
);
expect(nanInit).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 0.1 }));
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Sentry-Capacitor bootstrap for the native shell (Phase 08 #65).
*
* Mirrors the web client at `apps/web/app/sentry.client.ts`:
* - Same DSN catalogue: `VITE_SENTRY_DSN_WEB` is reused so the mobile
* wrapper does not need a second Sentry project — events land in
* the same dashboard, distinguished by `Capacitor.getPlatform()`
* forwarded as a tag.
* - `sendDefaultPii: false` matches the web policy.
* - Trace + replay sample rates default to the same conservative
* 0.1 / 0 / 1.0 split (capacitor SDK does not ship replay; we keep
* trace + uncaughtException coverage).
*
* The integration is loaded dynamically so unit tests under Vitest's
* node runner never resolve the native module graph. The exported
* `initMobileSentry()` returns true when the SDK was actually
* initialized and false in the no-op path (missing DSN or web platform).
*
* @see apps/web/app/sentry.client.ts for the web sibling
* @see docs/improvement-plan/02-observability/sentry-wiring-web.md
*/
export interface MobileSentryConfig {
dsn: string | undefined;
release: string | undefined;
environment: string | undefined;
tracesSampleRate?: number;
}
export interface MobileSentryDeps {
/** Resolves the Sentry-Capacitor SDK; injectable for tests. */
load?: () => Promise<{
init: (config: Record<string, unknown>) => void;
setTag?: (key: string, value: string) => void;
} | null>;
/** Returns the runtime platform — `ios` / `android` / `web`. */
getPlatform?: () => Promise<"ios" | "android" | "web">;
}
let initialized = false;
/**
* Initializes the Sentry-Capacitor SDK. Idempotent. Returns true when the
* SDK was actually wired (i.e. DSN present + native runtime + load
* succeeded); false in the no-op fallback. Safe to call from cold start
* before the WebView is attached.
*/
export async function initMobileSentry(config: MobileSentryConfig, deps: MobileSentryDeps = {}): Promise<boolean> {
if (initialized) return true;
if (!config.dsn) return false;
const platform = await (deps.getPlatform?.() ?? defaultGetPlatform());
if (platform === "web") {
// The web bundle is already initialized via apps/web/app/sentry.client.ts;
// we never re-init from the native wrapper to avoid double-reporting.
return false;
}
const sdk = await (deps.load?.() ?? defaultLoad());
if (!sdk) return false;
sdk.init({
dsn: config.dsn,
environment: config.environment,
release: config.release,
sendDefaultPii: false,
tracesSampleRate: clampSampleRate(config.tracesSampleRate, 0.1),
});
sdk.setTag?.("platform", platform);
initialized = true;
return true;
}
/** Test-only: reset the init guard. */
export function __resetMobileSentryForTests(): void {
initialized = false;
}
function clampSampleRate(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
async function defaultLoad(): Promise<MobileSentryDeps["load"] extends () => Promise<infer R> ? R : never> {
try {
const mod = (await import(/* @vite-ignore */ "@sentry/capacitor")) as {
init?: (config: Record<string, unknown>) => void;
setTag?: (key: string, value: string) => void;
};
if (typeof mod.init !== "function") return null as never;
return { init: mod.init.bind(mod), setTag: mod.setTag?.bind(mod) } as never;
} catch {
return null as never;
}
}
async function defaultGetPlatform(): Promise<"ios" | "android" | "web"> {
try {
const core = (await import(/* @vite-ignore */ "@capacitor/core")) as {
Capacitor?: { getPlatform?: () => string };
};
const value = core.Capacitor?.getPlatform?.();
if (value === "ios" || value === "android") return value;
return "web";
} catch {
return "web";
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*", "capacitor.config.ts"],
"exclude": ["node_modules", "ios", "android", "dist"]
}