diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0fa84271..0ef0b2e4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: id: version attributes: label: T4 Code version - placeholder: "0.1.8" + placeholder: "0.1.9" validations: required: true - type: dropdown diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2947b354..18519151 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release desktop builds +name: Release app builds on: push: @@ -159,6 +159,115 @@ jobs: if-no-files-found: error retention-days: 7 + build-android: + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - name: Check out verified release source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + cache: pnpm + + - name: Install Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Install Android SDK + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + + - name: Install pinned Android platform and build tools + run: sdkmanager --install "platforms;android-36" "build-tools;36.0.0" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Restore and validate Android release signing key + shell: bash + env: + T4_ANDROID_KEYSTORE_BASE64: ${{ secrets.T4_ANDROID_KEYSTORE_BASE64 }} + T4_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.T4_ANDROID_KEYSTORE_PASSWORD }} + T4_ANDROID_KEY_ALIAS: ${{ secrets.T4_ANDROID_KEY_ALIAS }} + T4_ANDROID_KEY_PASSWORD: ${{ secrets.T4_ANDROID_KEY_PASSWORD }} + run: | + set -euo pipefail + for name in \ + T4_ANDROID_KEYSTORE_BASE64 \ + T4_ANDROID_KEYSTORE_PASSWORD \ + T4_ANDROID_KEY_ALIAS \ + T4_ANDROID_KEY_PASSWORD + do + if [[ -z "${!name:-}" ]]; then + echo "required GitHub Actions secret ${name} is not configured" >&2 + exit 1 + fi + done + keystore_path="$RUNNER_TEMP/t4-code-release.jks" + printf '%s' "$T4_ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore_path" + chmod 600 "$keystore_path" + keytool -list \ + -keystore "$keystore_path" \ + -storepass "$T4_ANDROID_KEYSTORE_PASSWORD" \ + -alias "$T4_ANDROID_KEY_ALIAS" >/dev/null + + - name: Build signed Android APK + env: + T4_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/t4-code-release.jks + T4_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.T4_ANDROID_KEYSTORE_PASSWORD }} + T4_ANDROID_KEY_ALIAS: ${{ secrets.T4_ANDROID_KEY_ALIAS }} + T4_ANDROID_KEY_PASSWORD: ${{ secrets.T4_ANDROID_KEY_PASSWORD }} + run: | + pnpm build:web + pnpm --filter @t4-code/mobile build:android:release + + - name: Inspect signed Android APK + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + apk="apps/mobile/android/app/build/outputs/apk/release/app-release.apk" + test -f "$apk" + build_tools="${ANDROID_SDK_ROOT:-$ANDROID_HOME}/build-tools/36.0.0" + export PATH="$build_tools:$PATH" + apksigner verify --verbose "$apk" + aapt dump badging "$apk" | grep -F "package: name='com.lycaonsolutions.t4code'" + aapt dump badging "$apk" | grep -F "versionName='${VERSION}'" + + - name: Stage Android artifact + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + mkdir -p artifacts + cp \ + apps/mobile/android/app/build/outputs/apk/release/app-release.apk \ + "artifacts/T4-Code-${VERSION}-android.apk" + (cd artifacts && sha256sum T4-Code-* > SHA256SUMS-android.txt) + + - name: Upload Android artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: android-release + path: artifacts/* + if-no-files-found: error + retention-days: 7 + build-macos: needs: verify runs-on: macos-15 @@ -208,7 +317,7 @@ jobs: retention-days: 7 publish: - needs: [verify, build-linux, build-macos] + needs: [verify, build-android, build-linux, build-macos] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -239,7 +348,7 @@ jobs: - name: Assemble checksums shell: bash - run: cat artifacts/SHA256SUMS-linux.txt artifacts/SHA256SUMS-macos.txt | sort -k2 > artifacts/SHA256SUMS.txt + run: cat artifacts/SHA256SUMS-android.txt artifacts/SHA256SUMS-linux.txt artifacts/SHA256SUMS-macos.txt | sort -k2 > artifacts/SHA256SUMS.txt - name: Publish GitHub release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 @@ -248,21 +357,23 @@ jobs: generate_release_notes: true fail_on_unmatched_files: true body: | - ## Tailnet web app + ## Android app + + This release includes a signed Android APK for direct installation. It is a thin client: the T4 Code interface is bundled in the APK, while OMP sessions and runtime state remain on the host. On first run, enter the host's HTTPS Tailscale address and complete the normal T4 pairing flow. - The source-hosted Tailnet client is now installable from its titlebar on Android and iPhone. It opens in a standalone window with the T4 Code icon; after installation, the same titlebar action reloads the live client. This remains an online thin client and does not cache host configuration, transcripts, or session state for offline use. + Android device credentials are encrypted with an app-specific Android Keystore key. The app does not contain an OMP appserver and does not require a public route; the phone and host should use the same tailnet. - ## Session controls + ## Browser and iOS - Queue and Steer no longer attach a volatile active-turn revision that OMP can reject as stale. Normal prompts keep their revision guard, and prompt leases remain supported when negotiated. Fast mode copy now accurately describes a request for provider priority processing without implying a change in reasoning effort. + The installable Tailnet web app remains available. iOS TestFlight is coming soon; there is no iOS binary in this release. - ## Development guardrails + ## Connection security - The arbitrary physical-line ceiling has been removed. File boundaries are judged by cohesion and reviewability rather than a hard line count. + The Tailnet gateway accepts only the configured HTTPS web origin and the exact native app origins. It rejects wildcard, opaque, and untrusted origins. Keep the route on Tailscale Serve and do not enable Funnel. ## Install notes - Linux packages are built for x86_64. macOS packages are built for Apple Silicon. + The Android APK is signed and supports Android 7.0 or later. Linux packages are built for x86_64. macOS packages are built for Apple Silicon. ## Runtime compatibility @@ -276,6 +387,7 @@ jobs: Verify downloads with `SHA256SUMS.txt`. files: | + artifacts/T4-Code-*.apk artifacts/T4-Code-*.deb artifacts/T4-Code-*.AppImage artifacts/T4-Code-*.dmg diff --git a/README.md b/README.md index e0087f62..f5dcdbf4 100644 --- a/README.md +++ b/README.md @@ -4,38 +4,47 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ![T4 Code main window](docs/assets/t4-code-main.png) -[**Download v0.1.8**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.8) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.9**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.9) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements -T4 Code needs an OMP build with desktop appserver support. For v0.1.8, use the public integration build below. +T4 Code needs an OMP build with desktop appserver support. For v0.1.9, use the public integration build below. -T4 Code v0.1.8 was verified with OMP 16.5.0 built from [`d4a0b934`](https://github.com/lyc-aon/oh-my-pi/commit/d4a0b9344e1796c0e56041cfeea3431a8a728e61), tagged [`t4code-16.5.0-appserver-3`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.0-appserver-3). That public integration is based on the official upstream [`v16.5.0`](https://github.com/can1357/oh-my-pi/tree/v16.5.0) tag at [`3047c27c`](https://github.com/can1357/oh-my-pi/commit/3047c27c332c5629c8e063283d349384c10c9a56). It adds bounded large-session replay, complete desktop runtime events, catalog-backed session management, ordered remote delivery, cross-client control-state convergence, terminal streaming-state settlement, and restart-safe session teardown. The official upstream v16.5.0 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.2 from integration commit [`5d4315ee`](https://github.com/lyc-aon/oh-my-pi/commit/5d4315eea317260fec030e2b4726f10fed0cd5f6), source tree `713688e8099d4553a0a30b1bf415a7cffb5963f4`. +T4 Code v0.1.9 was verified with OMP 16.5.0 built from [`d4a0b934`](https://github.com/lyc-aon/oh-my-pi/commit/d4a0b9344e1796c0e56041cfeea3431a8a728e61), tagged [`t4code-16.5.0-appserver-3`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.0-appserver-3). That public integration is based on the official upstream [`v16.5.0`](https://github.com/can1357/oh-my-pi/tree/v16.5.0) tag at [`3047c27c`](https://github.com/can1357/oh-my-pi/commit/3047c27c332c5629c8e063283d349384c10c9a56). It adds bounded large-session replay, complete desktop runtime events, catalog-backed session management, ordered remote delivery, cross-client control-state convergence, terminal streaming-state settlement, and restart-safe session teardown. The official upstream v16.5.0 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.2 from integration commit [`5d4315ee`](https://github.com/lyc-aon/oh-my-pi/commit/5d4315eea317260fec030e2b4726f10fed0cd5f6), source tree `713688e8099d4553a0a30b1bf415a7cffb5963f4`. | Platform | Arch | Package | | --- | --- | --- | +| Android | arm64, armv7, x86_64 | `.apk` (**signed**) | | Linux | x86_64 | `.deb`, AppImage | | macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**unsigned, see below**) | -No Windows build and no Intel Mac build in v0.1.8. +No Windows build and no Intel Mac build in v0.1.9. The iOS TestFlight build is coming soon. -## What changed in v0.1.8 +## What changed in v0.1.9 -- The Tailnet browser client can now be installed to an Android or iPhone home screen. The titlebar download button uses the browser's native install prompt when available and otherwise shows the Add to Home Screen path. -- Installed web apps open in a standalone window with the T4 Code icon. The same titlebar slot becomes a Reload T4 Code button, so a phone user does not need browser chrome to refresh the client. -- Queue and Steer now work during an active turn without sending a stale session revision. Normal prompts keep their revision guard, and negotiated prompt leases remain supported. -- Fast mode now explains what the host actually requests: provider priority processing when supported. It does not imply a lower thinking effort. -- The installed client remains online-only. It fetches the current gateway UI on launch and does not cache host configuration, transcripts, or session state for offline use. -- The arbitrary physical-line ceiling is gone. File boundaries are now judged by cohesion and reviewability instead of a hard line count. -- The manifest, phone icons, gateway content type, install states, active-turn commands, and 320 × 568 titlebar layout are covered by unit and built-app browser tests. The verified OMP 16.5.0 and app-wire 0.5.2 runtime contract is unchanged from v0.1.7. +- Android now has a signed, installable APK. It is a thin client: the UI ships inside the app, while sessions and runtime state stay on your T4 Code host. +- First run accepts an HTTPS Tailscale hostname, checks the secure WebSocket route, and then uses the normal T4 pairing flow. +- Android device credentials are encrypted with an app-specific Android Keystore key. Only the non-secret Tailnet hostname is kept in web storage. +- The Tailnet gateway accepts the exact Capacitor Android and iOS origins while continuing to reject wildcard, opaque, and untrusted origins. +- The connection screen and titlebar controls are sized for small phone displays. A user can clear the saved host and pair again without reinstalling. +- The browser-installable PWA remains available, and the iOS download slot now reports that TestFlight is coming soon without advertising a build that is not published. ## Install +### Android + +1. On the Android phone, sign in to Tailscale with an account that can reach the T4 Code host. +2. Download [`T4-Code-0.1.9-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-android.apk). +3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK. +4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port. + +The APK does not contain an appserver or expose one to the public internet. It connects to the separately running Tailnet gateway on your OMP host. + ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.8/T4-Code-0.1.8-linux-amd64.deb -sudo apt install ./T4-Code-0.1.8-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-linux-amd64.deb +sudo apt install ./T4-Code-0.1.9-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -43,17 +52,17 @@ Use `apt install` rather than `dpkg -i` so system dependencies resolve automatic ### Linux (AppImage) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.8/T4-Code-0.1.8-linux-x86_64.AppImage -chmod +x T4-Code-0.1.8-linux-x86_64.AppImage -./T4-Code-0.1.8-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-linux-x86_64.AppImage +chmod +x T4-Code-0.1.9-linux-x86_64.AppImage +./T4-Code-0.1.9-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) > [!WARNING] -> **The macOS v0.1.8 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. +> **The macOS v0.1.9 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. -1. Download [`T4-Code-0.1.8-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.8/T4-Code-0.1.8-mac-arm64.dmg) (or [`T4-Code-0.1.8-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.8/T4-Code-0.1.8-mac-arm64.zip)). +1. Download [`T4-Code-0.1.9-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-mac-arm64.dmg) (or [`T4-Code-0.1.9-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. If Gatekeeper blocks the app and you choose to proceed, remove the quarantine attributes from the copied app bundle: diff --git a/SECURITY.md b/SECURITY.md index 6c1077d2..791cad49 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,4 @@ We read every report and will reply to tell you what happens next. This is a sma - T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. -- The macOS v0.1.8 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. +- The macOS v0.1.9 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7f17f31a..4e1cb75b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index 12e09be5..372afea6 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -324,7 +324,7 @@ export class DesktopTargetManager { cursorStore: this.cursorStoreFactory(targetId), capabilities: requestedCapabilities, requestedFeatures: ADDITIVE_FEATURES, - client: { name: "T4 Code", version: "0.1.8", build: "desktop", platform: process.platform }, + client: { name: "T4 Code", version: "0.1.9", build: "desktop", platform: process.platform }, reconnect: { attemptCap: 12, baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 00000000..c42a05f7 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,23 @@ +# T4 Code Mobile + +Android thin-client shell for the bundled T4 Code web application. The APK contains the UI assets and connects over secure WebSocket to a separately running T4 Tailnet gateway; it never embeds or starts an OMP appserver. + +## Build + +```bash +pnpm build:web +pnpm --filter @t4-code/mobile build:android:debug +``` + +The installable debug APK is written to `android/app/build/outputs/apk/debug/app-debug.apk`. + +A release build can be signed without committing credentials by setting all four variables before `pnpm --filter @t4-code/mobile build:android:release`: + +```text +T4_ANDROID_KEYSTORE_PATH +T4_ANDROID_KEYSTORE_PASSWORD +T4_ANDROID_KEY_ALIAS +T4_ANDROID_KEY_PASSWORD +``` + +Without those variables Gradle still creates an unsigned release APK for CI inspection. The mobile shell deliberately has no Capacitor `server.url`; `cap sync` copies `apps/web/dist` into the native application. diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore new file mode 100644 index 00000000..48354a3d --- /dev/null +++ b/apps/mobile/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/apps/mobile/android/app/.gitignore b/apps/mobile/android/app/.gitignore new file mode 100644 index 00000000..043df802 --- /dev/null +++ b/apps/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle new file mode 100644 index 00000000..62f1c0f9 --- /dev/null +++ b/apps/mobile/android/app/build.gradle @@ -0,0 +1,88 @@ +import groovy.json.JsonSlurper + +apply plugin: 'com.android.application' + +def mobilePackage = new JsonSlurper().parse(rootProject.file('../package.json')) +def mobileVersionName = System.getenv('T4_ANDROID_VERSION_NAME') ?: mobilePackage.version +def versionParts = mobileVersionName.tokenize('.').collect { part -> + def match = part =~ /^(\d+)/ + match.find() ? match.group(1).toInteger() : 0 +} +while (versionParts.size() < 3) versionParts.add(0) +def defaultVersionCode = versionParts[0] * 1000000 + versionParts[1] * 10000 + versionParts[2] +def mobileVersionCode = (System.getenv('T4_ANDROID_VERSION_CODE') ?: defaultVersionCode.toString()).toInteger() + +def releaseKeystorePath = System.getenv('T4_ANDROID_KEYSTORE_PATH') +def releaseKeystorePassword = System.getenv('T4_ANDROID_KEYSTORE_PASSWORD') +def releaseKeyAlias = System.getenv('T4_ANDROID_KEY_ALIAS') +def releaseKeyPassword = System.getenv('T4_ANDROID_KEY_PASSWORD') +def hasReleaseSigning = [ + releaseKeystorePath, + releaseKeystorePassword, + releaseKeyAlias, + releaseKeyPassword, +].every { value -> value != null && !value.isBlank() } + +android { + namespace = "com.lycaonsolutions.t4code" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.lycaonsolutions.t4code" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode mobileVersionCode + versionName mobileVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + signingConfigs { + if (hasReleaseSigning) { + release { + storeFile file(releaseKeystorePath) + storePassword releaseKeystorePassword + keyAlias releaseKeyAlias + keyPassword releaseKeyPassword + } + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (hasReleaseSigning) signingConfig signingConfigs.release + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +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 won't work") +} diff --git a/apps/mobile/android/app/capacitor.build.gradle b/apps/mobile/android/app/capacitor.build.gradle new file mode 100644 index 00000000..bbfb44fa --- /dev/null +++ b/apps/mobile/android/app/capacitor.build.gradle @@ -0,0 +1,19 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/apps/mobile/android/app/proguard-rules.pro b/apps/mobile/android/app/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/apps/mobile/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/apps/mobile/android/app/src/androidTest/java/com/lycaonsolutions/t4code/AppIdentityTest.java b/apps/mobile/android/app/src/androidTest/java/com/lycaonsolutions/t4code/AppIdentityTest.java new file mode 100644 index 00000000..ef13627a --- /dev/null +++ b/apps/mobile/android/app/src/androidTest/java/com/lycaonsolutions/t4code/AppIdentityTest.java @@ -0,0 +1,20 @@ +package com.lycaonsolutions.t4code; + +import static org.junit.Assert.assertEquals; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class AppIdentityTest { + @Test + public void applicationIdMatchesReleaseIdentity() { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.lycaonsolutions.t4code", context.getPackageName()); + } +} diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c96bbd17 --- /dev/null +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java new file mode 100644 index 00000000..d89da696 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/MainActivity.java @@ -0,0 +1,13 @@ +package com.lycaonsolutions.t4code; + +import android.os.Bundle; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(T4SecureStoragePlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4SecureStoragePlugin.java b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4SecureStoragePlugin.java new file mode 100644 index 00000000..8b0d9111 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/lycaonsolutions/t4code/T4SecureStoragePlugin.java @@ -0,0 +1,168 @@ +package com.lycaonsolutions.t4code; + +import android.content.Context; +import android.content.SharedPreferences; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +@CapacitorPlugin(name = "T4SecureStorage") +public final class T4SecureStoragePlugin extends Plugin { + private static final String KEYSTORE_PROVIDER = "AndroidKeyStore"; + private static final String KEY_ALIAS = "t4_code_device_credentials_v1"; + private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; + private static final String PREFERENCES_NAME = "t4_code_secure_storage"; + private static final String PREFERENCE_IV = "credentials_iv"; + private static final String PREFERENCE_PAYLOAD = "credentials_payload"; + private static final int GCM_TAG_BITS = 128; + private static final int MAX_DEVICE_ID_LENGTH = 256; + private static final int MAX_DEVICE_TOKEN_LENGTH = 512; + + @PluginMethod + public void getCredentials(PluginCall call) { + synchronized (this) { + try { + JSObject result = new JSObject(); + JSObject credentials = readCredentials(); + result.put("credentials", credentials == null ? JSONObject.NULL : credentials); + call.resolve(result); + } catch (Exception error) { + clearStoredStateBestEffort(); + call.reject("Stored credentials could not be decrypted."); + } + } + } + + @PluginMethod + public void setCredentials(PluginCall call) { + String deviceId = call.getString("deviceId"); + String deviceToken = call.getString("deviceToken"); + if (!isBoundedText(deviceId, MAX_DEVICE_ID_LENGTH) || !isBoundedText(deviceToken, MAX_DEVICE_TOKEN_LENGTH)) { + call.reject("Invalid device credentials."); + return; + } + + synchronized (this) { + try { + JSObject credentials = new JSObject(); + credentials.put("deviceId", deviceId); + credentials.put("deviceToken", deviceToken); + storeCredentials(credentials.toString()); + call.resolve(); + } catch (Exception error) { + call.reject("Device credentials could not be stored."); + } + } + } + + @PluginMethod + public void clearCredentials(PluginCall call) { + synchronized (this) { + try { + clearStoredState(); + call.resolve(); + } catch (Exception error) { + call.reject("Device credentials could not be cleared."); + } + } + } + + private boolean isBoundedText(String value, int maxLength) { + if (value == null || value.isEmpty() || value.length() > maxLength) return false; + for (int index = 0; index < value.length(); index += 1) { + char character = value.charAt(index); + if (character <= 0x1f || character == 0x7f) return false; + } + return true; + } + + private SharedPreferences preferences() { + return getContext().getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + } + + private SecretKey getOrCreateKey() throws Exception { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); + keyStore.load(null); + if (keyStore.containsAlias(KEY_ALIAS)) { + return (SecretKey) keyStore.getKey(KEY_ALIAS, null); + } + + KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER); + KeyGenParameterSpec specification = new KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(); + generator.init(specification); + return generator.generateKey(); + } + + private void storeCredentials(String plaintext) throws Exception { + Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + boolean committed = preferences().edit() + .putString(PREFERENCE_IV, Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP)) + .putString(PREFERENCE_PAYLOAD, Base64.encodeToString(ciphertext, Base64.NO_WRAP)) + .commit(); + if (!committed) throw new IllegalStateException("secure preferences commit failed"); + } + + private JSObject readCredentials() throws Exception { + String encodedIv = preferences().getString(PREFERENCE_IV, null); + String encodedPayload = preferences().getString(PREFERENCE_PAYLOAD, null); + if (encodedIv == null && encodedPayload == null) return null; + if (encodedIv == null || encodedPayload == null) throw new IllegalStateException("incomplete secure credential state"); + + byte[] iv = Base64.decode(encodedIv, Base64.NO_WRAP); + byte[] ciphertext = Base64.decode(encodedPayload, Base64.NO_WRAP); + Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + String plaintext = new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + JSObject credentials = JSObject.fromJSONObject(new JSONObject(plaintext)); + + String deviceId = credentials.getString("deviceId"); + String deviceToken = credentials.getString("deviceToken"); + if (!isBoundedText(deviceId, MAX_DEVICE_ID_LENGTH) || !isBoundedText(deviceToken, MAX_DEVICE_TOKEN_LENGTH)) { + throw new IllegalStateException("invalid secure credential payload"); + } + return credentials; + } + + private void clearStoredState() throws Exception { + if (!preferences().edit().clear().commit()) { + throw new IllegalStateException("secure preferences clear failed"); + } + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); + keyStore.load(null); + if (keyStore.containsAlias(KEY_ALIAS)) keyStore.deleteEntry(KEY_ALIAS); + } + + private void clearStoredStateBestEffort() { + try { + clearStoredState(); + } catch (Exception ignored) { + // The caller still receives a generic failure; never expose credential details. + } + } +} diff --git a/apps/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 00000000..59844232 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 00000000..5fe4dd61 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 00000000..5c013652 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 00000000..8343f586 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 00000000..9c1e83e8 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 00000000..9fe5ea4a Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 00000000..aecf44b2 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 00000000..593eb334 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 00000000..e0a06420 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/apps/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 00000000..468d279b Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/apps/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..c7bd21db --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/apps/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..d5fccc53 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/splash.png b/apps/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 00000000..5fe4dd61 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable/splash.png differ diff --git a/apps/mobile/android/app/src/main/res/layout/activity_main.xml b/apps/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..b5ad1387 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..04e56297 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ba8eafc3 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..04e56297 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..2e8a85cd Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..04344b6f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..2e8a85cd Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..8e1579f7 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..8f46c7ff Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..8e1579f7 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..4e4e05be Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..8262c741 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..0d1adac8 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..7854e90f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..54aca261 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..7854e90f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/android/app/src/main/res/values/colors.xml b/apps/mobile/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..08788749 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #E83174 + #0D0D0D + #E83174 + diff --git a/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..06781cec --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #0D0D0D + diff --git a/apps/mobile/android/app/src/main/res/values/strings.xml b/apps/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..23cb0f2b --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + T4 Code + T4 Code + com.lycaonsolutions.t4code + com.lycaonsolutions.t4code + diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..be874e54 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/xml/file_paths.xml b/apps/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..bd0c4d80 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle new file mode 100644 index 00000000..f8f0e43b --- /dev/null +++ b/apps/mobile/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/apps/mobile/android/capacitor.settings.gradle b/apps/mobile/android/capacitor.settings.gradle new file mode 100644 index 00000000..e0f161b8 --- /dev/null +++ b/apps/mobile/android/capacitor.settings.gradle @@ -0,0 +1,3 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@8.4.1_@capacitor+core@8.4.1/node_modules/@capacitor/android/capacitor') diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties new file mode 100644 index 00000000..2e87c52f --- /dev/null +++ b/apps/mobile/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e62db30c --- /dev/null +++ b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +distributionSha256Sum=ed1a8d686605fd7c23bdf62c7fc7add1c5b23b2bbc3721e661934ef4a4911d7c +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/mobile/android/gradlew b/apps/mobile/android/gradlew new file mode 100755 index 00000000..23d15a93 --- /dev/null +++ b/apps/mobile/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/apps/mobile/android/gradlew.bat b/apps/mobile/android/gradlew.bat new file mode 100644 index 00000000..5eed7ee8 --- /dev/null +++ b/apps/mobile/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/apps/mobile/android/settings.gradle b/apps/mobile/android/settings.gradle new file mode 100644 index 00000000..3b4431d7 --- /dev/null +++ b/apps/mobile/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/apps/mobile/android/variables.gradle b/apps/mobile/android/variables.gradle new file mode 100644 index 00000000..ee4ba41c --- /dev/null +++ b/apps/mobile/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/apps/mobile/assets/android-icon-foreground.svg b/apps/mobile/assets/android-icon-foreground.svg new file mode 100644 index 00000000..4f297d5f --- /dev/null +++ b/apps/mobile/assets/android-icon-foreground.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/mobile/capacitor.config.json b/apps/mobile/capacitor.config.json new file mode 100644 index 00000000..0e97a78d --- /dev/null +++ b/apps/mobile/capacitor.config.json @@ -0,0 +1,17 @@ +{ + "appId": "com.lycaonsolutions.t4code", + "appName": "T4 Code", + "webDir": "dist", + "loggingBehavior": "debug", + "appendUserAgent": " T4CodeMobile/0.1.9", + "android": { + "path": "android", + "minWebViewVersion": 60, + "allowMixedContent": false, + "webContentsDebuggingEnabled": false + }, + "server": { + "hostname": "localhost", + "androidScheme": "https" + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 00000000..a63c0076 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,20 @@ +{ + "name": "@t4-code/mobile", + "version": "0.1.9", + "private": true, + "type": "module", + "scripts": { + "prepare:web": "node ./scripts/prepare-web.mjs", + "sync:android": "pnpm prepare:web && cap sync android", + "build:android:debug": "pnpm sync:android && node ./scripts/run-gradle.mjs assembleDebug", + "build:android:release": "pnpm sync:android && node ./scripts/run-gradle.mjs assembleRelease", + "test": "node --test ./scripts/*.test.mjs" + }, + "dependencies": { + "@capacitor/core": "8.4.1" + }, + "devDependencies": { + "@capacitor/android": "8.4.1", + "@capacitor/cli": "8.4.1" + } +} diff --git a/apps/mobile/scripts/prepare-web.mjs b/apps/mobile/scripts/prepare-web.mjs new file mode 100644 index 00000000..b00081cd --- /dev/null +++ b/apps/mobile/scripts/prepare-web.mjs @@ -0,0 +1,56 @@ +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const mobileRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const webDist = resolve(mobileRoot, "../web/dist"); +const mobileDist = resolve(mobileRoot, "dist"); +const sourceIndex = resolve(webDist, "index.html"); +const MOBILE_CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data: blob:", + "connect-src 'self' wss://*.ts.net:*", + "object-src 'none'", + "base-uri 'none'", + "frame-src 'none'", + "form-action 'none'", +].join("; "); + +let indexHtml; +try { + indexHtml = await readFile(sourceIndex, "utf8"); +} catch { + throw new Error("apps/web/dist is missing; run `pnpm build:web` before syncing the mobile shell"); +} + +if (!indexHtml.includes('
')) { + throw new Error("apps/web/dist/index.html is not a T4 Code web build"); +} +if (indexHtml.includes('http-equiv="Content-Security-Policy"')) { + throw new Error("apps/web/dist/index.html already contains a content security policy"); +} + +const mobileIndexHtml = indexHtml.replace( + " ", + ` \n `, +); +if (mobileIndexHtml === indexHtml) { + throw new Error("apps/web/dist/index.html is missing its closing head element"); +} + +await rm(mobileDist, { force: true, recursive: true }); +await mkdir(mobileDist, { recursive: true }); +await cp(webDist, mobileDist, { recursive: true }); +await writeFile(resolve(mobileDist, "index.html"), mobileIndexHtml, "utf8"); + +const buildMetadata = { + appId: "com.lycaonsolutions.t4code", + bundledWebApp: true, + nativeOrigin: "https://localhost", +}; +await writeFile(resolve(mobileDist, "mobile-build.json"), `${JSON.stringify(buildMetadata, null, 2)}\n`, "utf8"); + +console.log(`Prepared bundled T4 web assets in ${mobileDist}`); diff --git a/apps/mobile/scripts/prepare-web.test.mjs b/apps/mobile/scripts/prepare-web.test.mjs new file mode 100644 index 00000000..a6c96845 --- /dev/null +++ b/apps/mobile/scripts/prepare-web.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; + +const mobileRoot = resolve(import.meta.dirname, ".."); + +test("Capacitor uses bundled assets and a local secure origin", async () => { + const config = JSON.parse(await readFile(resolve(mobileRoot, "capacitor.config.json"), "utf8")); + + assert.equal(config.webDir, "dist"); + assert.equal(config.server.hostname, "localhost"); + assert.equal(config.server.androidScheme, "https"); + assert.equal(config.server.url, undefined); + assert.equal(config.server.allowNavigation, undefined); + assert.equal(config.server.cleartext, undefined); +}); + +test("mobile package pins one Capacitor release across core, CLI, and Android", async () => { + const packageJson = JSON.parse(await readFile(resolve(mobileRoot, "package.json"), "utf8")); + const core = packageJson.dependencies["@capacitor/core"]; + + assert.equal(core, "8.4.1"); + assert.equal(packageJson.devDependencies["@capacitor/cli"], core); + assert.equal(packageJson.devDependencies["@capacitor/android"], core); +}); + +test("Android credentials are encrypted by a registered Keystore plugin", async () => { + const sourceRoot = resolve( + mobileRoot, + "android/app/src/main/java/com/lycaonsolutions/t4code", + ); + const activity = await readFile(resolve(sourceRoot, "MainActivity.java"), "utf8"); + const plugin = await readFile(resolve(sourceRoot, "T4SecureStoragePlugin.java"), "utf8"); + + assert.match(activity, /registerPlugin\(T4SecureStoragePlugin\.class\)/); + assert.match(plugin, /@CapacitorPlugin\(name = "T4SecureStorage"\)/); + assert.match(plugin, /AES\/GCM\/NoPadding/); + assert.match(plugin, /AndroidKeyStore/); + assert.match(plugin, /setCredentials\(PluginCall call\)/); + assert.match(plugin, /getCredentials\(PluginCall call\)/); + assert.match(plugin, /clearCredentials\(PluginCall call\)/); + assert.doesNotMatch(plugin, /putString\([^,]+,\s*deviceToken\)/); +}); + +test("the bundled document restricts connections without constraining the hosted web build", async () => { + const prepareScript = await readFile(resolve(mobileRoot, "scripts/prepare-web.mjs"), "utf8"); + const hostedIndex = await readFile(resolve(mobileRoot, "../web/index.html"), "utf8"); + + assert.match(prepareScript, /connect-src 'self' wss:\/\/\*\.ts\.net:\*/); + assert.match(prepareScript, /http-equiv="Content-Security-Policy"/); + assert.doesNotMatch(prepareScript, /connect-src \*/); + assert.doesNotMatch(hostedIndex, /http-equiv="Content-Security-Policy"/); +}); + +test("the Android build verifies its Gradle distribution", async () => { + const wrapper = await readFile( + resolve(mobileRoot, "android/gradle/wrapper/gradle-wrapper.properties"), + "utf8", + ); + + assert.match(wrapper, /^distributionSha256Sum=[a-f0-9]{64}$/mu); + assert.match(wrapper, /^validateDistributionUrl=true$/mu); +}); diff --git a/apps/mobile/scripts/run-gradle.mjs b/apps/mobile/scripts/run-gradle.mjs new file mode 100644 index 00000000..72d806b1 --- /dev/null +++ b/apps/mobile/scripts/run-gradle.mjs @@ -0,0 +1,39 @@ +import { spawn } from "node:child_process"; +import { access } from "node:fs/promises"; +import { delimiter, resolve } from "node:path"; + +const mobileRoot = resolve(import.meta.dirname, ".."); +const gradlew = resolve(mobileRoot, "android/gradlew"); +const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT ?? resolve(process.env.HOME ?? "", "Android/sdk"); +const javaHome = process.env.JAVA_HOME ?? "/usr/lib/jvm/java-21-openjdk-amd64"; + +await access(gradlew); +await access(resolve(javaHome, "bin/java")); + +const tasks = process.argv.slice(2); +if (tasks.length === 0) throw new Error("pass at least one Gradle task"); + +const child = spawn(gradlew, ["--no-daemon", ...tasks], { + cwd: resolve(mobileRoot, "android"), + env: { + ...process.env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + JAVA_HOME: javaHome, + PATH: `${resolve(javaHome, "bin")}${delimiter}${resolve(sdkRoot, "platform-tools")}${delimiter}${process.env.PATH ?? ""}`, + }, + stdio: "inherit", +}); + +child.on("error", (error) => { + throw error; +}); + +const exitCode = await new Promise((resolveExit) => { + child.on("exit", (code, signal) => { + if (signal !== null) throw new Error(`Gradle terminated by ${signal}`); + resolveExit(code ?? 1); + }); +}); + +process.exitCode = exitCode; diff --git a/apps/site/package.json b/apps/site/package.json index 8a99846f..40fa8028 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/docs/content.ts b/apps/site/src/docs/content.ts index f12c088e..4b26f46f 100644 --- a/apps/site/src/docs/content.ts +++ b/apps/site/src/docs/content.ts @@ -11,7 +11,8 @@ import { OMP_RUNTIME_VERSION, OMP_UPSTREAM_COMMIT, OMP_UPSTREAM_URL, - RELEASE_ASSETS, + assetsFor, + primaryAsset, RELEASE_TAG, RELEASE_VERSION, RELEASES_URL, @@ -40,20 +41,41 @@ export interface DocGroup { readonly topics: readonly DocTopic[]; } -const linuxDeb = RELEASE_ASSETS[0]!; -const linuxAppImage = RELEASE_ASSETS[1]!; -const macDmg = RELEASE_ASSETS[2]!; -const macZip = RELEASE_ASSETS[3]!; +function assetOfKind(platform: "linux" | "mac", kind: "appimage" | "zip") { + const found = assetsFor(platform).find((asset) => asset.kind === kind); + if (!found) throw new Error(`no ${kind} asset for ${platform}`); + return found; +} + +const androidApk = primaryAsset("android"); +const linuxDeb = primaryAsset("linux"); +const linuxAppImage = assetOfKind("linux", "appimage"); +const macDmg = primaryAsset("mac"); +const macZip = assetOfKind("mac", "zip"); const install: DocTopic = { id: "install", title: "Install", - lede: "T4 Code ships for Linux (x86_64) and macOS (Apple Silicon). Download from the GitHub release, install, and you are ready for first run.", + lede: "T4 Code ships for Android, Linux (x86_64), and macOS (Apple Silicon). iOS TestFlight is coming soon.", blocks: [ { kind: "p", text: `All downloads live on the [v${RELEASE_VERSION} release page](${RELEASES_URL}).`, }, + { kind: "h2", id: "install-android", text: "Android" }, + { + kind: "p", + text: `Download the [Android APK](${androidApk.url}) on your phone and open it. Android may ask you to allow app installs from your browser before it opens the package.`, + }, + { + kind: "p", + text: "The Android app is a thin client. It connects to the T4 Code host that runs your OMP sessions; it does not run OMP or an app server on your phone.", + }, + { kind: "h2", id: "install-ios", text: "iPhone and iPad" }, + { + kind: "p", + text: "iOS TestFlight is coming soon. This page will carry the TestFlight link when the build is ready.", + }, { kind: "h2", id: "install-linux", text: "Linux (x86_64)" }, { kind: "p", text: "The `.deb` package is the smoothest path on Debian and Ubuntu:" }, { @@ -98,7 +120,7 @@ const install: DocTopic = { { kind: "h2", id: "install-requirements", text: "Requirements" }, { kind: "p", - text: `T4 Code is a desktop front end for [Oh My Pi](${OMP_URL}). You need an \`omp\` build with desktop appserver support installed on the machine that runs your sessions, either this one or a remote host you pair with.`, + text: `T4 Code is a client for [Oh My Pi](${OMP_URL}). You need an \`omp\` build with desktop appserver support installed on the machine that runs your sessions, either this one or a remote host you pair with.`, }, { kind: "p", @@ -114,9 +136,13 @@ const install: DocTopic = { const firstRun: DocTopic = { id: "first-run", title: "First run", - lede: "On first launch, T4 Code finds your local Oh My Pi install and manages its app server for you.", + lede: "Desktop builds can manage a local Oh My Pi app server. Android connects to the T4 gateway on your computer.", blocks: [ - { kind: "h2", id: "first-run-discovery", text: "How T4 finds omp" }, + { + kind: "note", + text: "The discovery and service steps below apply to Linux and macOS. On Android, connect Tailscale to the same tailnet as your computer, then enter the gateway's full HTTPS address in T4 Code.", + }, + { kind: "h2", id: "first-run-discovery", text: "How desktop T4 finds omp" }, { kind: "p", text: "T4 Code checks these places, in order, and uses the first match:" }, { kind: "ol", @@ -134,7 +160,7 @@ const firstRun: DocTopic = { kind: "p", text: "Before trusting a match, T4 runs `omp appserver status --json` and checks the answer. A build that cannot answer is skipped.", }, - { kind: "h2", id: "first-run-service", text: "Who keeps the app server running" }, + { kind: "h2", id: "first-run-service", text: "Who keeps the desktop app server running" }, { kind: "p", text: "T4 Code registers the app server with your operating system so it stays up across restarts:", diff --git a/apps/site/src/landing/Landing.tsx b/apps/site/src/landing/Landing.tsx index fd695226..b2670bf2 100644 --- a/apps/site/src/landing/Landing.tsx +++ b/apps/site/src/landing/Landing.tsx @@ -9,7 +9,7 @@ import { RELEASE_VERSION, RELEASES_URL, REPO_URL, - type Platform, + type DesktopPlatform, } from "../release.ts"; interface Feature { @@ -45,13 +45,17 @@ const FEATURES: readonly Feature[] = [ }, ]; -function DownloadButtons({ platform }: { platform: Platform }) { +function DownloadButtons({ platform }: { platform: DesktopPlatform }) { + const android = primaryAsset("android"); const main = primaryAsset(platform); - const other: Platform = platform === "linux" ? "mac" : "linux"; + const other: DesktopPlatform = platform === "linux" ? "mac" : "linux"; const otherMain = primaryAsset(other); return (
- + + Download Android APK + + Download for {platform === "linux" ? "Linux" : "macOS"} @@ -67,7 +71,7 @@ function DownloadButtons({ platform }: { platform: Platform }) { export function Landing() { // Render Linux first (matches the build host + crawler default), then // correct for macOS visitors after mount. Both buttons are always visible. - const [platform, setPlatform] = useState("linux"); + const [platform, setPlatform] = useState("linux"); useEffect(() => { setPlatform(detectPlatform(navigator.userAgent)); }, []); @@ -101,15 +105,15 @@ export function Landing() {

- v{RELEASE_VERSION} · Linux x86_64 · macOS Apple Silicon ·{" "} + v{RELEASE_VERSION} · Android APK · Linux x86_64 · macOS Apple Silicon · iOS TestFlight + coming soon ·{" "} all downloads @@ -129,8 +133,9 @@ export function Landing() {

What it does

- A desktop front end for the OMP runtime. There is no cloud service and no - account. T4 Code talks straight to your host over its typed protocol. + A desktop control surface and Android thin client for the OMP runtime. There is no + cloud service and no account. T4 Code talks straight to your host over its typed + protocol.

{FEATURES.map((feature) => ( @@ -173,6 +178,25 @@ export function Landing() { `T4 Code needs an [Oh My Pi](${OMP_URL}) build with desktop appserver support on the machine that runs your sessions. The [docs](/docs/) cover first run, pairing, and troubleshooting.`, )}

+
+
+

Available now

+

Android APK

+

+ Install T4 Code directly on your phone, then connect to the OMP host you already + run. +

+ + Download Android APK + +
+
+

iPhone & iPad

+

TestFlight coming soon

+

The TestFlight link will appear here when the iOS build is ready.

+
+
+

Desktop builds

Linux

@@ -193,17 +217,16 @@ export function Landing() { xattr -dr com.apple.quarantine "/Applications/T4 Code.app"

- Drag the app to Applications first. The command clears Gatekeeper's - quarantine flag; right-click → Open works too. + Drag the app to Applications first. The command clears Gatekeeper's quarantine + flag; right-click → Open works too.

- Heads up: the v{RELEASE_VERSION} macOS build is unsigned and - not notarized, so macOS warns before first launch. If you would rather not - run an unsigned binary,{" "} - {renderInline(`[build it from source](/docs/#build-from-source)`)}. The - whole app is public. + Heads up: the v{RELEASE_VERSION} macOS build is unsigned and not + notarized, so macOS warns before first launch. If you would rather not run an unsigned + binary, {renderInline(`[build it from source](/docs/#build-from-source)`)}. The whole + app is public.
@@ -211,7 +234,7 @@ export function Landing() {

Get T4 Code

-

Free, MIT-licensed, and built in the open.

+

Android and desktop builds are ready now. iOS TestFlight is coming soon.

    {RELEASE_ASSETS.map((asset) => ( diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 4caff227..07b62c79 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -13,12 +13,13 @@ export const OMP_UPSTREAM_TAG = "v16.5.0"; export const OMP_UPSTREAM_COMMIT = "3047c27c332c5629c8e063283d349384c10c9a56"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; export const APP_WIRE_VERSION = "0.5.2"; -export const RELEASE_TAG = "v0.1.8"; -export const RELEASE_VERSION = "0.1.8"; +export const RELEASE_TAG = "v0.1.9"; +export const RELEASE_VERSION = "0.1.9"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; -export type Platform = "linux" | "mac"; -export type AssetKind = "deb" | "appimage" | "dmg" | "zip"; +export type Platform = "android" | "linux" | "mac"; +export type DesktopPlatform = Exclude; +export type AssetKind = "apk" | "deb" | "appimage" | "dmg" | "zip"; export interface ReleaseAsset { readonly platform: Platform; @@ -47,30 +48,30 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("linux", "deb", "x86_64", "T4-Code-0.1.8-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.8-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.8-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.8-mac-arm64.zip", "macOS .zip"), + asset("android", "apk", "universal", "T4-Code-0.1.9-android.apk", "Android APK"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.9-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.9-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.9-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.9-mac-arm64.zip", "macOS .zip"), ]; export function assetsFor(platform: Platform): readonly ReleaseAsset[] { return RELEASE_ASSETS.filter((a) => a.platform === platform); } -/** Preferred single download per platform: .deb on Linux, .dmg on macOS. */ +/** Preferred single download per platform: APK on Android, .deb on Linux, .dmg on macOS. */ export function primaryAsset(platform: Platform): ReleaseAsset { - const kind: AssetKind = platform === "linux" ? "deb" : "dmg"; + const kind: AssetKind = platform === "android" ? "apk" : platform === "linux" ? "deb" : "dmg"; const found = RELEASE_ASSETS.find((a) => a.platform === platform && a.kind === kind); if (!found) throw new Error(`no ${kind} asset for ${platform}`); return found; } /** - * Detect the visitor's platform from a user-agent string. Only Linux and - * Apple Silicon macOS builds exist; anything else falls back to Linux so the - * button always points at a real file. + * Detect the visitor's desktop platform from a user-agent string. Anything + * else falls back to Linux; the Android download is offered separately. */ -export function detectPlatform(userAgent: string): Platform { +export function detectPlatform(userAgent: string): DesktopPlatform { if (/mac os x|macintosh/i.test(userAgent)) return "mac"; return "linux"; } diff --git a/apps/site/src/site.css b/apps/site/src/site.css index 910a8694..e979e409 100644 --- a/apps/site/src/site.css +++ b/apps/site/src/site.css @@ -404,6 +404,63 @@ a.btn:hover { /* Install section */ +.mobile-install-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.65fr); + gap: 1.5rem; + margin-bottom: 2.25rem; +} + +.mobile-install-card { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: var(--card); + padding: 1.4rem; +} + +.android-install-card { + border-color: color-mix(in srgb, var(--primary) 40%, var(--border)); + background: color-mix(in srgb, var(--primary) 7%, var(--card)); +} + +.mobile-install-card .platform-label { + margin: 0 0 0.35rem; + color: var(--accent-text); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ios-install-card .platform-label { + color: var(--muted-foreground); +} + +.mobile-install-card h3 { + margin: 0 0 0.5rem; + font-size: 1.2rem; + letter-spacing: -0.01em; +} + +.mobile-install-card p:not(.platform-label) { + margin: 0 0 1.25rem; + color: var(--muted-foreground); + max-width: 48ch; +} + +.mobile-install-card .btn { + margin-top: auto; +} + +.desktop-install-title { + margin: 0 0 0.75rem; + font-size: 1rem; +} + .install-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -418,6 +475,7 @@ a.btn:hover { } @media (max-width: 900px) { + .mobile-install-grid, .install-grid { grid-template-columns: minmax(0, 1fr); } diff --git a/apps/site/test/docs-nav.test.ts b/apps/site/test/docs-nav.test.ts index 14d4ebea..0fc398cf 100644 --- a/apps/site/test/docs-nav.test.ts +++ b/apps/site/test/docs-nav.test.ts @@ -58,6 +58,8 @@ describe("resolveTopicForHash", () => { }); it("resolves a heading id to its owning topic", () => { + expect(resolveTopicForHash("#install-android")?.id).toBe("install"); + expect(resolveTopicForHash("#install-ios")?.id).toBe("install"); expect(resolveTopicForHash("#install-gatekeeper")?.id).toBe("install"); expect(resolveTopicForHash("#shortcuts-composer")?.id).toBe("keyboard-shortcuts"); }); diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index 275c1a18..5e1fd4d2 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -1,4 +1,4 @@ -// Release contract guard: exact v0.1.8 asset names and URLs, and the +// Release contract guard: exact v0.1.9 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { @@ -19,12 +19,13 @@ import { } from "../src/release.ts"; describe("release assets", () => { - it("carries the four contracted v0.1.8 filenames", () => { + it("carries the five contracted v0.1.9 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.8-linux-amd64.deb", - "T4-Code-0.1.8-linux-x86_64.AppImage", - "T4-Code-0.1.8-mac-arm64.dmg", - "T4-Code-0.1.8-mac-arm64.zip", + "T4-Code-0.1.9-android.apk", + "T4-Code-0.1.9-linux-amd64.deb", + "T4-Code-0.1.9-linux-x86_64.AppImage", + "T4-Code-0.1.9-mac-arm64.dmg", + "T4-Code-0.1.9-mac-arm64.zip", ]); }); @@ -36,18 +37,21 @@ describe("release assets", () => { it("targets the public LycaonLLC repo", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); - expect(RELEASE_TAG).toBe("v0.1.8"); - expect(RELEASE_VERSION).toBe("0.1.8"); + expect(RELEASE_TAG).toBe("v0.1.9"); + expect(RELEASE_VERSION).toBe("0.1.9"); }); it("splits assets by platform with correct architectures", () => { expect(assetsFor("linux").every((a) => a.arch === "x86_64")).toBe(true); expect(assetsFor("mac").every((a) => a.arch === "arm64")).toBe(true); + expect(assetsFor("android").every((a) => a.arch === "universal")).toBe(true); + expect(assetsFor("android")).toHaveLength(1); expect(assetsFor("linux")).toHaveLength(2); expect(assetsFor("mac")).toHaveLength(2); }); - it("picks .deb for Linux and .dmg for macOS as the primary download", () => { + it("picks the APK, .deb, and .dmg as primary downloads", () => { + expect(primaryAsset("android").kind).toBe("apk"); expect(primaryAsset("linux").kind).toBe("deb"); expect(primaryAsset("mac").kind).toBe("dmg"); }); diff --git a/apps/web/package.json b/apps/web/package.json index ca62fdad..6aa84e7c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/application.tsx b/apps/web/src/application.tsx new file mode 100644 index 00000000..c7492fd3 --- /dev/null +++ b/apps/web/src/application.tsx @@ -0,0 +1,18 @@ +import "./state/store-instance.ts"; + +import { TooltipProvider } from "@t4-code/ui"; +import { RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import type { Root } from "react-dom/client"; + +import { router } from "./router.tsx"; + +export function renderApplication(root: Root): void { + root.render( + + + + + , + ); +} diff --git a/apps/web/src/components/MobileConnectionAction.tsx b/apps/web/src/components/MobileConnectionAction.tsx new file mode 100644 index 00000000..8036f57e --- /dev/null +++ b/apps/web/src/components/MobileConnectionAction.tsx @@ -0,0 +1,82 @@ +import { + Button, + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, + IconButton, + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@t4-code/ui"; +import { Cable } from "lucide-react"; +import { useState } from "react"; + +import { + clearNativeMobileConnection, + nativeMobilePlatform, + readStoredMobileBackend, +} from "../platform/native-mobile.ts"; + +export function MobileConnectionAction() { + const [open, setOpen] = useState(false); + const [clearing, setClearing] = useState(false); + if (nativeMobilePlatform() === null) return null; + + let origin = "Saved Tailnet host"; + try { + origin = readStoredMobileBackend()?.origin ?? origin; + } catch { + // The setup screen will repair invalid storage on the next launch. + } + + return ( + + + setOpen(true)} + size="icon-sm" + > + + + } + /> + Mobile connection + + + + Mobile connection + + This phone connects directly to the T4 gateway below. Changing it also removes the paired device credential from this phone. + + +

    {origin}

    + + }> + Keep this host + + + +
    +
    + ); +} diff --git a/apps/web/src/components/MobileConnectionScreen.tsx b/apps/web/src/components/MobileConnectionScreen.tsx new file mode 100644 index 00000000..22168159 --- /dev/null +++ b/apps/web/src/components/MobileConnectionScreen.tsx @@ -0,0 +1,108 @@ +import { BrandLockup, Button, Spinner } from "@t4-code/ui"; +import { Cable, LockKeyhole, Network } from "lucide-react"; +import { useState } from "react"; + +import { + parseTailnetBackend, + probeMobileBackend, + writeStoredMobileBackend, +} from "../platform/native-mobile.ts"; + +export function MobileConnectionScreen({ startupMessage }: { readonly startupMessage?: string }) { + const [address, setAddress] = useState(""); + const [message, setMessage] = useState(startupMessage ?? null); + const [checking, setChecking] = useState(false); + + return ( +
    +
    + +
    +
    +
    +
    +

    Connect to your T4 host

    +

    + T4 Code runs the interface on this phone. OMP and your projects stay on your computer. +

    + +
    { + event.preventDefault(); + if (checking) return; + setMessage(null); + let backend; + try { + backend = parseTailnetBackend(address); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Enter a valid Tailnet address."); + return; + } + setChecking(true); + void probeMobileBackend(backend) + .then(() => { + writeStoredMobileBackend(backend); + window.location.reload(); + }) + .catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : "T4 Code could not reach that host."); + setChecking(false); + }); + }} + > + + setAddress(event.target.value)} + placeholder="https://your-computer.your-tailnet.ts.net:8445" + spellCheck={false} + type="url" + value={address} + /> +

    + Use the full HTTPS address shown by the T4 gateway on your computer. +

    +

    + {message} +

    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + ); +} diff --git a/apps/web/src/components/Titlebar.tsx b/apps/web/src/components/Titlebar.tsx index 1c88f1df..88ea9abe 100644 --- a/apps/web/src/components/Titlebar.tsx +++ b/apps/web/src/components/Titlebar.tsx @@ -8,6 +8,7 @@ import { Command, Moon, PanelLeft, Settings, Sun } from "lucide-react"; import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts"; import { resolveTheme } from "../theme/theme.ts"; import { HostedAppAction } from "./HostedAppAction.tsx"; +import { MobileConnectionAction } from "./MobileConnectionAction.tsx"; import type { RailTogglePresentation } from "./rail-toggle.ts"; function ThemeToggle() { @@ -100,6 +101,7 @@ export function Titlebar({ )} + - - - - , -); +const root = createRoot(rootElement); + +void prepareNativeMobileBackend().then(async (boot) => { + if (boot.kind === "setup") { + root.render( + + + , + ); + return; + } + const { renderApplication } = await import("./application.tsx"); + renderApplication(root); +}); diff --git a/apps/web/src/platform/browser-shell-port.ts b/apps/web/src/platform/browser-shell-port.ts index 0d394748..41b20e2c 100644 --- a/apps/web/src/platform/browser-shell-port.ts +++ b/apps/web/src/platform/browser-shell-port.ts @@ -54,6 +54,11 @@ import type { import type { DesktopShellPort } from "@t4-code/client"; import { BrowserWebSocketTransport } from "./browser-transport.ts"; +import { + currentNativeMobileBackend, + nativeMobilePlatform, + persistNativeMobileCredentials, +} from "./native-mobile.ts"; const TARGET_ID = "remote"; @@ -123,6 +128,8 @@ function hasExplicitQueryConfig(params: URLSearchParams): boolean { } export function detectBackend(): BrowserBackendConfig | null { + const nativeConfig = currentNativeMobileBackend(); + if (nativeConfig !== null) return parseBackendPayload(nativeConfig); if (typeof document !== "undefined") { const script = document.getElementById("t4-backend"); if (script !== null) { @@ -166,6 +173,7 @@ export function createBrowserShellPort(options: BrowserShellPortOptions = {}): D if (config === null) return null; const backendConfig = config; + const mobilePlatform = nativeMobilePlatform(); const platform: "linux" | "darwin" = (() => { if (typeof navigator !== "undefined" && /mac/i.test(navigator.platform)) return "darwin"; return "linux"; @@ -215,14 +223,15 @@ export function createBrowserShellPort(options: BrowserShellPortOptions = {}): D capabilities: DEVICE_CAPABILITIES, requestedFeatures: ADDITIVE_FEATURES, authentication: () => authentication, - privilegedPairResult: (result) => { + privilegedPairResult: async (result) => { + await persistNativeMobileCredentials(result); authentication = { deviceId: result.deviceId, deviceToken: result.deviceToken }; }, client: { name: "T4 Code", - version: "0.1.8", - build: "browser", - platform: platform === "darwin" ? "darwin" : "linux", + version: "0.1.9", + build: mobilePlatform ?? "browser", + platform: mobilePlatform ?? (platform === "darwin" ? "darwin" : "linux"), }, reconnect: { attemptCap: 12, baseMs: 250, maxMs: 10_000 }, }); @@ -379,8 +388,8 @@ export function createBrowserShellPort(options: BrowserShellPortOptions = {}): D const result = await client.pairStart({ code: request.code, deviceId: browserDeviceId, - deviceName: "T4 Browser", - platform, + deviceName: mobilePlatform === null ? "T4 Browser" : `T4 ${mobilePlatform === "android" ? "Android" : "iOS"}`, + platform: mobilePlatform ?? platform, requestedCapabilities: DEVICE_CAPABILITIES, }); return { targetId: request.targetId, paired: result.type === "pair.ok" }; diff --git a/apps/web/src/platform/native-mobile.ts b/apps/web/src/platform/native-mobile.ts new file mode 100644 index 00000000..63fbc698 --- /dev/null +++ b/apps/web/src/platform/native-mobile.ts @@ -0,0 +1,230 @@ +import { deviceToken as validateDeviceToken } from "@t4-code/protocol"; + +export const MOBILE_BACKEND_STORAGE_KEY = "t4-code:mobile-backend:v1"; + +const MAX_URL_LENGTH = 2048; +const MAX_LABEL_LENGTH = 128; + +export type NativeMobilePlatform = "android" | "ios"; + +export interface StoredMobileBackend { + readonly version: 1; + readonly origin: string; + readonly wsUrl: string; + readonly label: string; +} + +export interface NativeMobileBackendConfig { + readonly wsUrl: string; + readonly label: string; + readonly deviceId?: string; + readonly deviceToken?: string; +} + +interface T4SecureStoragePlugin { + getCredentials(): Promise<{ + readonly credentials: { readonly deviceId: string; readonly deviceToken: string } | null; + }>; + setCredentials(options: { readonly deviceId: string; readonly deviceToken: string }): Promise; + clearCredentials(): Promise; +} + +interface CapacitorBridge { + readonly Plugins?: { readonly T4SecureStorage?: T4SecureStoragePlugin }; + readonly getPlatform?: () => string; + readonly isNativePlatform?: () => boolean; +} + +declare global { + interface Window { + Capacitor?: CapacitorBridge; + __t4MobileBackend?: NativeMobileBackendConfig; + } +} + +export type MobileBootResult = + | { readonly kind: "web" } + | { readonly kind: "ready"; readonly backend: StoredMobileBackend } + | { readonly kind: "setup"; readonly message?: string }; + +function secureStorage(): T4SecureStoragePlugin | null { + return window.Capacitor?.Plugins?.T4SecureStorage ?? null; +} + +export function nativeMobilePlatform(): NativeMobilePlatform | null { + if (typeof window === "undefined") return null; + const bridge = window.Capacitor; + if (bridge?.isNativePlatform?.() !== true) return null; + const platform = bridge.getPlatform?.(); + return platform === "android" || platform === "ios" ? platform : null; +} + +function requiredLabel(value: unknown): string { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_LABEL_LENGTH) { + throw new Error("The saved host label is invalid."); + } + return value; +} + +export function parseTailnetBackend(value: string): StoredMobileBackend { + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error("Enter the HTTPS address shown by T4 Code on your computer."); + if (trimmed.length > MAX_URL_LENGTH) throw new Error("That address is too long."); + + const candidate = trimmed.includes("://") ? trimmed : `https://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + throw new Error("Enter a valid HTTPS Tailnet address."); + } + if (parsed.protocol !== "https:") throw new Error("Use the HTTPS Tailnet address, not HTTP."); + if (parsed.username !== "" || parsed.password !== "") throw new Error("The address cannot contain credentials."); + if (parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "") { + throw new Error("Enter the host address only, without a path, query, or fragment."); + } + const hostname = parsed.hostname.toLowerCase(); + if (hostname === "ts.net" || !hostname.endsWith(".ts.net")) { + throw new Error("Use the full Tailscale hostname ending in .ts.net."); + } + + const origin = parsed.origin; + const websocket = new URL(origin); + websocket.protocol = "wss:"; + websocket.pathname = "/v1/ws"; + const deviceName = hostname.slice(0, hostname.indexOf(".")); + return { + version: 1, + origin, + wsUrl: websocket.toString(), + label: requiredLabel(`T4 on ${deviceName}`), + }; +} + +export function readStoredMobileBackend(storage: Pick = window.localStorage): StoredMobileBackend | null { + const raw = storage.getItem(MOBILE_BACKEND_STORAGE_KEY); + if (raw === null) return null; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("The saved host address is damaged. Enter it again."); + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("The saved host address is damaged. Enter it again."); + } + const data = value as Record; + if (data.version !== 1 || typeof data.origin !== "string") { + throw new Error("The saved host address is from an unsupported app version."); + } + const parsed = parseTailnetBackend(data.origin); + if (data.wsUrl !== parsed.wsUrl || data.label !== parsed.label) { + throw new Error("The saved host address is inconsistent. Enter it again."); + } + return parsed; +} + +export function writeStoredMobileBackend( + backend: StoredMobileBackend, + storage: Pick = window.localStorage, +): void { + storage.setItem(MOBILE_BACKEND_STORAGE_KEY, JSON.stringify(backend)); +} + +export function currentNativeMobileBackend(): NativeMobileBackendConfig | null { + if (typeof window === "undefined") return null; + return window.__t4MobileBackend ?? null; +} + +export async function prepareNativeMobileBackend(): Promise { + const platform = nativeMobilePlatform(); + if (platform === null) return { kind: "web" }; + document.documentElement.dataset.platform = platform; + + let backend: StoredMobileBackend | null; + try { + backend = readStoredMobileBackend(); + } catch (error) { + return { kind: "setup", message: error instanceof Error ? error.message : "Enter the host address again." }; + } + if (backend === null) return { kind: "setup" }; + + const plugin = secureStorage(); + if (plugin === null) { + return { kind: "setup", message: "The Android security bridge did not start. Close T4 Code and open it again." }; + } + + let credentials: { readonly deviceId: string; readonly deviceToken: string } | null = null; + try { + const result = await plugin.getCredentials(); + if (result.credentials !== null) { + const deviceId = result.credentials.deviceId; + if (deviceId.length === 0 || deviceId.length > 256) throw new Error("invalid device id"); + credentials = { deviceId, deviceToken: validateDeviceToken(result.credentials.deviceToken, "deviceToken") }; + } + } catch { + await plugin.clearCredentials().catch(() => undefined); + } + + window.__t4MobileBackend = { + wsUrl: backend.wsUrl, + label: backend.label, + ...(credentials === null ? {} : credentials), + }; + return { kind: "ready", backend }; +} + +export async function persistNativeMobileCredentials(credentials: { + readonly deviceId: string; + readonly deviceToken: string; +}): Promise { + if (nativeMobilePlatform() === null) return; + const plugin = secureStorage(); + if (plugin === null) throw new Error("Android secure storage is unavailable"); + await plugin.setCredentials({ + deviceId: credentials.deviceId, + deviceToken: validateDeviceToken(credentials.deviceToken, "deviceToken"), + }); +} + +export async function clearNativeMobileConnection(): Promise { + const plugin = secureStorage(); + if (plugin !== null) await plugin.clearCredentials(); + window.localStorage.removeItem(MOBILE_BACKEND_STORAGE_KEY); + delete window.__t4MobileBackend; +} + +export async function probeMobileBackend( + backend: StoredMobileBackend, + options: { readonly timeoutMs?: number; readonly WebSocketImpl?: typeof WebSocket } = {}, +): Promise { + const WebSocketImpl = options.WebSocketImpl ?? window.WebSocket; + const timeoutMs = options.timeoutMs ?? 8_000; + await new Promise((resolve, reject) => { + let settled = false; + const socket = new WebSocketImpl(backend.wsUrl); + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.removeEventListener("open", onOpen); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + if (error === undefined) resolve(); + else reject(error); + }; + const onOpen = () => { + finish(); + socket.close(1000, "T4 mobile connection check"); + }; + const onError = () => finish(new Error("T4 Code could not reach that host. Check Tailscale and the address.")); + const onClose = () => finish(new Error("The host closed the connection before T4 Code could start.")); + const timer = setTimeout(() => { + socket.close(); + finish(new Error("The host did not answer. Check that Tailscale and the T4 gateway are running.")); + }, timeoutMs); + socket.addEventListener("open", onOpen); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + }); +} diff --git a/apps/web/test/browser-platform.test.ts b/apps/web/test/browser-platform.test.ts index cae21b1e..e09fcf06 100644 --- a/apps/web/test/browser-platform.test.ts +++ b/apps/web/test/browser-platform.test.ts @@ -51,6 +51,28 @@ describe("browser platform boundary", () => { expect(() => detectBackend()).toThrow(/invalid browser backend wsUrl/u); }); + it("prefers a prepared native backend without putting credentials in the URL", () => { + Object.defineProperty(globalThis, "document", { configurable: true, value: undefined }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + __t4MobileBackend: { + wsUrl: "wss://host.tailnet.ts.net:8445/v1/ws", + label: "T4 on host", + deviceId: "android-device", + deviceToken: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + location: { search: "" }, + }, + }); + expect(detectBackend()).toEqual({ + wsUrl: "wss://host.tailnet.ts.net:8445/v1/ws", + label: "T4 on host", + deviceId: "android-device", + deviceToken: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }); + }); + it("exposes one remote target and no local service lifecycle", async () => { setBackendScript(JSON.stringify({ wsUrl: "wss://omp.example/v1/ws", label: "Remote OMP" })); const shell = createBrowserShellPort(); diff --git a/apps/web/test/native-mobile.test.tsx b/apps/web/test/native-mobile.test.tsx new file mode 100644 index 00000000..a8e033ba --- /dev/null +++ b/apps/web/test/native-mobile.test.tsx @@ -0,0 +1,123 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { MobileConnectionScreen } from "../src/components/MobileConnectionScreen.tsx"; +import { + clearNativeMobileConnection, + MOBILE_BACKEND_STORAGE_KEY, + parseTailnetBackend, + prepareNativeMobileBackend, + probeMobileBackend, + readStoredMobileBackend, + writeStoredMobileBackend, +} from "../src/platform/native-mobile.ts"; + +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; + +class MemoryStorage { + readonly values = new Map(); + getItem(key: string): string | null { return this.values.get(key) ?? null; } + setItem(key: string, value: string): void { this.values.set(key, value); } + removeItem(key: string): void { this.values.delete(key); } +} + +afterEach(() => { + Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument }); + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); +}); + +describe("native mobile connection", () => { + it("normalizes a full Tailnet HTTPS origin and rejects unsafe addresses", () => { + expect(parseTailnetBackend("lycaon-bunker.tail9f9e1a.ts.net:8445")).toEqual({ + version: 1, + origin: "https://lycaon-bunker.tail9f9e1a.ts.net:8445", + wsUrl: "wss://lycaon-bunker.tail9f9e1a.ts.net:8445/v1/ws", + label: "T4 on lycaon-bunker", + }); + expect(() => parseTailnetBackend("http://host.tailnet.ts.net")).toThrow(/HTTPS/u); + expect(() => parseTailnetBackend("https://example.com")).toThrow(/\.ts\.net/u); + expect(() => parseTailnetBackend("https://host.tailnet.ts.net/admin")).toThrow(/host address only/u); + expect(() => parseTailnetBackend("https://user:pass@host.tailnet.ts.net")).toThrow(/credentials/u); + }); + + it("round-trips only validated nonsecret host configuration", () => { + const storage = new MemoryStorage(); + const backend = parseTailnetBackend("https://host.tailnet.ts.net:8445"); + writeStoredMobileBackend(backend, storage); + expect(readStoredMobileBackend(storage)).toEqual(backend); + + storage.setItem(MOBILE_BACKEND_STORAGE_KEY, JSON.stringify({ ...backend, wsUrl: "wss://evil.example/v1/ws" })); + expect(() => readStoredMobileBackend(storage)).toThrow(/inconsistent/u); + }); + + it("loads paired credentials from the native security bridge before app boot", async () => { + const storage = new MemoryStorage(); + const backend = parseTailnetBackend("https://host.tailnet.ts.net:8445"); + writeStoredMobileBackend(backend, storage); + const clearCredentials = () => Promise.resolve(); + Object.defineProperty(globalThis, "document", { configurable: true, value: { documentElement: { dataset: {} } } }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + localStorage: storage, + Capacitor: { + isNativePlatform: () => true, + getPlatform: () => "android", + Plugins: { + T4SecureStorage: { + getCredentials: () => Promise.resolve({ + credentials: { + deviceId: "android-device", + deviceToken: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + }), + setCredentials: () => Promise.resolve(), + clearCredentials, + }, + }, + }, + }, + }); + + await expect(prepareNativeMobileBackend()).resolves.toEqual({ kind: "ready", backend }); + expect(window.__t4MobileBackend).toEqual({ + wsUrl: backend.wsUrl, + label: backend.label, + deviceId: "android-device", + deviceToken: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }); + await clearNativeMobileConnection(); + expect(storage.getItem(MOBILE_BACKEND_STORAGE_KEY)).toBeNull(); + }); + + it("probes the exact WSS endpoint before saving", async () => { + class OpeningSocket { + static url = ""; + readonly listeners = new Map void>>(); + constructor(url: string | URL) { + OpeningSocket.url = String(url); + queueMicrotask(() => this.emit("open")); + } + addEventListener(name: string, listener: () => void): void { + const listeners = this.listeners.get(name) ?? new Set(); + listeners.add(listener); + this.listeners.set(name, listeners); + } + removeEventListener(name: string, listener: () => void): void { this.listeners.get(name)?.delete(listener); } + close(): void {} + private emit(name: string): void { for (const listener of this.listeners.get(name) ?? []) listener(); } + } + const backend = parseTailnetBackend("https://host.tailnet.ts.net:8445"); + await expect(probeMobileBackend(backend, { WebSocketImpl: OpeningSocket as unknown as typeof WebSocket })).resolves.toBeUndefined(); + expect(OpeningSocket.url).toBe(backend.wsUrl); + }); + + it("renders focused first-run instructions instead of fixture sessions", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("Connect to your T4 host"); + expect(markup).toContain("Open Tailscale on this phone"); + expect(markup).toContain("h-12 w-full"); + expect(markup).not.toContain("Sample data"); + }); +}); diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index a6452958..403f4d46 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -34,6 +34,6 @@ }, "desktop": { "package": "@t4-code/protocol", - "version": "0.1.8" + "version": "0.1.9" } } diff --git a/docs/TAILNET_REMOTE.md b/docs/TAILNET_REMOTE.md index e70b39a9..c60d1b03 100644 --- a/docs/TAILNET_REMOTE.md +++ b/docs/TAILNET_REMOTE.md @@ -4,16 +4,17 @@ T4 Code can be used from a phone or another computer on the same Tailscale network. The remote path is intentionally small: ```text -phone browser - -> Tailscale Serve HTTPS (tailnet only) +phone browser or bundled T4 mobile UI + -> Tailscale Serve HTTPS/WSS (tailnet only) -> T4 gateway on 127.0.0.1:4194 -> OMP appserver Unix socket ``` There is no T4 password in this mode. Tailscale membership and your tailnet ACLs or grants are the access boundary. The gateway never listens on a LAN -interface, accepts WebSocket connections only from the exact configured -`.ts.net` HTTPS origin, and passes no token or password through the browser. +interface. It accepts WebSocket connections from the exact configured `.ts.net` +HTTPS origin and the two fixed Capacitor WebView origins described below. No +token or password is passed through the browser or mobile UI. > This is a source-hosted feature in the current release. The downloadable > desktop packages do not install or manage the Tailnet gateway. Keep the @@ -115,6 +116,37 @@ connected to the tailnet, the host gateway must be running, and OMP remains the owner of every session. The web app deliberately does not cache transcripts, host configuration, or runtime state for offline use. +## Native mobile client origins + +The Android APK and the planned iOS build bundle the T4 UI. They connect to the +same `/v1/ws` endpoint over WSS; they do not load the hosted website into the +WebView. Capacitor's documented defaults give those bundled pages these +origins: + +- Android: `https://localhost` +- iOS: `capacitor://localhost` + +The service installer writes those exact values to +`T4_NATIVE_ALLOWED_ORIGINS`. The gateway accepts them in addition to the one +Tailnet HTTPS origin passed through `--origin`. Empty values, `null`, wildcard +origins, alternate localhost schemes, and extra origins are rejected at +startup. These values must stay aligned with the mobile +`server.hostname`, `server.androidScheme`, and `server.iosScheme` settings. See +the [Capacitor configuration reference](https://capacitorjs.com/docs/config#schema) +for the platform defaults. + +The hosted web response keeps an exact `connect-src` entry for its configured +`wss://HOST.ts.net[:PORT]` endpoint. The bundled mobile document can connect to +user-selected profiles only under `wss://*.ts.net:*`. That subdomain pattern +restricts outbound destinations; it is never added to the gateway's accepted +WebSocket Origin list. Do not replace either policy with an unrestricted +`connect-src *`. + +An Origin header identifies the page that opened the WebSocket. It does not +prove that the caller is the signed T4 APK. Tailscale membership and ACLs remain +the access boundary, so the gateway must stay behind Tailscale Serve with +Funnel disabled. + ## Operate and update The lifecycle commands are the same on Linux and macOS: @@ -170,8 +202,10 @@ removes all of them. - The local HTTP gateway is fixed to `127.0.0.1` (or `::1` when explicitly configured by code); it cannot be configured to listen on a LAN address. -- The browser endpoint is an exact HTTPS `.ts.net` origin. Plain HTTP, public - domains, URL paths, embedded credentials, and wildcard origins are rejected. +- The hosted browser endpoint is an exact HTTPS `.ts.net` origin. The native + exceptions are exactly `https://localhost` and `capacitor://localhost`. + Plain HTTP Tailnet origins, public domains, URL paths, embedded credentials, + `null`, wildcard origins, and any other native origin are rejected. - Tailscale Serve terminates HTTPS and keeps the route tailnet-only. Do not substitute Funnel. - Every tailnet identity permitted to reach this node and port can operate the diff --git a/e2e/site-mobile-docs.spec.ts b/e2e/site-mobile-docs.spec.ts index b520bd15..927a75e0 100644 --- a/e2e/site-mobile-docs.spec.ts +++ b/e2e/site-mobile-docs.spec.ts @@ -122,6 +122,31 @@ test.afterAll(async () => { await site?.stop(); }); +test("offers the Android APK without hiding desktop downloads", async ({ page }) => { + await page.goto(`${site.url}/`, { waitUntil: "networkidle" }); + + const androidDownload = page.getByRole("link", { name: "Download Android APK" }).first(); + await expect(androidDownload).toBeVisible(); + await expect(androidDownload).toHaveAttribute( + "href", + "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-android.apk", + ); + await expect(page.getByRole("link", { name: "Download for Linux" }).first()).toBeVisible(); + await expect(page.getByRole("link", { name: "macOS build" }).first()).toBeVisible(); + await expect(page.getByText("TestFlight coming soon", { exact: true }).first()).toBeVisible(); + + const geometry = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + })); + expect(geometry).toEqual({ + clientWidth: 320, + documentScrollWidth: 320, + bodyScrollWidth: 320, + }); +}); + test("every docs topic stays inside a 320px viewport", async ({ page }) => { for (const topic of TOPICS) { await page.goto(`${site.url}/docs/#${topic}`, { waitUntil: "networkidle" }); diff --git a/package.json b/package.json index 426aa544..b124c8ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/root", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "scripts": { diff --git a/packages/client/package.json b/packages/client/package.json index 34fb3676..28da762d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/client", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "exports": { diff --git a/packages/client/src/omp-client-frames.ts b/packages/client/src/omp-client-frames.ts index 86cc0c83..cfedc2fc 100644 --- a/packages/client/src/omp-client-frames.ts +++ b/packages/client/src/omp-client-frames.ts @@ -66,7 +66,7 @@ export function sendClientHello( v: PROTOCOL_VERSION, type: "hello", protocol: { min: PROTOCOL_VERSION, max: PROTOCOL_VERSION }, - client: options.client ?? { name: "t4-code", version: "0.1.8", build: "client", platform: "electron" }, + client: options.client ?? { name: "t4-code", version: "0.1.9", build: "client", platform: "electron" }, requestedFeatures: [...(options.requestedFeatures ?? ["resume"])], savedCursors, ...(options.capabilities === undefined ? {} : { capabilities: { client: [...options.capabilities] } }), diff --git a/packages/fixture-server/package.json b/packages/fixture-server/package.json index 62e56997..86ba3e7a 100644 --- a/packages/fixture-server/package.json +++ b/packages/fixture-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/fixture-server", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "exports": { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 3e02193a..c7229193 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/protocol", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "exports": { diff --git a/packages/remote/package.json b/packages/remote/package.json index dde2c5f1..c851f3ec 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/remote", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "exports": { diff --git a/packages/service-manager/package.json b/packages/service-manager/package.json index f5037a8e..668aa1c9 100644 --- a/packages/service-manager/package.json +++ b/packages/service-manager/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/service-manager", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "exports": { diff --git a/packages/ui/package.json b/packages/ui/package.json index d3832da8..d3d299a7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/ui", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7616dc8e..259c4916 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 2.7.0 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) apps/desktop: dependencies: @@ -107,7 +107,20 @@ importers: version: 26.15.6(electron-builder-squirrel-windows@26.15.6) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + + apps/mobile: + dependencies: + '@capacitor/core': + specifier: 8.4.1 + version: 8.4.1 + devDependencies: + '@capacitor/android': + specifier: 8.4.1 + version: 8.4.1(@capacitor/core@8.4.1) + '@capacitor/cli': + specifier: 8.4.1 + version: 8.4.1 apps/site: dependencies: @@ -132,13 +145,13 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) + version: 6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)' + version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0) + version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) apps/web: dependencies: @@ -214,7 +227,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.3.2(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) + version: 4.3.2(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) '@types/react': specifier: 'catalog:' version: 19.2.17 @@ -223,16 +236,16 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) + version: 6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) tailwindcss: specifier: ^4.0.0 version: 4.3.2 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)' + version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0) + version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) packages/client: dependencies: @@ -251,7 +264,7 @@ importers: version: 8.5.13 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) ws: specifier: 8.18.3 version: 8.18.3 @@ -273,7 +286,7 @@ importers: version: 8.5.13 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) packages/protocol: dependencies: @@ -286,7 +299,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) packages/remote: dependencies: @@ -299,7 +312,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) packages/service-manager: devDependencies: @@ -308,7 +321,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) packages/ui: dependencies: @@ -345,7 +358,7 @@ importers: version: 19.2.6(react@19.2.6) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0) + version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) packages: @@ -391,6 +404,19 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@capacitor/android@8.4.1': + resolution: {integrity: sha512-igtDCJ7QQn0P2qHFD9p4KXaa6V1b2PRNt+MxjVwtjTm/BJvqmiazOJq6rPjwFSZnfHm6iFoZk8TfzHd44pyBGw==} + peerDependencies: + '@capacitor/core': ^8.4.0 + + '@capacitor/cli@8.4.1': + resolution: {integrity: sha512-t7F2s7fFHCq113xgrggrmK6ctV0/8E5YfLNVLfPHp4GCTDO+tly9fZvWPf2/sOI8lMm18dLT43qbXLRTz/OZgw==} + engines: {node: '>=22.0.0'} + hasBin: true + + '@capacitor/core@8.4.1': + resolution: {integrity: sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -458,6 +484,38 @@ packages: '@fontsource/jetbrains-mono@5.2.8': resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} + '@ionic/cli-framework-output@2.2.8': + resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-array@2.1.6': + resolution: {integrity: sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-fs@3.1.7': + resolution: {integrity: sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-object@2.1.6': + resolution: {integrity: sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-process@2.1.12': + resolution: {integrity: sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-stream@3.1.7': + resolution: {integrity: sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-subprocess@3.0.1': + resolution: {integrity: sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-terminal@2.3.5': + resolution: {integrity: sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==} + engines: {node: '>=16.0.0'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1058,6 +1116,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/fs-extra@8.1.5': + resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -1093,6 +1154,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/slice-ansi@4.0.0': + resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1378,6 +1442,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + async-exit-hook@2.0.1: resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} engines: {node: '>=0.12.0'} @@ -1412,6 +1480,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -1419,6 +1491,10 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} @@ -1525,6 +1601,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} @@ -1591,6 +1671,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -1672,6 +1756,10 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true + elementtree@0.1.7: + resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==} + engines: {node: '>= 0.4.0'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1833,6 +1921,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1931,6 +2023,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1943,6 +2039,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1958,6 +2059,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -2029,6 +2134,14 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -2135,6 +2248,10 @@ packages: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -2365,6 +2482,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + native-run@2.0.3: + resolution: {integrity: sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==} + engines: {node: '>=16.0.0'} + hasBin: true + node-abi@4.33.0: resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==} engines: {node: '>=22.12.0'} @@ -2404,6 +2526,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + oxfmt@0.57.0: resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2454,6 +2580,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -2472,6 +2601,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2547,6 +2680,10 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -2597,6 +2734,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -2648,6 +2789,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} @@ -2658,6 +2804,9 @@ packages: sanitize-filename@1.6.4: resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + sax@1.1.4: + resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -2722,6 +2871,13 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2736,6 +2892,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} @@ -2801,6 +2961,9 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -2837,6 +3000,10 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2857,6 +3024,11 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2893,6 +3065,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} @@ -3027,6 +3203,14 @@ packages: utf-8-validate: optional: true + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -3119,6 +3303,36 @@ snapshots: '@blazediff/core@1.9.1': {} + '@capacitor/android@8.4.1(@capacitor/core@8.4.1)': + dependencies: + '@capacitor/core': 8.4.1 + + '@capacitor/cli@8.4.1': + dependencies: + '@ionic/cli-framework-output': 2.2.8 + '@ionic/utils-subprocess': 3.0.1 + '@ionic/utils-terminal': 2.3.5 + commander: 12.1.0 + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 11.3.6 + kleur: 4.1.5 + native-run: 2.0.3 + open: 8.4.2 + plist: 3.1.0 + prompts: 2.4.2 + rimraf: 6.1.3 + semver: 7.8.5 + tar: 7.5.19 + tslib: 2.8.1 + xml2js: 0.6.2 + transitivePeerDependencies: + - supports-color + + '@capacitor/core@8.4.1': + dependencies: + tslib: 2.8.1 + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -3241,6 +3455,82 @@ snapshots: '@fontsource/jetbrains-mono@5.2.8': {} + '@ionic/cli-framework-output@2.2.8': + dependencies: + '@ionic/utils-terminal': 2.3.5 + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-array@2.1.6': + dependencies: + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-fs@3.1.7': + dependencies: + '@types/fs-extra': 8.1.5 + debug: 4.4.3 + fs-extra: 9.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-object@2.1.6': + dependencies: + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-process@2.1.12': + dependencies: + '@ionic/utils-object': 2.1.6 + '@ionic/utils-terminal': 2.3.5 + debug: 4.4.3 + signal-exit: 3.0.7 + tree-kill: 1.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-stream@3.1.7': + dependencies: + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-subprocess@3.0.1': + dependencies: + '@ionic/utils-array': 2.1.6 + '@ionic/utils-fs': 3.1.7 + '@ionic/utils-process': 2.1.12 + '@ionic/utils-stream': 3.1.7 + '@ionic/utils-terminal': 2.3.5 + cross-spawn: 7.0.6 + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-terminal@2.3.5': + dependencies: + '@types/slice-ansi': 4.0.0 + debug: 4.4.3 + signal-exit: 3.0.7 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + tslib: 2.8.1 + untildify: 4.0.0 + wrap-ansi: 7.0.0 + transitivePeerDependencies: + - supports-color + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -3688,12 +3978,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/vite@4.3.2(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.2(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' '@tanstack/history@1.162.0': {} @@ -3763,6 +4053,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/fs-extra@8.1.5': + dependencies: + '@types/node': 26.1.1 + '@types/fs-extra@9.0.13': dependencies: '@types/node': 26.1.1 @@ -3803,6 +4097,8 @@ snapshots: dependencies: '@types/node': 26.1.1 + '@types/slice-ansi@4.0.0': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -3849,45 +4145,45 @@ snapshots: '@ungap/structured-clone@1.3.2': {} - '@vitejs/plugin-react@6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' - '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9)': + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)))': + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9)': + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -3895,16 +4191,16 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)))': + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)))': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -3921,21 +4217,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))': + '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)' - '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' '@vitest/pretty-format@4.1.9': dependencies: @@ -3961,7 +4257,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)': + '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)': dependencies: '@oxc-project/runtime': 0.138.0 '@oxc-project/types': 0.138.0 @@ -3971,8 +4267,9 @@ snapshots: '@types/node': 24.12.4 fsevents: 2.3.3 jiti: 2.7.0 + typescript: 6.0.3 - '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)': + '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)': dependencies: '@oxc-project/runtime': 0.138.0 '@oxc-project/types': 0.138.0 @@ -3982,6 +4279,7 @@ snapshots: '@types/node': 26.1.1 fsevents: 2.3.3 jiti: 2.7.0 + typescript: 6.0.3 '@voidzero-dev/vite-plus-darwin-arm64@0.2.2': optional: true @@ -4098,6 +4396,8 @@ snapshots: assertion-error@2.0.1: {} + astral-regex@2.0.0: {} + async-exit-hook@2.0.1: {} async@3.2.6: {} @@ -4118,11 +4418,17 @@ snapshots: base64-js@1.5.1: {} + big-integer@1.6.52: {} + bluebird@3.7.2: {} boolean@3.2.0: optional: true + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 @@ -4238,6 +4544,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@12.1.0: {} + commander@5.1.0: {} commander@9.5.0: @@ -4302,6 +4610,8 @@ snapshots: gopd: 1.2.0 optional: true + define-lazy-prop@2.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -4440,6 +4750,10 @@ snapshots: transitivePeerDependencies: - supports-color + elementtree@0.1.7: + dependencies: + sax: 1.1.4 + emoji-regex@8.0.0: {} end-of-stream@1.4.5: @@ -4605,6 +4919,12 @@ snapshots: dependencies: pump: 3.0.4 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -4780,6 +5100,8 @@ snapshots: inherits@2.0.4: {} + ini@4.1.3: {} + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -4791,6 +5113,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@2.2.1: {} + is-fullwidth-code-point@3.0.0: {} is-hexadecimal@2.0.1: {} @@ -4799,6 +5123,10 @@ snapshots: is-plain-obj@4.1.0: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -4854,6 +5182,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@3.0.3: {} + + kleur@4.1.5: {} + lazy-val@1.0.5: {} lexical@0.41.0: {} @@ -4926,6 +5258,8 @@ snapshots: lowercase-keys@2.0.0: {} + lru-cache@11.5.2: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -5348,6 +5682,22 @@ snapshots: nanoid@3.3.15: {} + native-run@2.0.3: + dependencies: + '@ionic/utils-fs': 3.1.7 + '@ionic/utils-terminal': 2.3.5 + bplist-parser: 0.3.2 + debug: 4.4.3 + elementtree: 0.1.7 + ini: 4.1.3 + plist: 3.1.0 + split2: 4.2.0 + through2: 4.0.2 + tslib: 2.8.1 + yauzl: 2.10.0 + transitivePeerDependencies: + - supports-color + node-abi@4.33.0: dependencies: semver: 7.8.5 @@ -5390,7 +5740,13 @@ snapshots: dependencies: mimic-fn: 2.1.0 - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)): + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -5413,9 +5769,9 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.57.0 '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + vite-plus: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)): + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -5438,7 +5794,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.57.0 '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@26.1.1)(jiti@2.7.0) + vite-plus: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) oxlint-tsgolint@0.24.0: optionalDependencies: @@ -5449,7 +5805,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.24.0 '@oxlint-tsgolint/win32-x64': 0.24.0 - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)): + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 '@oxlint/binding-android-arm64': 1.72.0 @@ -5471,9 +5827,9 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@24.12.4)(jiti@2.7.0) + vite-plus: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)): + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 '@oxlint/binding-android-arm64': 1.72.0 @@ -5495,7 +5851,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@26.1.1)(jiti@2.7.0) + vite-plus: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) p-cancelable@2.1.1: {} @@ -5513,6 +5869,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -5533,6 +5891,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} pe-library@0.4.1: {} @@ -5602,6 +5965,11 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -5670,6 +6038,12 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.5 @@ -5743,6 +6117,11 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -5759,6 +6138,8 @@ snapshots: dependencies: truncate-utf8-bytes: 1.0.2 + sax@1.1.4: {} + sax@1.6.0: {} scheduler@0.27.0: {} @@ -5805,6 +6186,14 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sisteransi@1.0.5: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5816,6 +6205,8 @@ snapshots: space-separated-tokens@2.0.2: {} + split2@4.2.0: {} + sprintf-js@1.1.3: optional: true @@ -5888,6 +6279,10 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 + through2@4.0.2: + dependencies: + readable-stream: 3.6.2 + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 @@ -5915,6 +6310,8 @@ snapshots: totalist@3.0.1: {} + tree-kill@1.2.2: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -5930,6 +6327,9 @@ snapshots: type-fest@2.19.0: {} + typescript@6.0.3: + optional: true + undici-types@7.16.0: {} undici-types@8.3.0: {} @@ -5973,6 +6373,8 @@ snapshots: universalify@2.0.1: {} + untildify@4.0.0: {} + unzipper@0.12.5: dependencies: bluebird: 3.7.2 @@ -6004,25 +6406,25 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0): + vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3): dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(jiti@2.7.0) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) oxlint-tsgolint: 0.24.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)' - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)' + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -6062,25 +6464,25 @@ snapshots: - utf-8-validate - yaml - vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0): + vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3): dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@26.1.1)(jiti@2.7.0) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) + '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) + oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) oxlint-tsgolint: 0.24.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)' - vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' + vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -6120,10 +6522,10 @@ snapshots: - utf-8-validate - yaml - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -6140,18 +6542,18 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) transitivePeerDependencies: - msw - vitest@4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)): + vitest@4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -6168,11 +6570,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0))(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) transitivePeerDependencies: - msw @@ -6215,6 +6617,13 @@ snapshots: ws@8.21.0: {} + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} y18n@5.0.8: {} diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index 07728c9d..038bb1d9 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -45,6 +45,7 @@ const VERSION_PATTERN = /^\d+\.\d+\.\d+$/u; export function expectedReleaseAssetNames(version) { return [ + `T4-Code-${version}-android.apk`, `T4-Code-${version}-linux-amd64.deb`, `T4-Code-${version}-linux-x86_64.AppImage`, `T4-Code-${version}-mac-arm64.dmg`, @@ -251,7 +252,9 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { requireText(site, `"${filename}"`, "apps/site/src/release.ts", errors); } const siteAssetVersions = new Set( - [...site.matchAll(/T4-Code-(\d+\.\d+\.\d+)-(?:linux|mac)-/gu)].map((match) => match[1]), + [...site.matchAll(/T4-Code-(\d+\.\d+\.\d+)-(?:android|linux|mac)(?:\.|-)/gu)].map( + (match) => match[1], + ), ); for (const assetVersion of siteAssetVersions) { if (assetVersion !== version) { @@ -405,6 +408,15 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "ref: ${{ needs.verify.outputs.source_sha }}", "Confirm the release tag still resolves to the verified source", 'test "$(git rev-parse "${RELEASE_TAG}^{commit}")" = "$SOURCE_SHA"', + "build-android:", + "T4_ANDROID_KEYSTORE_BASE64", + "T4_ANDROID_KEYSTORE_PASSWORD", + "T4_ANDROID_KEY_ALIAS", + "T4_ANDROID_KEY_PASSWORD", + "pnpm --filter @t4-code/mobile build:android:release", + "apksigner verify --verbose", + "T4-Code-${VERSION}-android.apk", + "needs: [verify, build-android, build-linux, build-macos]", ]) { requireText(releaseWorkflow, expected, ".github/workflows/release.yml", errors); } diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 97aed51f..8a66a793 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -17,23 +17,23 @@ function changed(path, replace) { } test("current source tree has one consistent release version", () => { - assert.deepEqual(collectReleaseConsistencyErrors(files, "v0.1.8"), []); + assert.deepEqual(collectReleaseConsistencyErrors(files, "v0.1.9"), []); }); test("rejects a tag that differs from the package version", () => { assert.ok( collectReleaseConsistencyErrors(files, "v9.9.9").some((error) => - error.includes("release tag v9.9.9 does not match v0.1.8"), + error.includes("release tag v9.9.9 does not match v0.1.9"), ), ); }); test("rejects workspace, site, README, and runtime version drift", () => { const cases = [ - ["apps/web/package.json", (text) => text.replace('"version": "0.1.8"', '"version": "0.1.3"')], - ["apps/site/src/release.ts", (text) => text.replace('RELEASE_TAG = "v0.1.8"', 'RELEASE_TAG = "v0.1.3"')], - ["README.md", (text) => text.replace("Download v0.1.8", "Download v0.1.3")], - ["apps/desktop/src/target-manager.ts", (text) => text.replace('version: "0.1.8"', 'version: "0.1.3"')], + ["apps/web/package.json", (text) => text.replace('"version": "0.1.9"', '"version": "0.1.3"')], + ["apps/site/src/release.ts", (text) => text.replace('RELEASE_TAG = "v0.1.9"', 'RELEASE_TAG = "v0.1.3"')], + ["README.md", (text) => text.replace("Download v0.1.9", "Download v0.1.3")], + ["apps/desktop/src/target-manager.ts", (text) => text.replace('version: "0.1.9"', 'version: "0.1.3"')], ["apps/site/src/docs/content.ts", (text) => text.replace('id: "troubleshooting-large-session"', 'id: "missing-large-session"')], ]; for (const [path, replace] of cases) { @@ -123,7 +123,7 @@ test("rejects stale README release URLs while allowing historical prose", () => ); assert.ok( collectReleaseConsistencyErrors(staleLink).some((error) => - error.includes("release URL for v0.1.3; expected v0.1.8"), + error.includes("release URL for v0.1.3; expected v0.1.9"), ), ); assert.deepEqual(collectReleaseConsistencyErrors(files), []); @@ -144,7 +144,10 @@ test("deploys release site source only after artifact publication", () => { ); assert.ok(releaseWorkflow.includes("ref: ${{ steps.source.outputs.source_sha }}")); assert.ok(releaseWorkflow.includes("ref: ${{ needs.verify.outputs.source_sha }}")); - assert.ok(releaseWorkflow.includes("needs: [verify, build-linux, build-macos]")); + assert.ok(releaseWorkflow.includes("needs: [verify, build-android, build-linux, build-macos]")); + assert.ok(releaseWorkflow.includes("pnpm --filter @t4-code/mobile build:android:release")); + assert.ok(releaseWorkflow.includes("T4_ANDROID_KEYSTORE_BASE64")); + assert.ok(releaseWorkflow.includes("apksigner verify --verbose")); assert.ok( releaseWorkflow.includes("Confirm the release tag still resolves to the verified source"), ); diff --git a/scripts/inspect-macos-dmg.test.mjs b/scripts/inspect-macos-dmg.test.mjs index ea57c250..65ed1eab 100644 --- a/scripts/inspect-macos-dmg.test.mjs +++ b/scripts/inspect-macos-dmg.test.mjs @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { findMountedApp, inspectMacosDmg } from "./inspect-macos-dmg.mjs"; -const dmgPath = "/tmp/T4-Code-0.1.8-mac-arm64.dmg"; +const dmgPath = "/tmp/T4-Code-0.1.9-mac-arm64.dmg"; const mountPoint = "/tmp/t4-code-dmg-test"; function directory(name) { diff --git a/scripts/tailnet-gateway.mjs b/scripts/tailnet-gateway.mjs index 77c9d6c3..bec56e99 100644 --- a/scripts/tailnet-gateway.mjs +++ b/scripts/tailnet-gateway.mjs @@ -17,6 +17,14 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000; const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]); const OMP_SOCKET_NAME = /^\.appserver-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.sock$/u; +// Capacitor v8 serves bundled assets from these origins when its documented +// default hostname and platform schemes are used. Keep this list exact: an +// Origin header identifies a browser context, not a signed mobile binary. +export const CAPACITOR_NATIVE_ORIGINS = Object.freeze([ + "https://localhost", + "capacitor://localhost", +]); + const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], @@ -61,6 +69,23 @@ export function normalizeAllowedOrigin(value) { return url.origin; } +export function normalizeNativeAllowedOrigins(value = CAPACITOR_NATIVE_ORIGINS) { + if (!Array.isArray(value)) { + throw new Error("T4_NATIVE_ALLOWED_ORIGINS must be an array"); + } + const origins = value.map((origin) => requiredText(origin, "T4_NATIVE_ALLOWED_ORIGINS", 128)); + const unique = new Set(origins); + if ( + unique.size !== CAPACITOR_NATIVE_ORIGINS.length || + !CAPACITOR_NATIVE_ORIGINS.every((origin) => unique.has(origin)) + ) { + throw new Error( + `T4_NATIVE_ALLOWED_ORIGINS must contain exactly ${CAPACITOR_NATIVE_ORIGINS.join(", ")}`, + ); + } + return [...CAPACITOR_NATIVE_ORIGINS]; +} + export function websocketUrlForOrigin(origin) { const url = new URL("/v1/ws", normalizeAllowedOrigin(origin)); url.protocol = "wss:"; @@ -312,6 +337,7 @@ export async function startTailnetGateway(input) { listenHost: input.listenHost ?? "127.0.0.1", listenPort: input.listenPort ?? DEFAULT_PORT, allowedOrigin: normalizeAllowedOrigin(input.allowedOrigin), + nativeAllowedOrigins: normalizeNativeAllowedOrigins(input.nativeAllowedOrigins), label: input.label ?? "OMP on this Tailnet host", heartbeatIntervalMs: input.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS, }; @@ -322,6 +348,7 @@ export async function startTailnetGateway(input) { if (!Number.isSafeInteger(options.heartbeatIntervalMs) || options.heartbeatIntervalMs < 10) { throw new Error("Tailnet gateway heartbeat interval is invalid"); } + const allowedSocketOrigins = new Set([options.allowedOrigin, ...options.nativeAllowedOrigins]); const activeBrowsers = new Map(); const webSockets = new WebSocketServer({ @@ -376,7 +403,7 @@ export async function startTailnetGateway(input) { rejectUpgrade(socket, "404 Not Found", "Not found"); return; } - if (request.headers.origin !== options.allowedOrigin) { + if (!allowedSocketOrigins.has(request.headers.origin)) { rejectUpgrade(socket, "403 Forbidden", "Origin not allowed"); return; } @@ -444,6 +471,10 @@ export function optionsFromEnvironment(environment = process.env) { listenHost: environment.T4_GATEWAY_HOST ?? "127.0.0.1", listenPort: port, allowedOrigin: environment.T4_ALLOWED_ORIGIN, + nativeAllowedOrigins: + environment.T4_NATIVE_ALLOWED_ORIGINS === undefined + ? undefined + : environment.T4_NATIVE_ALLOWED_ORIGINS.split(","), label: environment.T4_HOST_LABEL ?? "OMP on this Tailnet host", }; } diff --git a/scripts/tailnet-gateway.test.mjs b/scripts/tailnet-gateway.test.mjs index fae125f9..b43afa52 100644 --- a/scripts/tailnet-gateway.test.mjs +++ b/scripts/tailnet-gateway.test.mjs @@ -8,9 +8,12 @@ import { test } from "node:test"; import WebSocket, { WebSocketServer } from "ws"; import { + CAPACITOR_NATIVE_ORIGINS, cacheControlForStaticPath, injectBackendConfig, normalizeAllowedOrigin, + normalizeNativeAllowedOrigins, + optionsFromEnvironment, resolveAppSocket, safeStaticPath, startTailnetGateway, @@ -94,6 +97,33 @@ test("origin validation accepts only explicit Tailscale HTTPS origins", () => { } }); +test("native origin validation is pinned to Capacitor's Android and iOS defaults", () => { + assert.deepEqual(normalizeNativeAllowedOrigins(), ["https://localhost", "capacitor://localhost"]); + assert.deepEqual(normalizeNativeAllowedOrigins(["capacitor://localhost", "https://localhost"]), [ + ...CAPACITOR_NATIVE_ORIGINS, + ]); + for (const value of [ + ["*"], + ["null"], + ["http://localhost", "capacitor://localhost"], + ["https://localhost", "capacitor://localhost", "https://mobile.example.com"], + ]) { + assert.throws(() => normalizeNativeAllowedOrigins(value), /must contain exactly/u); + } +}); + +test("gateway environment parses the explicit comma-separated native origin set", () => { + const options = optionsFromEnvironment({ + T4_ALLOWED_ORIGIN: ALLOWED_ORIGIN, + T4_NATIVE_ALLOWED_ORIGINS: "https://localhost,capacitor://localhost", + XDG_RUNTIME_DIR: "/run/user/1000", + }); + assert.deepEqual(normalizeNativeAllowedOrigins(options.nativeAllowedOrigins), [ + "https://localhost", + "capacitor://localhost", + ]); +}); + test("backend injection is explicit, credential-free, and script-safe", () => { const source = ""; const injected = injectBackendConfig(source, { @@ -139,6 +169,12 @@ test("gateway serves configured app and reports real upstream health", async () assert.equal(indexResponse.status, 200); assert.match(await indexResponse.text(), /id="t4-backend"/u); assert.equal(indexResponse.headers.get("x-frame-options"), "DENY"); + const contentSecurityPolicy = indexResponse.headers.get("content-security-policy"); + assert.match( + contentSecurityPolicy ?? "", + /connect-src 'self' wss:\/\/host\.example-tailnet\.ts\.net:8445/u, + ); + assert.doesNotMatch(contentSecurityPolicy ?? "", /\*/u); const assetResponse = await fetch(`${running.url}/app.js`); assert.equal(assetResponse.status, 200); @@ -175,26 +211,32 @@ test("gateway serves configured app and reports real upstream health", async () } }); -test("gateway rejects cross-origin sockets and bridges the allowed browser to Unix", async () => { +test("gateway rejects cross-origin sockets and bridges only the web and native allowlist", async () => { const running = await fixture(); try { - const denied = new WebSocket(`${running.url.replace("http", "ws")}/v1/ws`, { - headers: { Origin: "https://attacker.example-tailnet.ts.net" }, - }); - const deniedStatus = await new Promise((resolvePromise, reject) => { - denied.once("unexpected-response", (_request, response) => resolvePromise(response.statusCode)); - denied.once("open", () => reject(new Error("cross-origin socket opened"))); - denied.once("error", () => {}); - }); - assert.equal(deniedStatus, 403); + for (const origin of ["https://attacker.example-tailnet.ts.net", "null", "*"]) { + const denied = new WebSocket(`${running.url.replace("http", "ws")}/v1/ws`, { + headers: { Origin: origin }, + }); + const deniedStatus = await new Promise((resolvePromise, reject) => { + denied.once("unexpected-response", (_request, response) => + resolvePromise(response.statusCode), + ); + denied.once("open", () => reject(new Error(`socket opened for denied origin ${origin}`))); + denied.once("error", () => {}); + }); + assert.equal(deniedStatus, 403); + } - const allowed = new WebSocket(`${running.url.replace("http", "ws")}/v1/ws`, { - headers: { Origin: ALLOWED_ORIGIN }, - }); - await websocketOpen(allowed); - allowed.send("hello"); - assert.equal(await websocketMessage(allowed), "upstream:hello"); - allowed.close(); + for (const origin of [ALLOWED_ORIGIN, ...CAPACITOR_NATIVE_ORIGINS]) { + const allowed = new WebSocket(`${running.url.replace("http", "ws")}/v1/ws`, { + headers: { Origin: origin }, + }); + await websocketOpen(allowed); + allowed.send(`hello:${origin}`); + assert.equal(await websocketMessage(allowed), `upstream:hello:${origin}`); + allowed.close(); + } } finally { await running.close(); } diff --git a/scripts/tailnet-service.mjs b/scripts/tailnet-service.mjs index 0668e494..6f5f854c 100644 --- a/scripts/tailnet-service.mjs +++ b/scripts/tailnet-service.mjs @@ -13,6 +13,7 @@ export const DEFAULT_GATEWAY_PORT = 4_194; const CONFIG_VERSION = 1; const MAX_OUTPUT_BYTES = 64 * 1024; const MAX_TEXT = 4_096; +const CAPACITOR_NATIVE_ORIGINS = Object.freeze(["https://localhost", "capacitor://localhost"]); function fail(message) { throw new Error(message); @@ -76,6 +77,19 @@ function normalizeServiceOrigin(value) { return url.origin; } +function normalizeServiceNativeOrigins(value = CAPACITOR_NATIVE_ORIGINS) { + if (!Array.isArray(value)) fail("native allowed origins must be an array"); + const origins = value.map((origin) => cleanText(origin, "native allowed origin", 128)); + const unique = new Set(origins); + if ( + unique.size !== CAPACITOR_NATIVE_ORIGINS.length || + !CAPACITOR_NATIVE_ORIGINS.every((origin) => unique.has(origin)) + ) { + fail(`native allowed origins must contain exactly ${CAPACITOR_NATIVE_ORIGINS.join(", ")}`); + } + return [...CAPACITOR_NATIVE_ORIGINS]; +} + export function servicePaths({ platform = process.platform, homeDirectory = homedir(), @@ -115,6 +129,7 @@ export function validateServiceConfig(input) { webRoot: absolutePath(input.webRoot ?? join(sourceRoot, "apps", "web", "dist"), "web root"), appSocket: absolutePath(input.appSocket, "OMP appserver socket"), allowedOrigin: normalizeServiceOrigin(input.allowedOrigin), + nativeAllowedOrigins: normalizeServiceNativeOrigins(input.nativeAllowedOrigins), port: gatewayPort(input.port ?? DEFAULT_GATEWAY_PORT), label: cleanText(input.label ?? "OMP on this Tailnet host", "host label", 128), }; @@ -138,6 +153,7 @@ function escapeXml(value) { function gatewayEnvironment(config) { return { T4_ALLOWED_ORIGIN: config.allowedOrigin, + T4_NATIVE_ALLOWED_ORIGINS: config.nativeAllowedOrigins.join(","), T4_GATEWAY_HOST: "127.0.0.1", T4_GATEWAY_PORT: String(config.port), T4_WEB_ROOT: config.webRoot, @@ -526,6 +542,7 @@ async function inspect(paths) { console.log(`health: ${healthResult.ok ? "healthy" : "unhealthy"}`); console.log(`local URL: http://127.0.0.1:${config.port}`); console.log(`allowed origin: ${config.allowedOrigin}`); + console.log(`native origins: ${config.nativeAllowedOrigins.join(", ")}`); if (supervisorResult?.stderr) console.log(`diagnostics: ${supervisorResult.stderr}`); if (definitionState !== "current" || !supervisorRunning || !healthResult.ok) { process.exitCode = 1; diff --git a/scripts/tailnet-service.test.mjs b/scripts/tailnet-service.test.mjs index 4d09385b..bbf79fb2 100644 --- a/scripts/tailnet-service.test.mjs +++ b/scripts/tailnet-service.test.mjs @@ -24,16 +24,24 @@ const CONFIG = { webRoot: "/home/alice/t4-code/apps/web/dist", appSocket: "/run/user/1000/omp/appserver.sock", allowedOrigin: "https://workstation.example-tailnet.ts.net:8445", + nativeAllowedOrigins: ["https://localhost", "capacitor://localhost"], port: DEFAULT_GATEWAY_PORT, label: "Alice's workstation", }; test("service config requires an exact Tailnet HTTPS origin and absolute local paths", () => { assert.deepEqual(validateServiceConfig(CONFIG), CONFIG); + const legacyConfig = { ...CONFIG }; + delete legacyConfig.nativeAllowedOrigins; + assert.deepEqual(validateServiceConfig(legacyConfig), CONFIG); assert.throws( () => validateServiceConfig({ ...CONFIG, allowedOrigin: "https://public.example.com" }), /Tailscale HTTPS origin/u, ); + assert.throws( + () => validateServiceConfig({ ...CONFIG, nativeAllowedOrigins: ["*"] }), + /must contain exactly/u, + ); assert.throws( () => validateServiceConfig({ ...CONFIG, webRoot: "apps/web/dist" }), /absolute path/u, @@ -53,6 +61,10 @@ test("Linux paths are user-scoped and the systemd unit is shell-free and loopbac const unit = renderSystemdUnit({ ...CONFIG, label: 'host "$(touch /tmp/nope)" 100%' }); assert.match(unit, /ExecStart="\/opt\/node\/bin\/node" "\/home\/alice\/t4-code\/scripts\/tailnet-gateway\.mjs"/u); assert.match(unit, /Environment="T4_GATEWAY_HOST=127\.0\.0\.1"/u); + assert.match( + unit, + /Environment="T4_NATIVE_ALLOWED_ORIGINS=https:\/\/localhost,capacitor:\/\/localhost"/u, + ); assert.match(unit, /Environment="T4_HOST_LABEL=host \\"\\\$\(touch \/tmp\/nope\)\\" 100%%"/u); assert.match(unit, /NoNewPrivileges=true/u); assert.match(unit, /ProtectHome=read-only/u); @@ -98,6 +110,10 @@ test("macOS paths and launch agent preserve argv and environment as XML data", ( assert.match(plist, /ProgramArguments<\/key>/u); assert.match(plist, /\/opt\/node\/bin\/node<\/string>/u); assert.match(plist, /T4_GATEWAY_HOST<\/key>\s+127\.0\.0\.1<\/string>/u); + assert.match( + plist, + /T4_NATIVE_ALLOWED_ORIGINS<\/key>\s+https:\/\/localhost,capacitor:\/\/localhost<\/string>/u, + ); assert.match(plist, /A&B <host><\/string>/u); assert.match(plist, /Umask<\/key>63<\/integer>/u); }); diff --git a/scripts/wait-for-release-assets.test.mjs b/scripts/wait-for-release-assets.test.mjs index 0bf9f647..b0c936da 100644 --- a/scripts/wait-for-release-assets.test.mjs +++ b/scripts/wait-for-release-assets.test.mjs @@ -5,14 +5,15 @@ import { releaseAssetUrls, waitForReleaseAssets } from "./wait-for-release-asset const quiet = { log() {} }; -test("builds the four package URLs and checksum URL for one release tag", () => { +test("builds the five package URLs and checksum URL for one release tag", () => { assert.deepEqual( - releaseAssetUrls("0.1.8").map(({ filename }) => filename), + releaseAssetUrls("0.1.9").map(({ filename }) => filename), [ - "T4-Code-0.1.8-linux-amd64.deb", - "T4-Code-0.1.8-linux-x86_64.AppImage", - "T4-Code-0.1.8-mac-arm64.dmg", - "T4-Code-0.1.8-mac-arm64.zip", + "T4-Code-0.1.9-android.apk", + "T4-Code-0.1.9-linux-amd64.deb", + "T4-Code-0.1.9-linux-x86_64.AppImage", + "T4-Code-0.1.9-mac-arm64.dmg", + "T4-Code-0.1.9-mac-arm64.zip", "SHA256SUMS.txt", ], ); @@ -20,19 +21,19 @@ test("builds the four package URLs and checksum URL for one release tag", () => test("passes only when every public release file returns HTTP 200", async () => { const result = await waitForReleaseAssets({ - version: "0.1.8", + version: "0.1.9", fetchImpl: async () => ({ status: 200 }), logger: quiet, }); assert.equal(result.attempts, 1); - assert.equal(result.assets.length, 5); + assert.equal(result.assets.length, 6); }); test("retries unavailable files and stays inside the configured timeout", async () => { let currentTime = 0; let calls = 0; const result = await waitForReleaseAssets({ - version: "0.1.8", + version: "0.1.9", timeoutMs: 100, intervalMs: 20, requestTimeoutMs: 5, @@ -49,7 +50,7 @@ test("fails closed at the timeout and reports filenames without response content let currentTime = 0; await assert.rejects( waitForReleaseAssets({ - version: "0.1.8", + version: "0.1.9", timeoutMs: 40, intervalMs: 25, requestTimeoutMs: 5,