Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -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
|
||||
@@ -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>
|
||||
+10
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user