diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9cead..d943dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.13] - 2026-07-26 + +### Added + +- iPhone, iPad, and Android now have thin Capacitor gateway clients for an + already configured 1Helm host. The connection screen accepts an HTTPS server + address, username, and password, confirms the host's explicit mobile + compatibility contract, and never exposes first-run host setup. +- Android ships as a directly distributed, signed universal APK with a stable + application ID and permanent release certificate. Release builds fail + closed without external signing material and verify alignment, package and + version metadata, v2/v3 signatures, and native ABI coverage. +- iOS has an App Store archive/export path with iPhone and iPad support, stable + bundle and callback identifiers, a privacy manifest, and the required + microphone, speech, and encryption declarations. + +### Security + +- Mobile passwords are used only for the sign-in request and are never saved. + Sessions are kept in the device-only iOS Keychain or encrypted with an + Android Keystore-held key; Disconnect erases the session and server address. +- The packaged local web client connects only to HTTPS servers, disables + native WebView debugging and release logging, rejects cleartext traffic and + Android backups, and limits server CORS to the two packaged Capacitor origins. + +### Changed + +- Authenticated API calls, files, avatars, workspace photos, terminals, app + events, external links, and provider OAuth flows now resolve correctly + through a selected mobile server while bundled brand and resident character + assets remain local. + ## [0.0.12] - 2026-07-26 ### Fixed @@ -348,7 +380,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.12...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.13...HEAD +[0.0.13]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.13 [0.0.12]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.12 [0.0.11]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.11 [0.0.10]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.10 diff --git a/README.md b/README.md index d1859e4..66728da 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@  ·  for Linux  ·  + for Android +  ·  The story  ·  Ship's manual @@ -32,7 +34,7 @@ Self-hosted   Model-agnostic   Signed + notarized   - macOS · Windows · Linux + macOS · Windows · Linux · iOS · Android

--- @@ -132,6 +134,26 @@ digest-verified, health-checked updater — see the it works best on a dedicated machine: your crew works around the clock, and your everyday computer takes naps. +### Connect from iPhone, iPad, or Android + +The mobile apps are thin, native gateways to a 1Helm you already run. Install +and finish setup on a supported Mac, Windows, or Linux host first, give that +host an HTTPS address, then enter the address, username, and password in the +mobile app. The password is used only for sign-in and is never retained; the +resulting session is stored in the iOS Keychain or encrypted with a key held by +Android Keystore. + +- iPhone and iPad use the App Store build under the stable bundle identifier + `com.gitcommit90.onehelm.mobile`. +- Android is distributed directly as `1Helm--universal.apk` on the + matching GitHub Release. Android may ask you to allow installs from the app + you used to open the file. Verify the release SHA-256 and install it; future + releases signed by the same permanent 1Helm certificate install in place. +- The native clients require HTTPS, do not contain or initialize the 1Helm + server, and do not retain host data or provider credentials beyond the + selected server address and secure session token. Use **Disconnect** in the + profile menu to erase both from the device. + ## Ready on day one. Specialized by day one hundred.

@@ -223,6 +245,8 @@ and an audit trail. A prompt saying “use this service” is not a connector. - Host-owned updates: a signed native Mac updater plus an atomic, digest-verified Linux system service with health-check rollback. - Signed, Apple-notarized, stapled Apple Silicon DMG releases. +- Native iPhone/iPad and directly distributed Android gateway clients for an + already configured HTTPS 1Helm host. ### Platform truth @@ -231,10 +255,11 @@ and an audit trail. A prompt saying “use this service” is not a connector. | **Apple Silicon macOS 26** | Native desktop product and real isolated Linux computer per resident (Apple `container machine`, `home-mount=none`). | | **Linux / CI** | Supported headless systemd host with one unprivileged LXC per resident (subordinate UID/GID mapping, exact ownership checks); CI may select an explicit test backend. | | **Windows 11 x64** | Native desktop product with one private WSL 2 world per resident (Windows-drive mounts and interop disabled); the signed Setup executable ships with every release. | +| **iPhone and iPad** | App Store gateway to an already configured HTTPS 1Helm host; sessions live in the device-only iOS Keychain. | +| **Android 7+** | Directly distributed signed universal APK gateway; sessions are encrypted by a key held in Android Keystore. | -Not yet shipped: a native Linux desktop shell, mobile clients, a hosted -control plane, rich Photon attachment fidelity, or blind execution of -community skills. +Not yet shipped: a native Linux desktop shell, a hosted control plane, rich +Photon attachment fidelity, or blind execution of community skills. ## Run the source workspace @@ -257,7 +282,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.12` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.13` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..5af5489 --- /dev/null +++ b/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 +*.jks +*.keystore +*.properties.local + +# 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/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..4f25f81 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,84 @@ +import java.util.Properties + +apply plugin: 'com.android.application' + +def oneHelmVersion = new groovy.json.JsonSlurper().parseText(file('../../package.json').text).version as String +def oneHelmVersionParts = oneHelmVersion.tokenize('.').collect { it as int } +def oneHelmVersionCode = oneHelmVersionParts[0] * 1000000 + oneHelmVersionParts[1] * 1000 + oneHelmVersionParts[2] +def signingPropertiesPath = System.getenv('HELM_ANDROID_SIGNING_PROPERTIES') +def signingProperties = new Properties() +if (signingPropertiesPath) { + def signingPropertiesFile = file(signingPropertiesPath) + if (!signingPropertiesFile.isFile()) throw new GradleException('HELM_ANDROID_SIGNING_PROPERTIES does not name a readable file.') + signingPropertiesFile.withInputStream { signingProperties.load(it) } +} + +android { + namespace = "com.gitcommit90.onehelm.mobile" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.gitcommit90.onehelm.mobile" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode oneHelmVersionCode + versionName oneHelmVersion + 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 { + release { + if (signingPropertiesPath) { + storeFile file(signingProperties.getProperty('storeFile')) + storePassword signingProperties.getProperty('storePassword') + keyAlias signingProperties.getProperty('keyAlias') + keyPassword signingProperties.getProperty('keyPassword') + enableV1Signing true + enableV2Signing true + enableV3Signing true + enableV4Signing true + } + } + } + buildTypes { + release { + if (!signingPropertiesPath) throw new GradleException('Release builds require HELM_ANDROID_SIGNING_PROPERTIES.') + signingConfig signingConfigs.release + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +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/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..8062541 --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,24 @@ +// 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 { + implementation project(':aparajita-capacitor-secure-storage') + implementation project(':capacitor-app') + implementation project(':capacitor-browser') + implementation project(':capacitor-keyboard') + implementation project(':capacitor-splash-screen') + implementation project(':capacitor-status-bar') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/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/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +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; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..470cdd3 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/gitcommit90/onehelm/mobile/MainActivity.java b/android/app/src/main/java/com/gitcommit90/onehelm/mobile/MainActivity.java new file mode 100644 index 0000000..b5099ce --- /dev/null +++ b/android/app/src/main/java/com/gitcommit90/onehelm/mobile/MainActivity.java @@ -0,0 +1,5 @@ +package com.gitcommit90.onehelm.mobile; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..5ca6e09 Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..ddaee7f Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..c82d23f Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..3dc63d8 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..51c58db Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..4f9762d Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..dfd3f08 Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..c69f2da Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..6aa7c84 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..30606b9 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..ddaee7f Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/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/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b7fe701 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fd54bd2 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b7fe701 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..7459dba Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f5b5dc7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..7459dba Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..95abf53 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e1d5879 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..95abf53 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..8d9289f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c0eebd5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8d9289f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..48bc077 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a454df2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..48bc077 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c9d205c --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #B9CFDA + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..99fe291 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 1Helm + 1Helm + com.gitcommit90.onehelm.mobile + com.gitcommit90.onehelm.mobile + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..2d4726a --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..0c8c255 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..b648f20 --- /dev/null +++ b/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/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..fdbf680 --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,21 @@ +// 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/@capacitor/android/capacitor') + +include ':aparajita-capacitor-secure-storage' +project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/@aparajita/capacitor-secure-storage/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-browser' +project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') + +include ':capacitor-splash-screen' +project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/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/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/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/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/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/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/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/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/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/capacitor.config.json b/capacitor.config.json new file mode 100644 index 0000000..679d899 --- /dev/null +++ b/capacitor.config.json @@ -0,0 +1,53 @@ +{ + "appId": "com.gitcommit90.onehelm.mobile", + "appName": "1Helm", + "webDir": "public", + "loggingBehavior": "none", + "backgroundColor": "#08090c", + "zoomEnabled": false, + "appendUserAgent": " 1HelmMobile", + "server": { + "hostname": "localhost", + "iosScheme": "capacitor", + "androidScheme": "https", + "cleartext": false + }, + "ios": { + "contentInset": "never", + "preferredContentMode": "mobile", + "allowsLinkPreview": false, + "webContentsDebuggingEnabled": false, + "buildOptions": { + "signingStyle": "automatic", + "exportMethod": "app-store-connect" + } + }, + "android": { + "allowMixedContent": false, + "webContentsDebuggingEnabled": false, + "minWebViewVersion": 83, + "useLegacyBridge": false, + "buildOptions": { + "releaseType": "APK", + "signingType": "apksigner" + } + }, + "plugins": { + "Keyboard": { + "resize": "native", + "resizeOnFullScreen": true + }, + "SplashScreen": { + "launchShowDuration": 1200, + "launchAutoHide": true, + "backgroundColor": "#08090c", + "showSpinner": false, + "androidScaleType": "CENTER_CROP" + }, + "StatusBar": { + "overlaysWebView": true, + "style": "DEFAULT", + "backgroundColor": "#08090c" + } + } +} diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 20c0ea8..810da72 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -38,6 +38,7 @@ npm run typecheck npm run build npm test npm run test:onboarding-browser +ANDROID_SDK_ROOT= npm run mobile:check git diff --check git status --short ``` @@ -70,12 +71,13 @@ git push origin "refs/tags/v${VERSION}" HEADLESS="dist/1Helm-${VERSION}-linux-node.tgz" DMG="dist/1Helm-${VERSION}-arm64.dmg" UPDATE_ZIP="dist/1Helm-${VERSION}-mac-arm64.zip" +ANDROID_APK="dist/1Helm-${VERSION}-universal.apk" RELEASE_NOTES="dist/1Helm-${VERSION}-release-notes.md" # Author RELEASE_NOTES from docs/release-notes-template.md. It must contain the # complete numbered acceptance ledger, artifact digests, and verification. test -s "$RELEASE_NOTES" rg -q '^1\. ' "$RELEASE_NOTES" # multi-item ships must retain a numbered ledger -gh release create "v${VERSION}" "$DMG" "$UPDATE_ZIP" "$HEADLESS" --title "1Helm ${VERSION}" --notes-file "$RELEASE_NOTES" --draft +gh release create "v${VERSION}" "$DMG" "$UPDATE_ZIP" "$HEADLESS" "$ANDROID_APK" --title "1Helm ${VERSION}" --notes-file "$RELEASE_NOTES" --draft # review notes, then: gh release edit "v${VERSION}" --draft=false ``` @@ -86,6 +88,22 @@ body must lead with the complete user-visible acceptance ledger. Before publication, compare the notes item-by-item with the originating request and the versioned `CHANGELOG.md` entry. +### Mobile release gates + +- Build Android only with the retained external production key and properties + file. Back up that key independently, never commit either file or any + password, and never replace the key: Android updates require the same + certificate forever. +- Verify the universal APK with `zipalign`, `apksigner`, package/version + inspection, release-certificate SHA-256, and an install/update smoke on a + device or emulator. Upload the APK SHA-256 and certificate fingerprint in + the release evidence. +- Build iOS with full Xcode from the exact merged source, archive for generic + iOS using automatic App Store signing, validate the archive/IPA, then upload + it to App Store Connect or TestFlight. A public GitHub IPA is not a substitute + for Apple distribution. Record the App Store build number and validation or + upload result. + ## 6. Local verify ```bash @@ -129,5 +147,7 @@ Local setup: needs_setup verified on clean CTRL_DATA_DIR Clean deploy: Mac host update: Linux update: +Android: +iOS: CI: Actions green on main ``` diff --git a/docs/release-lifecycle.md b/docs/release-lifecycle.md index 5d98584..fb6c7c5 100644 --- a/docs/release-lifecycle.md +++ b/docs/release-lifecycle.md @@ -22,6 +22,7 @@ Process contract from intent to verified deploy. Commands: [release-checklist.md │ v full numbered notes · tag · signed DMG + updater ZIP + Linux host artifact + + signed universal APK + App Store mobile archive │ v verify (local health + clean install + public artifact) @@ -85,9 +86,11 @@ Draft PRs are allowed for long slices; mark ready only when the quality bar is m user-visible item must appear once, with the same numbering as the request when available. Include additional fixes, artifacts/digests, and verification evidence in their own sections. -6. Publish the verified DMG, native updater ZIP, Linux host artifact, and those - complete release notes through one GitHub Release. Do not use GitHub's - generated notes as the sole or primary body. +6. Publish the verified DMG, native updater ZIP, Linux host artifact, any + directly distributed signed Android APK, and those complete release notes + through one GitHub Release. Submit an iOS build through App Store Connect + rather than publishing an installable IPA as a generic download. Do not use + GitHub's generated notes as the sole or primary body. ## 7. Deploy diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..f470299 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6074b23 --- /dev/null +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,380 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + A1E100012026072600000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A1E100012026072600000002 /* PrivacyInfo.xcprivacy */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A1E100012026072600000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + A1E100012026072600000002 /* PrivacyInfo.xcprivacy */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + A1E100012026072600000001 /* PrivacyInfo.xcprivacy in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 13; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.0.13; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; + PRODUCT_NAME = 1Helm; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 13; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.0.13; + PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; + PRODUCT_NAME = 1Helm; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000..c3cd83b --- /dev/null +++ b/ios/App/App/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..e079a2e Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9b7d382 --- /dev/null +++ b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/App/Assets.xcassets/Contents.json b/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 0000000..d7d96a6 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000..3456817 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000..3456817 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000..3456817 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/ios/App/App/Base.lproj/LaunchScreen.storyboard b/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..e7ae5d7 --- /dev/null +++ b/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 0000000..b44df7b --- /dev/null +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist new file mode 100644 index 0000000..8d25952 --- /dev/null +++ b/ios/App/App/Info.plist @@ -0,0 +1,70 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + 1Helm + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.gitcommit90.onehelm.mobile + CFBundleURLSchemes + + onehelm + + + + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + ITSAppUsesNonExemptEncryption + + NSMicrophoneUsageDescription + 1Helm uses the microphone only when you choose dictation in a message composer. + NSSpeechRecognitionUsageDescription + 1Helm converts speech to text only when you choose dictation in a message composer. + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/App/App/PrivacyInfo.xcprivacy b/ios/App/App/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5397adc --- /dev/null +++ b/ios/App/App/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/ios/App/CapApp-SPM/.gitignore b/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 0000000..d6337be --- /dev/null +++ b/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.2"), + .package(name: "AparajitaCapacitorSecureStorage", path: "../../../node_modules/@aparajita/capacitor-secure-storage"), + .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorBrowser", path: "../../../node_modules/@capacitor/browser"), + .package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"), + .package(name: "CapacitorSplashScreen", path: "../../../node_modules/@capacitor/splash-screen"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "AparajitaCapacitorSecureStorage", package: "AparajitaCapacitorSecureStorage"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), + .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), + .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") + ] + ) + ] +) diff --git a/ios/App/CapApp-SPM/README.md b/ios/App/CapApp-SPM/README.md new file mode 100644 index 0000000..03964db --- /dev/null +++ b/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 0000000..945afec --- /dev/null +++ b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/ios/debug.xcconfig b/ios/debug.xcconfig new file mode 100644 index 0000000..53ce18d --- /dev/null +++ b/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/package-lock.json b/package-lock.json index cce927d..199def0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,24 @@ { "name": "1helm", - "version": "0.0.12", + "version": "0.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.12", - "license": "AGPL-3.0-only", + "version": "0.0.13", "hasInstallScript": true, + "license": "AGPL-3.0-only", "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor/android": "^8.4.2", + "@capacitor/app": "^8.0.1", + "@capacitor/browser": "^8.0.1", + "@capacitor/core": "^8.4.2", + "@capacitor/ios": "^8.4.2", + "@capacitor/keyboard": "^8.0.1", + "@capacitor/splash-screen": "^8.0.1", + "@capacitor/status-bar": "^8.0.1", "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", "@opencoredev/loginwithchatgpt-server": "^0.2.0", "node-pty": "^1.1.0", @@ -17,6 +26,7 @@ "ws": "^8.21.1" }, "devDependencies": { + "@capacitor/cli": "^8.4.2", "@cloudflare/workers-types": "^5.20260722.1", "@electron/osx-sign": "2.6.0", "@electron/packager": "20.0.3", @@ -35,12 +45,211 @@ "wrangler": "^4.113.0" } }, + "node_modules/@aparajita/capacitor-secure-storage": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@aparajita/capacitor-secure-storage/-/capacitor-secure-storage-8.0.0.tgz", + "integrity": "sha512-oYnwSjdIh23aRNgz8982+TmFvQH/2yZkEdw1iIg+H2ziFJoOVELPTc7u6Ez2HwOuDIW5AGqBX75GvrzQ+D70Qg==", + "license": "MIT", + "dependencies": { + "@capacitor/android": "^8.0.2", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.0.2", + "@capacitor/ios": "^8.0.2", + "@capacitor/keyboard": "^8.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.13.0.tgz", "integrity": "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@capacitor/android": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.2.tgz", + "integrity": "sha512-bzf8XZ9C+ePgOQ+e+T0fnhHGH4aCNY494vvSQIF5w4AY07RwEmkFqZs1678DDSMH0hLSQpxEwXGFNUVcXO6bBA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.4.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.1.tgz", + "integrity": "sha512-yeG3yyA0ETKqvgqexwHMBlmVOF13A1hRXzv/km0Ptv5TrNIZvZJK4MTI3uiqvnbHrzoJGP5DwWAjEXEfi90v3Q==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/browser": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.1.tgz", + "integrity": "sha512-cCEYK9DGJvamfCKOh95QP+VafApblySDR/eodOcl1Qt+d/Y+NNPzc8NYm/dztQlRf3aMLJ7ne1HrRCfu2UOMnw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.4.2.tgz", + "integrity": "sha512-hE4HmpMdr5T8eQ++kGa1BKkdG2JLEvo1y1cj1kmFK78VB9dBp538oe1EZvzwupNGOl0ZueALotzL5wcXRD9w1Q==", + "dev": true, + "license": "MIT", + "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.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@capacitor/cli/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@capacitor/cli/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@capacitor/cli/node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.2.tgz", + "integrity": "sha512-fQPRb3JXRaU2pnufDUvqOjhsrYxDQeFRZyaj/sHydIUxD2NxOGHKMpmKUdI+U4OOmKuGZyvRpi7TSJU+7Bjbmg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.2.tgz", + "integrity": "sha512-ClVzIjK++yRjsRPOpdEzhsZfbHfoEc0f4M6vD0v7sWh0qcoF6NkxNAeHpWTn4OPJpJfpGGoYsq+IfEHrYyBiWA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.4.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.1.tgz", + "integrity": "sha512-HDf4qrvvhLRMsgBoqeqIld6hP8JMK/WPbCYMvz8ajhY6TaibYt6B+NQyky4oIPCOfHTz5OcVsuHkbb8fQvGDAg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/splash-screen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-8.0.1.tgz", + "integrity": "sha512-c/ew/Z3eA7z8l06WoRAtzVF16VwYYrExmHmfGq1Cg675pVzaC/yuucB8/1xG1vhEfnW4fZ1KhSf/kzR1RiVYgg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz", + "integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -1470,6 +1679,269 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-fs/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@ionic/utils-fs/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "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.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ionic/utils-terminal/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2890,6 +3362,16 @@ "node": ">= 20" } }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -2899,6 +3381,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3308,6 +3797,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3354,6 +3863,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3389,6 +3908,19 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -3440,6 +3972,16 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -3680,6 +4222,16 @@ "node": ">=4.0.0" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -3956,6 +4508,19 @@ "dev": true, "license": "MIT" }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -4115,6 +4680,16 @@ "node": ">=6" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4389,6 +4964,22 @@ "license": "ISC", "optional": true }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4431,6 +5022,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -4937,6 +5541,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -4998,6 +5615,42 @@ "license": "MIT", "optional": true }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/native-run/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/nice-grpc": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/nice-grpc/-/nice-grpc-2.1.16.tgz", @@ -5069,6 +5722,24 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/open-graph-scraper": { "version": "6.12.0", "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.12.0.tgz", @@ -5084,6 +5755,13 @@ "node": ">=20.0.0" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5199,6 +5877,13 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5357,6 +6042,30 @@ "node": ">=10" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/protobufjs": { "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", @@ -5451,8 +6160,8 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -5570,6 +6279,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -5584,8 +6294,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -5593,6 +6302,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -5689,6 +6405,13 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -5736,6 +6459,47 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5907,12 +6671,22 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -6008,6 +6782,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", @@ -6038,6 +6829,16 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", @@ -6052,6 +6853,16 @@ "node": ">=6.0.0" } }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6065,6 +6876,16 @@ "node": ">=8.0" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-error": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/ts-error/-/ts-error-1.0.6.tgz", @@ -6075,9 +6896,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -6169,12 +6988,22 @@ "node": ">= 4.0.0" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/vcf": { "version": "2.1.2", @@ -6346,6 +7175,30 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -6365,6 +7218,16 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", @@ -6393,6 +7256,17 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/package.json b/package.json index 2b35222..719076a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.12", + "version": "0.0.13", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -13,6 +13,7 @@ "main": "desktop/main.cjs", "scripts": { "assets:brand": "node scripts/generate-brand-assets.cjs", + "assets:mobile": "node scripts/generate-mobile-assets.mjs", "assets:screenshots": "node scripts/capture-user-guide-screenshots.mjs && node scripts/generate-readme-assets.cjs", "assets:readme": "node scripts/generate-readme-assets.cjs", "postinstall": "node scripts/ensure-node-pty-helper.cjs", @@ -20,6 +21,8 @@ "build:css": "tailwindcss -i src/client/styles.css -o public/app.css --minify", "build:stamp": "node src/build/stamp.mjs", "build": "npm run build:js && npm run build:css && npm run build:stamp", + "mobile:sync": "node scripts/sync-mobile-version.mjs && npm run assets:mobile && npm run build && cap sync", + "mobile:check": "npm run typecheck && node --test test/mobile.mjs && cap doctor android", "start": "node --disable-warning=ExperimentalWarning src/server/index.ts", "dev": "npm run build && npm start", "watch:js": "esbuild src/client/app.ts --bundle --format=esm --outfile=public/bundle.js --loader:.css=css --watch", @@ -46,10 +49,21 @@ "package:linux": "node scripts/package-linux-host.mjs", "package:windows": "node scripts/package-windows.cjs", "package:windows:release": "set HELM_REQUIRE_WINDOWS_SIGNATURE=1&& node scripts/package-windows.cjs", + "package:android:release": "node scripts/package-android-apk.mjs", + "package:ios:release": "node scripts/package-ios-ipa.mjs", "test:desktop": "node --test test/desktop.mjs", "test:channel-computers:mac": "node scripts/mac-channel-computer-acceptance.mjs" }, "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor/android": "^8.4.2", + "@capacitor/app": "^8.0.1", + "@capacitor/browser": "^8.0.1", + "@capacitor/core": "^8.4.2", + "@capacitor/ios": "^8.4.2", + "@capacitor/keyboard": "^8.0.1", + "@capacitor/splash-screen": "^8.0.1", + "@capacitor/status-bar": "^8.0.1", "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", "@opencoredev/loginwithchatgpt-server": "^0.2.0", "node-pty": "^1.1.0", @@ -57,6 +71,7 @@ "ws": "^8.21.1" }, "devDependencies": { + "@capacitor/cli": "^8.4.2", "@cloudflare/workers-types": "^5.20260722.1", "@electron/osx-sign": "2.6.0", "@electron/packager": "20.0.3", diff --git a/public/index.html b/public/index.html index 9d3bad2..64b022a 100644 --- a/public/index.html +++ b/public/index.html @@ -34,6 +34,6 @@

- + diff --git a/scripts/generate-mobile-assets.mjs b/scripts/generate-mobile-assets.mjs new file mode 100644 index 0000000..bc018fe --- /dev/null +++ b/scripts/generate-mobile-assets.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { mkdir, readdir } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import sharp from "sharp"; + +const root = resolve(import.meta.dirname, ".."); +const source = join(root, "desktop", "icons", "1helm-macos-app-logo.jpg"); +const dark = { r: 8, g: 9, b: 12, alpha: 1 }; + +async function icon(target, size) { + await mkdir(dirname(target), { recursive: true }); + await sharp(source).resize(size, size, { fit: "cover" }).png({ compressionLevel: 9 }).toFile(target); +} + +async function splash(target, width, height) { + const markSize = Math.round(Math.min(width, height) * 0.34); + const mark = await sharp(source) + .resize(markSize, markSize, { fit: "cover" }) + .png({ compressionLevel: 9 }) + .toBuffer(); + await mkdir(dirname(target), { recursive: true }); + await sharp({ create: { width, height, channels: 4, background: dark } }) + .composite([{ input: mark, left: Math.round((width - markSize) / 2), top: Math.round((height - markSize) / 2) }]) + .png({ compressionLevel: 9 }) + .toFile(target); +} + +const androidRes = join(root, "android", "app", "src", "main", "res"); +const densities = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 }; +for (const [density, size] of Object.entries(densities)) { + const folder = join(androidRes, `mipmap-${density}`); + await icon(join(folder, "ic_launcher.png"), size); + await icon(join(folder, "ic_launcher_round.png"), size); + await icon(join(folder, "ic_launcher_foreground.png"), Math.round(size * 2.25)); +} + +const androidSplashes = { + "drawable/splash.png": [480, 320], + "drawable-land-mdpi/splash.png": [480, 320], + "drawable-land-hdpi/splash.png": [800, 480], + "drawable-land-xhdpi/splash.png": [1280, 720], + "drawable-land-xxhdpi/splash.png": [1600, 960], + "drawable-land-xxxhdpi/splash.png": [1920, 1280], + "drawable-port-mdpi/splash.png": [320, 480], + "drawable-port-hdpi/splash.png": [480, 800], + "drawable-port-xhdpi/splash.png": [720, 1280], + "drawable-port-xxhdpi/splash.png": [960, 1600], + "drawable-port-xxxhdpi/splash.png": [1280, 1920], +}; +for (const [relative, [width, height]] of Object.entries(androidSplashes)) await splash(join(androidRes, relative), width, height); + +const iosAssets = join(root, "ios", "App", "App", "Assets.xcassets"); +await icon(join(iosAssets, "AppIcon.appiconset", "AppIcon-512@2x.png"), 1024); +for (const filename of await readdir(join(iosAssets, "Splash.imageset"))) { + if (filename.endsWith(".png")) await splash(join(iosAssets, "Splash.imageset", filename), 2732, 2732); +} + +console.log("Generated branded iOS and Android icons and launch screens."); diff --git a/scripts/package-android-apk.mjs b/scripts/package-android-apk.mjs new file mode 100644 index 0000000..b8ae53a --- /dev/null +++ b/scripts/package-android-apk.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { copyFile, mkdir, readFile, readdir, rename, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { delimiter, join, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")); +const version = String(pkg.version || ""); +const canonical = join(root, "dist", `1Helm-${version}-universal.apk`); +const candidate = `${canonical}.candidate`; +const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || ""; +const signing = process.env.HELM_ANDROID_SIGNING_PROPERTIES || ""; +const releaseCertificateSha256 = "7b2d96ab21a242f9b17ddc7c65d133033bb9f0322158b6aab57bf8d46a7d27bf"; +if (!sdkRoot) throw new Error("ANDROID_SDK_ROOT is required for a release APK."); +if (!signing) throw new Error("HELM_ANDROID_SIGNING_PROPERTIES is required for a signed release APK."); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { cwd: root, stdio: "inherit", env: process.env, ...options }); + if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status ?? "unknown"}.`); +} +function capture(command, args) { + const result = spawnSync(command, args, { cwd: root, encoding: "utf8", env: process.env }); + if (result.status !== 0) throw new Error(result.stderr || `${command} failed.`); + return String(result.stdout || ""); +} +const buildTools = join(sdkRoot, "build-tools"); +const latest = (await readdir(buildTools)).filter((name) => /^\d+(?:\.\d+)+$/.test(name)).sort((left, right) => left.localeCompare(right, undefined, { numeric: true })).at(-1) || ""; +if (!latest) throw new Error("Android SDK Build Tools are not installed."); +const apksigner = join(buildTools, latest, "apksigner"); +const zipalign = join(buildTools, latest, "zipalign"); + +await mkdir(join(root, "dist"), { recursive: true }); +await rm(candidate, { force: true }); +run("npm", ["run", "mobile:sync"]); +run(join(root, "android", "gradlew"), ["--no-daemon", "assembleRelease"], { cwd: join(root, "android") }); +const built = join(root, "android", "app", "build", "outputs", "apk", "release", "app-release.apk"); +await copyFile(built, candidate); +run(zipalign, ["-c", "-P", "16", "-v", "4", candidate]); +const signature = capture(apksigner, ["verify", "--verbose", "--print-certs", candidate]); +process.stdout.write(signature); +const signerDigest = signature.match(/Signer #1 certificate SHA-256 digest:\s*([0-9a-f]+)/i)?.[1]?.toLowerCase() || ""; +if (signerDigest !== releaseCertificateSha256) throw new Error(`APK signer ${signerDigest || "is missing"}; expected the permanent 1Helm release certificate.`); + +const analyzer = join(sdkRoot, "cmdline-tools", "latest", "bin", "apkanalyzer"); +const badging = capture(analyzer, ["manifest", "application-id", candidate]).trim(); +if (badging !== "com.gitcommit90.onehelm.mobile") throw new Error(`APK has unexpected application ID ${badging}.`); +const apkVersion = capture(analyzer, ["manifest", "version-name", candidate]).trim(); +if (apkVersion !== version) throw new Error(`APK has version ${apkVersion}, expected ${version}.`); +const entries = capture("unzip", ["-Z1", candidate]); +if (/(^|\n)(?:AGENTS\.md|.*\/AGENTS\.md)(?:\n|$)/.test(entries)) throw new Error("Host-local AGENTS.md instructions leaked into the APK."); +const nativeAbis = [...entries.matchAll(/^lib\/([^/]+)\//gm)].map((entry) => entry[1]); +const uniqueAbis = [...new Set(nativeAbis)]; +if (uniqueAbis.length && !["arm64-v8a", "armeabi-v7a", "x86", "x86_64"].every((abi) => uniqueAbis.includes(abi))) { + throw new Error(`APK is not universal; native libraries cover only: ${uniqueAbis.join(", ")}.`); +} +await rm(canonical, { force: true }); +await rename(candidate, canonical); +console.log(`Verified universal APK: ${canonical}`); diff --git a/scripts/package-ios-ipa.mjs b/scripts/package-ios-ipa.mjs new file mode 100644 index 0000000..37bed4d --- /dev/null +++ b/scripts/package-ios-ipa.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { mkdtemp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +if (process.platform !== "darwin") throw new Error("The iOS release must be built on macOS with full Xcode."); +const root = resolve(import.meta.dirname, ".."); +const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")); +const version = String(pkg.version || ""); +const team = process.env.APPLE_TEAM_ID || process.env.REROUTED_TEAM_ID || ""; +if (!team) throw new Error("APPLE_TEAM_ID is required for an App Store-signed iOS release."); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { cwd: root, stdio: "inherit", env: process.env, ...options }); + if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status ?? "unknown"}.`); +} +const xcode = spawnSync("xcodebuild", ["-version"], { encoding: "utf8" }); +if (xcode.status !== 0 || !/^Xcode /m.test(xcode.stdout || "")) throw new Error("Full Xcode is required; Command Line Tools alone cannot archive iOS apps."); + +const scratch = await mkdtemp(join(tmpdir(), `1helm-ios-${version}-`)); +const archive = join(scratch, `1Helm-${version}.xcarchive`); +const exported = join(scratch, "export"); +const options = join(scratch, "ExportOptions.plist"); +const canonical = join(root, "dist", `1Helm-${version}-ios.ipa`); +const candidate = `${canonical}.candidate`; +try { + await mkdir(join(root, "dist"), { recursive: true }); + await rm(candidate, { force: true }); + run("npm", ["run", "mobile:sync"]); + run("xcodebuild", [ + "-project", "ios/App/App.xcodeproj", "-scheme", "App", "-configuration", "Release", + "-destination", "generic/platform=iOS", "-archivePath", archive, + `DEVELOPMENT_TEAM=${team}`, "CODE_SIGN_STYLE=Automatic", "-allowProvisioningUpdates", "archive", + ]); + await writeFile(options, `\n\nmethodapp-store-connectsigningStyleautomaticteamID${team}manageAppVersionAndBuildNumberstripSwiftSymbolsuploadSymbols\n`, { mode: 0o600 }); + run("xcodebuild", ["-exportArchive", "-archivePath", archive, "-exportPath", exported, "-exportOptionsPlist", options, "-allowProvisioningUpdates"]); + const ipaName = (await readdir(exported)).find((name) => name.endsWith(".ipa")); + if (!ipaName) throw new Error("Xcode did not produce an IPA."); + await rename(join(exported, ipaName), candidate); + const entries = spawnSync("unzip", ["-Z1", candidate], { encoding: "utf8" }); + if (entries.status !== 0 || /(^|\n)(?:AGENTS\.md|.*\/AGENTS\.md)(?:\n|$)/.test(entries.stdout || "")) throw new Error("The IPA is invalid or contains host-local instructions."); + await rm(canonical, { force: true }); + await rename(candidate, canonical); + console.log(`Verified App Store IPA: ${canonical}`); +} finally { + await rm(candidate, { force: true }); + await rm(scratch, { recursive: true, force: true }); +} diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 51369ff..f6219b1 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -47,7 +47,7 @@ const suites = [ "test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs", "test/feedback.mjs", "test/feedback-browser.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", "test/release-license.mjs", "test/release-governance.mjs", "test/channel-surfaces.mjs", "test/workspace-interactions.mjs", "test/sweep-fleet-telemetry.mjs", "test/sweep-server-integration.mjs", "test/thread-followup-chat.mjs", - "test/notifications.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/web-research.mjs", "test/workflows.mjs"], + "test/notifications.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/mobile.mjs", "test/web-research.mjs", "test/workflows.mjs"], ]; let status = 0; diff --git a/scripts/sync-mobile-version.mjs b/scripts/sync-mobile-version.mjs new file mode 100644 index 0000000..d29265c --- /dev/null +++ b/scripts/sync-mobile-version.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); +const version = String(pkg.version || ""); +const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); +if (!match) throw new Error(`Mobile releases require a three-part semantic package version; received ${version || "empty"}.`); +const build = Number(match[1]) * 1_000_000 + Number(match[2]) * 1_000 + Number(match[3]); +if (!Number.isSafeInteger(build) || build < 1) throw new Error("The package version cannot be represented as a native build number."); + +const projectPath = resolve(root, "ios", "App", "App.xcodeproj", "project.pbxproj"); +let project = await readFile(projectPath, "utf8"); +project = project.replace(/CURRENT_PROJECT_VERSION = \d+;/g, `CURRENT_PROJECT_VERSION = ${build};`); +project = project.replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${version};`); +await writeFile(projectPath, project); +console.log(`Synced native mobile version ${version} (build ${build}).`); diff --git a/src/client/api.ts b/src/client/api.ts index 3a44703..ac4f339 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -134,8 +134,9 @@ export type Collaboration = { export type AccessRequest = { id: number; email: string; display: string; status: "pending" | "approved" | "denied" | "claimed"; requested_at: number; reviewed_at: number | null }; export type Workspace = { name: string; terminals_enabled: boolean; setup_complete: boolean; photo_url: string | null; theme: "graphite" | "ocean" | "forest" | "ember" | "plum" }; -let token = localStorage.getItem("ctrl.token") || ""; +let token = ""; export const getToken = (): string => token; +export async function initializeApiTransport(): Promise { token = await initializeMobileRuntime(); } /** Workspace photo is auth-gated; cannot send Bearer — attach session token. */ export function workspacePhotoSrc(photoUrl: string | null | undefined, cacheBust?: string | number): string { @@ -145,13 +146,14 @@ export function workspacePhotoSrc(photoUrl: string | null | undefined, cacheBust if (t) params.set("token", t); if (cacheBust != null && cacheBust !== "") params.set("v", String(cacheBust)); const q = params.toString(); - return q ? `${photoUrl}${photoUrl.includes("?") ? "&" : "?"}${q}` : photoUrl; + const source = serverAssetUrl(photoUrl); + return q ? `${source}${source.includes("?") ? "&" : "?"}${q}` : source; } -export const setToken = (t: string): void => { token = t; localStorage.setItem("ctrl.token", t); }; -export const clearToken = (): void => { token = ""; localStorage.removeItem("ctrl.token"); }; +export const setToken = async (t: string): Promise => { token = t; await persistSecureSession(t); }; +export const clearToken = async (): Promise => { token = ""; await removeSecureSession(); }; export async function api(path: string, opts: { method?: string; body?: unknown; headers?: Record } = {}): Promise { - const res = await fetch(path, { + const res = await fetch(apiUrl(path), { method: opts.method || (opts.body !== undefined ? "POST" : "GET"), headers: { ...(opts.body !== undefined ? { "content-type": "application/json" } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, @@ -194,7 +196,7 @@ export async function openAuthenticatedFile(path: string): Promise { } async function authenticatedFile(path: string): Promise<{ blob: Blob; name: string }> { - const response = await fetch(path, { headers: token ? { authorization: `Bearer ${token}` } : {} }); + const response = await fetch(apiUrl(path), { headers: token ? { authorization: `Bearer ${token}` } : {} }); if (!response.ok) throw new Error((await response.json().catch(() => ({})) as { error?: string }).error || `HTTP ${response.status}`); const disposition = response.headers.get("content-disposition") || ""; const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; @@ -215,7 +217,7 @@ export async function downloadAuthenticatedFile(path: string, preferredName?: st } export async function uploadFile(file: File): Promise<{ token: string; name: string; mime: string; size: number }> { - const res = await fetch("/api/upload", { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": file.type || "application/octet-stream", "x-filename": encodeURIComponent(file.name) }, body: file }); + const res = await fetch(apiUrl("/api/upload"), { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": file.type || "application/octet-stream", "x-filename": encodeURIComponent(file.name) }, body: file }); const data = await res.json(); return { token: data.token, name: file.name, mime: file.type || "application/octet-stream", size: file.size }; } @@ -228,13 +230,16 @@ export type EventSocketHooks = { /** Single app-event socket with auto-reconnect. onOpen fires on every successful (re)connect so the UI can resync. */ export function connectEvents(onMessage: Handler, hooks: EventSocketHooks = {}): WebSocket { - const proto = location.protocol === "https:" ? "wss" : "ws"; - const ws = new WebSocket(`${proto}://${location.host}/ws?token=${token}`); + const socketToken = token; + const ws = new WebSocket(serverWebSocketUrl(`/ws?token=${encodeURIComponent(token)}`)); ws.onmessage = (e) => { try { onMessage(JSON.parse(e.data)); } catch { /* ignore */ } }; ws.onopen = () => { hooks.onOpen?.(); }; ws.onclose = () => { hooks.onClose?.(); - setTimeout(() => connectEvents(onMessage, hooks), 1500); + if (socketToken && token === socketToken) setTimeout(() => { + if (token === socketToken) connectEvents(onMessage, hooks); + }, 1500); }; return ws; } +import { apiUrl, initializeMobileRuntime, persistSecureSession, removeSecureSession, serverAssetUrl, serverWebSocketUrl } from "./mobile.ts"; diff --git a/src/client/app.ts b/src/client/app.ts index 6b15efb..298a716 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -1,4 +1,4 @@ -import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadFollowup, type ThreadUsage, type RoutingModel } from "./api.ts"; +import { api, downloadAuthenticatedFile, initializeApiTransport, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadFollowup, type ThreadUsage, type RoutingModel } from "./api.ts"; import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, icon, helmMark, type ChannelLink } from "./dom.ts"; import { openSettings, finishOpenRouterOAuth, refreshOpenSkillsSettings } from "./settings.ts"; import { hydrateNotificationPreferences, playNotification } from "./notifications.ts"; @@ -6,6 +6,7 @@ import { openRoutingPopover, pushRoutingActivity } from "./routing.ts"; import { openOnboarding } from "./onboarding.ts"; import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTerminalChrome } from "./term.ts"; import { openCreateChannel, renderActivity, renderBoard, renderChannelSettings, renderFiles, renderGlobalThreads, renderMemory, renderNotes, renderTexts, renderThreads, type ChannelView } from "./channel.ts"; +import { apiUrl, forgetMobileServer, getServerOrigin, isNativeMobile, serverAssetUrl, setMobileServer } from "./mobile.ts"; /** Per-channel layout bound to the user profile (server user_ui_state). */ type ChannelUiView = { @@ -173,18 +174,21 @@ function writeRoute(channel: Channel | undefined, view: ChannelView, threadRootI // ---------------- boot ---------------- export async function boot(): Promise { + // The native clients are gateways to an existing 1Helm. They never expose + // the host setup wizard, even if a remembered address points at a fresh host. + if (isNativeMobile() && !getToken()) return renderAuth(); const setup = await api<{ needs_setup: boolean; has_users: boolean; setup_complete: boolean; workspace: Workspace }>("/api/setup/status").catch(() => null); - if (setup && !setup.has_users) return openOnboarding(root, { resume: false, onDone: () => boot() }); + if (!isNativeMobile() && setup && !setup.has_users) return openOnboarding(root, { resume: false, onDone: () => boot() }); if (!getToken()) return renderAuth(); try { const me = await api<{ user: User; workspace: Workspace }>("/api/me"); S.me = me.user; S.workspace = me.workspace; - } catch { clearToken(); return renderAuth(); } + } catch { await clearToken(); return renderAuth(); } // Complete OpenRouter OAuth before loading the workspace so a failed // channel/provider fetch cannot swallow the callback. const oauth = await finishOpenRouterOAuth(); - if (!S.workspace.setup_complete && S.me.is_admin) { + if (!isNativeMobile() && !S.workspace.setup_complete && S.me.is_admin) { return openOnboarding(root, { resume: true, resumeStep: oauth.connected ? 2 : 1, onDone: () => boot() }); } await enterWorkspace(); @@ -644,12 +648,40 @@ function applyMessageDeleted(e: { function renderAuth(): void { clear(root); const err = h("p", { class: "min-h-5 text-sm text-danger" }); + const server = h("input", { + class: "field", + type: "url", + inputmode: "url", + placeholder: "https://your-1helm-server.com", + autocomplete: "url", + autocapitalize: "none", + spellcheck: false, + value: getServerOrigin(), + }) as HTMLInputElement; const u = h("input", { class: "field", placeholder: "username", autocomplete: "username" }); const pw = h("input", { class: "field", type: "password", placeholder: "password", autocomplete: "current-password" }); + const submitButton = h("button", { class: "btn-primary w-full py-2.5" }, "Sign in") as HTMLButtonElement; const submit = async (): Promise => { err.textContent = ""; - try { const r = await api<{ token: string; user: User }>("/api/auth/login", { body: { username: u.value, password: pw.value } }); setToken(r.token); boot(); } - catch (e) { err.textContent = (e as Error).message; } + submitButton.disabled = true; + submitButton.textContent = isNativeMobile() ? "Connecting…" : "Signing in…"; + try { + const password = pw.value; + pw.value = ""; + if (isNativeMobile()) { + setMobileServer(server.value); + const compatibility = await api<{ product?: string; mobile_api?: number; has_users?: boolean; setup_complete?: boolean }>("/api/mobile/compatibility"); + if (compatibility.product !== "1Helm" || compatibility.mobile_api !== 1) throw new Error("That address is not a compatible 1Helm server."); + if (!compatibility.has_users || !compatibility.setup_complete) throw new Error("Finish setting up 1Helm on the host before connecting the mobile app."); + } + const r = await api<{ token: string; user: User }>("/api/auth/login", { body: { username: u.value, password } }); + await setToken(r.token); + await boot(); + } catch (e) { + err.textContent = (e as Error).message; + submitButton.disabled = false; + submitButton.textContent = "Sign in"; + } }; const access = h("div", { class: "space-y-3" }); root.append(h("div", { class: "auth-stage grid h-full place-items-center overflow-y-auto p-6" }, @@ -659,12 +691,13 @@ function renderAuth(): void { h("h1", { class: "mt-5 text-[2rem] font-bold leading-none tracking-[-0.03em] text-fg" }, "1Helm"), h("p", { class: "eyebrow mt-3 text-muted" }, "Native agent workspace")), h("div", { class: "card space-y-3 p-7" }, - h("div", { class: "mb-3" }, h("h2", { class: "font-display text-[1.55rem] leading-tight text-fg" }, "Enter the bridge"), h("p", { class: "mt-1.5 text-sm text-muted" }, "Sign in to your workspace.")), - u, pw, err, - h("button", { class: "btn-primary w-full py-2.5", onclick: submit }, "Sign in"), access)))); + h("div", { class: "mb-3" }, h("h2", { class: "font-display text-[1.55rem] leading-tight text-fg" }, isNativeMobile() ? "Connect to your 1Helm" : "Enter the bridge"), h("p", { class: "mt-1.5 text-sm text-muted" }, isNativeMobile() ? "Your 1Helm host must already be set up and reachable over HTTPS." : "Sign in to your workspace.")), + isNativeMobile() ? h("label", { class: "space-y-1 text-xs font-semibold text-fg" }, "Server address", server) : null, + u, pw, err, submitButton, isNativeMobile() ? h("p", { class: "text-center text-[11px] leading-5 text-faint" }, "Your password is sent only to this server for sign-in and is never saved. The session is stored in your device’s secure key store.") : access)))); + submitButton.onclick = () => { void submit(); }; pw.addEventListener("keydown", (ev) => { if ((ev as KeyboardEvent).key === "Enter") submit(); }); - u.focus(); - void renderAccessRequest(access); + (isNativeMobile() && !server.value ? server : u).focus(); + if (!isNativeMobile()) void renderAccessRequest(access); } async function renderAccessRequest(container: HTMLElement): Promise { @@ -695,7 +728,7 @@ async function renderAccessRequest(container: HTMLElement): Promise { const finish = async (): Promise => { try { const result = await api<{ token: string }>(`/api/access-requests/${claimToken}`, { body: { display: display.value, username: username.value, password: password.value } }); - localStorage.removeItem(key); setToken(result.token); void boot(); + localStorage.removeItem(key); await setToken(result.token); void boot(); } catch (error) { status.textContent = (error as Error).message; } }; container.append(h("div", { class: "space-y-2 border-t border-line pt-3" }, h("p", { class: "text-sm font-semibold text-fg" }, "You’re approved"), h("p", { class: "text-xs text-muted" }, "Create your local account for this workspace."), display, username, password, status, h("button", { class: "btn-primary w-full", onclick: () => { void finish(); } }, "Create account and sign in"))); @@ -1094,7 +1127,7 @@ function openProfile(anchor: HTMLElement): void { const image = await renderedPhoto(); if (image) { status.textContent = "Compressing and saving photo…"; - const response = await fetch("/api/me/avatar", { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": image.type }, body: image }); + const response = await fetch(apiUrl("/api/me/avatar"), { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": image.type }, body: image }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); acceptUser(result.user); @@ -1129,6 +1162,15 @@ function openProfile(anchor: HTMLElement): void { h("p", { class: "border-t border-line pt-3 text-xs leading-5 text-muted" }, "1Helm is AGPL-3.0-only. ", h("a", { class: "text-accent hover:underline", href: "https://github.com/gitcommit90/1Helm", target: "_blank", rel: "noopener noreferrer" }, "View source code ↗"))); + if (isNativeMobile()) pop.append(h("section", { class: "flex items-center justify-between gap-3 border-t border-line pt-3" }, + h("div", { class: "min-w-0" }, h("p", { class: "text-xs font-semibold text-fg" }, "Connected server"), h("p", { class: "truncate text-[11px] text-muted" }, getServerOrigin())), + h("button", { class: "btn-subtle min-h-9 shrink-0 px-3 text-xs", onclick: async () => { + await api("/api/auth/logout", { method: "POST" }).catch(() => undefined); + await forgetMobileServer(); + await clearToken(); + close(); + await boot(); + } }, "Disconnect"))); if (S.me.is_admin) pop.append(h("section", { class: "flex items-center justify-between gap-3 border-t border-line pt-3", dataset: { profileUpdate: "" } }, h("div", { class: "min-w-0" }, updateLabel, updateStatus), updateButton)); @@ -2356,7 +2398,7 @@ function attachments(m: Message): HTMLElement | null { if (!m.attachments?.length) return null; return h("div", { class: "mt-1.5 flex flex-wrap gap-2" }, ...m.attachments.map((a) => { const viewUrl = `/api/files/${a.id}`; - const mediaUrl = `${viewUrl}?token=${encodeURIComponent(getToken())}`; + const mediaUrl = `${serverAssetUrl(viewUrl)}?token=${encodeURIComponent(getToken())}`; const actions = h("div", { class: "flex items-center gap-1 border-t border-line/70 px-2 py-1.5" }, h("button", { class: "btn-subtle text-xs", type: "button", onclick: (event: MouseEvent) => { event.stopPropagation(); void openAuthenticatedFile(viewUrl).catch((error) => appAlert((error as Error).message)); } }, "Open"), h("button", { class: "btn-subtle text-xs", type: "button", onclick: (event: MouseEvent) => { event.stopPropagation(); void downloadAuthenticatedFile(`${viewUrl}?download=1`, a.name).catch((error) => appAlert((error as Error).message)); } }, "Download")); @@ -2988,7 +3030,7 @@ export function avatar(name: string, kind: "user" | "bot" | "system", size = 8, return h("img", { class: `${kind === "bot" ? "identity-bot" : "identity-user"} identity-photo rounded-md object-cover`, style: `width:${px}px;height:${px}px`, - src: avatarValue, + src: serverAssetUrl(avatarValue), alt: name, title: name, }); @@ -3064,6 +3106,7 @@ window.addEventListener("blur", () => { altTapOnly = false; }); window.matchMedia("(min-width: 768px)").addEventListener("change", (event) => { if (event.matches && S.mobileMenuOpen) closeMobileMenu(); }); function registerServiceWorker(): void { + if (isNativeMobile()) return; if (!("serviceWorker" in navigator)) return; void navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" }) .then((reg) => { @@ -3079,4 +3122,10 @@ function registerServiceWorker(): void { if (document.readyState === "complete") registerServiceWorker(); else window.addEventListener("load", registerServiceWorker, { once: true }); -boot(); +void initializeApiTransport().then(() => boot()).catch((error) => { + clear(root); + root.append(h("div", { class: "auth-stage grid h-full place-items-center p-6" }, h("section", { class: "card max-w-md space-y-3 p-6 text-center" }, + h("h1", { class: "font-display text-2xl text-fg" }, "1Helm could not open"), + h("p", { class: "text-sm leading-6 text-muted" }, (error as Error).message || "The device security store is unavailable."), + h("button", { class: "btn-primary", onclick: () => location.reload() }, "Try again")))); +}); diff --git a/src/client/mobile.ts b/src/client/mobile.ts new file mode 100644 index 0000000..cda41c5 --- /dev/null +++ b/src/client/mobile.ts @@ -0,0 +1,145 @@ +import { App } from "@capacitor/app"; +import { Browser } from "@capacitor/browser"; +import { Capacitor } from "@capacitor/core"; +import { Keyboard, KeyboardResize } from "@capacitor/keyboard"; +import { SecureStorage, KeychainAccess } from "@aparajita/capacitor-secure-storage"; +import { StatusBar, Style } from "@capacitor/status-bar"; + +const SESSION_KEY = "session"; +const SERVER_KEY = "1helm.mobile.server"; +const SERVER_PREFIX = "1helm_mobile_"; +const OPENROUTER_CALLBACK_PREFIX = "onehelm://openrouter"; + +type StoredSession = { server: string; token: string }; + +const native = Capacitor.isNativePlatform(); +let serverOrigin = ""; + +export const isNativeMobile = (): boolean => native; +export const mobilePlatform = (): string => native ? Capacitor.getPlatform() : "web"; +export const getServerOrigin = (): string => serverOrigin; + +export function normalizeServerOrigin(value: string): string { + const raw = value.trim(); + if (!raw) throw new Error("Enter your 1Helm server address."); + let parsed: URL; + try { parsed = new URL(raw.includes("://") ? raw : `https://${raw}`); } + catch { throw new Error("Enter a valid server address, such as https://helm.example.com."); } + if (parsed.protocol !== "https:") throw new Error("The mobile app connects only to HTTPS 1Helm servers."); + if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error("Use only the server address, without credentials, a query, or a fragment."); + const pathname = parsed.pathname.replace(/\/+$/, ""); + if (pathname && pathname !== "/") throw new Error("Use the root server address without an extra path."); + return parsed.origin; +} + +export function setMobileServer(value: string): string { + if (!native) return location.origin; + serverOrigin = normalizeServerOrigin(value); + localStorage.setItem(SERVER_KEY, serverOrigin); + return serverOrigin; +} + +export async function forgetMobileServer(): Promise { + if (!native) return; + await removeSecureSession(); + serverOrigin = ""; + localStorage.removeItem(SERVER_KEY); +} + +export function apiUrl(path: string): string { + if (!native || !path.startsWith("/")) return path; + if (!serverOrigin) throw new Error("Choose a 1Helm server before connecting."); + return `${serverOrigin}${path}`; +} + +export function serverAssetUrl(path: string): string { + if (!native || !path.startsWith("/")) return path; + return serverOrigin ? `${serverOrigin}${path}` : path; +} + +export function serverWebSocketUrl(path: string): string { + if (!native) { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${location.host}${path}`; + } + if (!serverOrigin) throw new Error("Choose a 1Helm server before connecting."); + const target = new URL(serverOrigin); + target.protocol = "wss:"; + target.pathname = path.split("?")[0] || "/"; + target.search = path.includes("?") ? path.slice(path.indexOf("?")) : ""; + return target.toString(); +} + +export async function initializeMobileRuntime(): Promise { + if (!native) return localStorage.getItem("ctrl.token") || ""; + document.documentElement.dataset.nativeMobile = mobilePlatform(); + serverOrigin = localStorage.getItem(SERVER_KEY) || ""; + try { if (serverOrigin) serverOrigin = normalizeServerOrigin(serverOrigin); } + catch { serverOrigin = ""; localStorage.removeItem(SERVER_KEY); } + + await SecureStorage.setKeyPrefix(SERVER_PREFIX); + if (mobilePlatform() === "ios") { + await SecureStorage.setSynchronize(false); + await SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly); + } + + await Promise.allSettled([ + StatusBar.setStyle({ style: Style.Default }), + StatusBar.setOverlaysWebView({ overlay: true }), + Keyboard.setResizeMode({ mode: KeyboardResize.Native }), + ]); + + await App.addListener("appUrlOpen", ({ url }) => { + const code = nativeCallbackCode(url); + if (!code) return; + void Browser.close().catch(() => undefined); + location.assign(`/?code=${encodeURIComponent(code)}`); + }); + const launchUrl = await App.getLaunchUrl().catch(() => undefined); + const launchCode = nativeCallbackCode(launchUrl?.url || ""); + if (launchCode) history.replaceState({}, "", `/?code=${encodeURIComponent(launchCode)}`); + + installExternalLinkHandling(); + const stored = await SecureStorage.get(SESSION_KEY).catch(() => null); + if (!stored || typeof stored !== "object") return ""; + const session = stored as StoredSession; + if (!session.token || !session.server || session.server !== serverOrigin) return ""; + return session.token; +} + +export async function persistSecureSession(token: string): Promise { + if (!native) { localStorage.setItem("ctrl.token", token); return; } + if (!serverOrigin) throw new Error("Choose a 1Helm server before signing in."); + await SecureStorage.set(SESSION_KEY, { server: serverOrigin, token }, true, false, KeychainAccess.whenUnlockedThisDeviceOnly); +} + +export async function removeSecureSession(): Promise { + if (!native) { localStorage.removeItem("ctrl.token"); return; } + await SecureStorage.remove(SESSION_KEY).catch(() => false); +} + +export async function openExternalUrl(url: string): Promise { + if (!native) { window.open(url, "_blank", "noopener,noreferrer"); return; } + await Browser.open({ url, presentationStyle: "fullscreen", toolbarColor: "#15171c" }); +} + +function installExternalLinkHandling(): void { + document.addEventListener("click", (event) => { + const anchor = (event.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null; + if (!anchor) return; + let destination: URL; + try { destination = new URL(anchor.href); } catch { return; } + if (!/^https?:$/.test(destination.protocol) || destination.origin === location.origin) return; + event.preventDefault(); + void openExternalUrl(destination.toString()); + }, true); +} + +function nativeCallbackCode(value: string): string { + try { + const callback = new URL(value); + if (`${callback.protocol}//${callback.host}` !== OPENROUTER_CALLBACK_PREFIX) return ""; + return callback.searchParams.get("code") || ""; + } + catch { return ""; } +} diff --git a/src/client/onboarding.ts b/src/client/onboarding.ts index b4cc491..1f0c52d 100644 --- a/src/client/onboarding.ts +++ b/src/client/onboarding.ts @@ -57,7 +57,7 @@ export function openOnboarding(root: HTMLElement, opts: WizardOptions): void { status.textContent = ""; setBusy(button, true, "Creating Captain…"); try { const result = await api<{ token: string }>("/api/auth/register", { body: { username: username.value, display: display.value, password: password.value } }); - setToken(result.token); step = 1; await render(); + await setToken(result.token); step = 1; await render(); } catch (error) { status.replaceChildren(h("div", { class: "wizard-status-err" }, (error as Error).message)); setBusy(button, false); } }; password.addEventListener("keydown", (event) => { if (event.key === "Enter") void submit(); }); diff --git a/src/client/routing.ts b/src/client/routing.ts index c911e21..5ab7068 100644 --- a/src/client/routing.ts +++ b/src/client/routing.ts @@ -9,6 +9,7 @@ import { type RoutingUsage, } from "./api.ts"; import { add, clear, h, icon, providerMark, timeLabel } from "./dom.ts"; +import { getServerOrigin, openExternalUrl } from "./mobile.ts"; type RoutingView = "sources" | "routes" | "activity" | "quota" | "logs" | "endpoint"; type Dialog = (message: string) => Promise; @@ -26,7 +27,7 @@ const providerCopy: Record = { }; const fmt = (value: unknown): string => new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(Number(value || 0)); -const publicEndpoint = (): string => `${location.origin}/v1`; +const publicEndpoint = (): string => `${getServerOrigin() || location.origin}/v1`; const providerFamily = (provider: RoutingProvider): string => provider.type === "codex" ? "chatgpt" : provider.type; const isCustom = (provider: RoutingProvider): boolean => ["custom", "openai-compat"].includes(provider.type); const routeMember = (provider: RoutingProvider, model: string): RoutingComboMember => isCustom(provider) @@ -263,7 +264,7 @@ async function openOauth(type: string, refresh: () => Promise, providerId? paste, h("button", { class: "btn-subtle min-h-11 w-full", onclick: () => { void finish(); } }, "Finish connection"), status)); document.body.append(modal); - window.open(started.authUrl, "_blank", "noopener,noreferrer"); + void openExternalUrl(started.authUrl); for (let attempt = 0; attempt < 240 && !closed; attempt++) { await new Promise((resolve) => setTimeout(resolve, 1_500)); if (closed) break; diff --git a/src/client/settings.ts b/src/client/settings.ts index 03423ea..6bfc91e 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -1,8 +1,9 @@ -import { api, getToken, workspacePhotoSrc, type AccessRequest, type ChannelRuntime, type Collaboration, type Computer, type Skill, type SkillCatalogResult, type SkillCatalogStatus, type User, type WorkspaceDomain } from "./api.ts"; +import { api, getToken, openAuthenticatedFile, workspacePhotoSrc, type AccessRequest, type ChannelRuntime, type Collaboration, type Computer, type Skill, type SkillCatalogResult, type SkillCatalogStatus, type User, type WorkspaceDomain } from "./api.ts"; import { h, clear, add, icon } from "./dom.ts"; import { S, avatar, reloadProviders, renderApp, appAlert, appConfirm, appPrompt } from "./app.ts"; import { connectRoutingOauth, routingPanel } from "./routing.ts"; import { globalNotificationsMuted, setGlobalNotificationsMuted } from "./notifications.ts"; +import { apiUrl, isNativeMobile, openExternalUrl } from "./mobile.ts"; // ============================================================ OpenRouter OAuth (PKCE) const b64url = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); @@ -19,8 +20,10 @@ export async function startOpenRouterOAuth(): Promise { // localStorage (not sessionStorage): the verifier must survive the full-page // round trip through openrouter.ai, which can land in a fresh session. localStorage.setItem(VERIFIER_KEY, JSON.stringify({ v: verifier, t: Date.now() })); - const callback = location.origin + location.pathname; - location.assign(`https://openrouter.ai/auth?callback_url=${encodeURIComponent(callback)}&code_challenge=${await pkceChallenge(verifier)}&code_challenge_method=S256`); + const callback = isNativeMobile() ? "onehelm://openrouter" : location.origin + location.pathname; + const destination = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(callback)}&code_challenge=${await pkceChallenge(verifier)}&code_challenge_method=S256`; + if (isNativeMobile()) await openExternalUrl(destination); + else location.assign(destination); } const VERIFIER_KEY = "ctrl.or_verifier"; const VERIFIER_TTL_MS = 30 * 60 * 1000; @@ -163,7 +166,7 @@ function adminPanel(): HTMLElement { ctx.fillStyle = hex; ctx.fillRect(0, 0, 256, 256); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); if (!blob) { status.textContent = "Could not generate image."; return; } - const response = await fetch("/api/workspace/photo", { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": "image/png" }, body: blob }); + const response = await fetch(apiUrl("/api/workspace/photo"), { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": "image/png" }, body: blob }); const result = await response.json().catch(() => ({})); if (!response.ok) { status.textContent = result.error || `HTTP ${response.status}`; return; } S.workspace = result.workspace; photo.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); document.querySelectorAll(".logo-asset").forEach((image) => { image.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); }); status.textContent = "Workspace photo updated."; @@ -172,7 +175,7 @@ function adminPanel(): HTMLElement { file.onchange = async () => { const image = file.files?.[0]; if (!image) return; status.textContent = "Uploading workspace photo…"; - const response = await fetch("/api/workspace/photo", { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": image.type }, body: image }); + const response = await fetch(apiUrl("/api/workspace/photo"), { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": image.type }, body: image }); const result = await response.json().catch(() => ({})); if (!response.ok) { status.textContent = result.error || `HTTP ${response.status}`; return; } S.workspace = result.workspace; photo.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); document.querySelectorAll(".logo-asset").forEach((image) => { image.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); }); status.textContent = "Workspace photo updated."; @@ -331,7 +334,7 @@ function gmailConnectionPanel(): HTMLElement { try { const result = await api<{ gmail: GmailStatus }>("/api/connectors/gmail/setup", { body: {} }); const destination = result.gmail.setup.authorization_url; - if (destination) window.open(destination, "_blank", "noopener,noreferrer"); + if (destination) await openExternalUrl(destination); await draw(); } catch (error) { void appAlert((error as Error).message); } } }, connected ? "Connect another" : "Connect Gmail"); @@ -464,11 +467,10 @@ function feedbackPanel(): HTMLElement { h("span", { class: "chip" }, report.state), h("div", { class: "mt-1 text-xs text-muted" }, new Date(report.created).toLocaleString()))), h("p", { class: "whitespace-pre-wrap text-sm leading-6 text-fg" }, report.comment || "(attachment-only report)"), - report.id && report.attachments?.length ? h("div", { class: "flex flex-wrap gap-2" }, ...report.attachments.map((attachment) => h("a", { + report.id && report.attachments?.length ? h("div", { class: "flex flex-wrap gap-2" }, ...report.attachments.map((attachment) => h("button", { class: "btn-subtle text-xs", - href: `/api/feedback/${report.id}/attachments/${attachment.id}`, - target: "_blank", - rel: "noopener", + type: "button", + onclick: () => { void openAuthenticatedFile(`/api/feedback/${report.id}/attachments/${attachment.id}`).catch((error) => appAlert((error as Error).message)); }, }, `${attachment.name} · ${Math.ceil(attachment.size / 1024)} KB`))) : null, diagnostics !== "{}" ? h("details", { class: "rounded-lg border border-line bg-panel p-3" }, h("summary", { class: "cursor-pointer text-xs font-semibold text-fg" }, "Privacy-bounded diagnostics"), diff --git a/src/client/term.ts b/src/client/term.ts index 9392d14..a5b156f 100644 --- a/src/client/term.ts +++ b/src/client/term.ts @@ -2,6 +2,7 @@ import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { api, getToken } from "./api.ts"; +import { serverWebSocketUrl } from "./mobile.ts"; import { h, clear, icon } from "./dom.ts"; import { S } from "./app.ts"; @@ -367,8 +368,7 @@ async function connect(pane: Pane): Promise { pane.el.dataset.sessionId = pane.sessionId; } if (pane.disposed) { if (pane.sessionId) await api(`/api/term/${pane.sessionId}`, { method: "DELETE" }).catch(() => undefined); return; } - const proto = location.protocol === "https:" ? "wss" : "ws"; - const ws = new WebSocket(`${proto}://${location.host}/ws/term/${pane.sessionId}?token=${getToken()}`); + const ws = new WebSocket(serverWebSocketUrl(`/ws/term/${pane.sessionId}?token=${encodeURIComponent(getToken())}`)); pane.el.dataset.sessionId = pane.sessionId; ws.binaryType = "arraybuffer"; pane.ws = ws; diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 4d9bb40..d8ca57d 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.12"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.13"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ diff --git a/src/server/db.ts b/src/server/db.ts index c2828e2..feb7fd2 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -979,7 +979,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.12"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.13"); // Earlier Linux/Windows releases persisted the compatibility `native` // seam into every channel row. A production host update must actually // move those rows onto the platform isolation backend; changing the unit's diff --git a/src/server/index.ts b/src/server/index.ts index 25ac8a9..617ef54 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -109,6 +109,8 @@ const UPLOAD_BODY_LIMIT = 25 * 1024 * 1024; const WORKSPACE_PHOTO = join(DATA_DIR, "workspace-photo"); const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60_000; const INTERNAL_WAKE_TOKEN = String(process.env.HELM_INTERNAL_WAKE_TOKEN || ""); +const MOBILE_API_VERSION = 1; +const MOBILE_APP_ORIGINS = new Set(["capacitor://localhost", "https://localhost"]); const MIME: Record = { ".html": "text/html", ".js": "text/javascript", @@ -158,6 +160,16 @@ const json = (res: ServerResponse, code: number, body: unknown): void => { res.writeHead(code, { "content-type": "application/json", ...SECURITY_HEADERS }); res.end(s); }; +const applyMobileCors = (req: IncomingMessage, res: ServerResponse): boolean => { + const origin = String(req.headers.origin || ""); + if (!MOBILE_APP_ORIGINS.has(origin)) return false; + res.setHeader("access-control-allow-origin", origin); + res.setHeader("access-control-allow-methods", "GET, HEAD, POST, PATCH, PUT, DELETE, OPTIONS"); + res.setHeader("access-control-allow-headers", "Authorization, Content-Type, X-Filename"); + res.setHeader("access-control-expose-headers", "Content-Disposition, Content-Type"); + res.setHeader("vary", "Origin"); + return true; +}; const body = (req: IncomingMessage, limit = JSON_BODY_LIMIT): Promise => new Promise((resolve, reject) => { const declared = Number(req.headers["content-length"] || 0); if (declared > limit) { const error = new Error(`Request exceeds the ${Math.floor(limit / 1024 / 1024)} MB limit.`); error.name = "PayloadTooLargeError"; reject(error); return; } @@ -514,6 +526,12 @@ const server = createServer(async (req, res) => { const url = new URL(req.url || "/", `http://localhost`); const p = url.pathname; const m = req.method || "GET"; + const mobileOrigin = applyMobileCors(req, res); + if (m === "OPTIONS" && p.startsWith("/api/")) { + if (!mobileOrigin) return json(res, 403, { error: "Origin not allowed" }); + res.writeHead(204, { ...SECURITY_HEADERS, "cache-control": "no-store" }); + return res.end(); + } // The unified provider gateway is public by URL and authenticates with its // own generated gateway keys. It intentionally does not use a 1Helm web @@ -547,6 +565,17 @@ const server = createServer(async (req, res) => { } // ---- setup and auth (no session required) ---- + if (p === "/api/mobile/compatibility" && m === "GET") { + const setup = setupStatus(); + return json(res, 200, { + product: "1Helm", + mobile_api: MOBILE_API_VERSION, + version: installedAppVersion(APP_ROOT), + has_users: setup.has_users, + setup_complete: setup.setup_complete, + requires_https: true, + }); + } if (p === "/api/setup/status" && m === "GET") return json(res, 200, setupStatus()); if (p === "/api/collaboration/public" && m === "GET") return json(res, 200, { workspace: publicWorkspaceStatus() }); if (p === "/api/access-requests" && m === "POST") { diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 556d47f..e4562c0 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.12"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.13"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/mobile.mjs b/test/mobile.mjs new file mode 100644 index 0000000..7dd8436 --- /dev/null +++ b/test/mobile.mjs @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { access, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import puppeteer from "puppeteer"; +import sharp from "sharp"; + +const root = resolve(import.meta.dirname, ".."); +const read = (path) => readFile(join(root, path), "utf8"); + +async function browserExecutable() { + const candidates = [process.env.PUPPETEER_EXECUTABLE_PATH || ""]; + try { candidates.push(puppeteer.executablePath()); } catch { /* no bundled browser */ } + candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"); + for (const candidate of candidates.filter(Boolean)) { + try { await access(candidate, fsConstants.X_OK); return candidate; } catch { /* try next */ } + } + return null; +} +const executablePath = await browserExecutable(); + +async function freePort() { + return new Promise((resolvePort, reject) => { + const socket = createServer(); + socket.once("error", reject); + socket.listen(0, "127.0.0.1", () => { + const address = socket.address(); + socket.close((error) => error ? reject(error) : resolvePort(typeof address === "object" && address ? address.port : 0)); + }); + }); +} + +async function waitFor(url) { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + try { const response = await fetch(url); if (response.status < 500) return response; } catch { /* starting */ } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +test("mobile compatibility is explicit and CORS is confined to packaged Capacitor origins", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "1helm-mobile-test-")); + const port = await freePort(); + const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { + cwd: root, + env: { ...process.env, CTRL_DATA_DIR: dataDir, PORT: String(port), HELM_CHANNEL_COMPUTER_BACKEND: "native" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let logs = ""; + child.stdout.on("data", (chunk) => { logs += chunk; }); child.stderr.on("data", (chunk) => { logs += chunk; }); + try { + const base = `http://127.0.0.1:${port}`; + await waitFor(`${base}/api/setup/status`); + const compatible = await fetch(`${base}/api/mobile/compatibility`, { headers: { origin: "capacitor://localhost" } }); + assert.equal(compatible.status, 200); + assert.equal(compatible.headers.get("access-control-allow-origin"), "capacitor://localhost"); + assert.equal(compatible.headers.get("vary"), "Origin"); + assert.deepEqual(await compatible.json(), { + product: "1Helm", mobile_api: 1, version: JSON.parse(await read("package.json")).version, + has_users: false, setup_complete: false, requires_https: true, + }); + + const android = await fetch(`${base}/api/mobile/compatibility`, { headers: { origin: "https://localhost" } }); + assert.equal(android.headers.get("access-control-allow-origin"), "https://localhost"); + const hostile = await fetch(`${base}/api/mobile/compatibility`, { headers: { origin: "https://evil.example" } }); + assert.equal(hostile.headers.has("access-control-allow-origin"), false); + + const preflight = await fetch(`${base}/api/auth/login`, { + method: "OPTIONS", + headers: { origin: "https://localhost", "access-control-request-method": "POST", "access-control-request-headers": "authorization,content-type" }, + }); + assert.equal(preflight.status, 204); + assert.match(preflight.headers.get("access-control-allow-headers") || "", /Authorization/); + const blockedPreflight = await fetch(`${base}/api/auth/login`, { method: "OPTIONS", headers: { origin: "https://evil.example" } }); + assert.equal(blockedPreflight.status, 403); + } finally { + child.kill("SIGTERM"); + await new Promise((resolveWait) => child.once("exit", resolveWait)); + await rm(dataDir, { recursive: true, force: true }); + } + assert.doesNotMatch(logs, /(?:token|password|secret)=/i); +}); + +test("Capacitor shells keep sessions native, connections HTTPS-only, and release identities stable", async () => { + const [config, mobile, api, app, androidManifest, androidBuild, androidRules, androidPackage, iosInfo, iosProject, privacy, packageJson] = await Promise.all([ + read("capacitor.config.json"), read("src/client/mobile.ts"), read("src/client/api.ts"), read("src/client/app.ts"), + read("android/app/src/main/AndroidManifest.xml"), read("android/app/build.gradle"), read("android/app/src/main/res/xml/data_extraction_rules.xml"), read("scripts/package-android-apk.mjs"), + read("ios/App/App/Info.plist"), read("ios/App/App.xcodeproj/project.pbxproj"), read("ios/App/App/PrivacyInfo.xcprivacy"), read("package.json"), + ]); + const parsed = JSON.parse(config); + assert.equal(parsed.appId, "com.gitcommit90.onehelm.mobile"); + assert.equal(parsed.server.androidScheme, "https"); + assert.equal(parsed.server.cleartext, false); + assert.equal(parsed.server.url, undefined, "release app packages the audited local UI instead of navigating a wildcard remote WebView"); + assert.equal(parsed.loggingBehavior, "none"); + assert.equal(parsed.android.buildOptions.releaseType, "APK"); + assert.equal(parsed.android.webContentsDebuggingEnabled, false); + assert.equal(parsed.ios.preferredContentMode, "mobile"); + assert.equal(parsed.ios.webContentsDebuggingEnabled, false); + + assert.match(mobile, /SecureStorage/); + assert.match(mobile, /KeychainAccess\.whenUnlockedThisDeviceOnly/); + assert.match(mobile, /parsed\.protocol !== "https:"/); + assert.match(mobile, /persistSecureSession/); + assert.match(mobile, /removeSecureSession/); + assert.match(mobile, /App\.getLaunchUrl/, "a cold-start OAuth callback is retained"); + assert.doesNotMatch(mobile, /destination\.origin === serverOrigin/, "server links cannot replace the audited packaged WebView"); + assert.doesNotMatch(api, /let token = localStorage\.getItem/, "the session is not eagerly copied out of native secure storage"); + assert.match(app, /if \(isNativeMobile\(\) && !getToken\(\)\) return renderAuth\(\)/, "the gateway never opens host onboarding"); + assert.match(app, /src: serverAssetUrl\(avatarValue\)/, "server-hosted custom avatars resolve against the selected host"); + + assert.match(androidManifest, /android:allowBackup="false"/); + assert.match(androidManifest, /android:usesCleartextTraffic="false"/); + assert.match(androidManifest, /android\.permission\.INTERNET/); + assert.match(androidManifest, /android\.permission\.RECORD_AUDIO/); + assert.match(androidManifest, /android:scheme="onehelm" android:host="openrouter"/); + assert.match(androidBuild, /versionName oneHelmVersion/); + assert.match(androidBuild, /HELM_ANDROID_SIGNING_PROPERTIES/); + assert.match(androidBuild, /signingConfig signingConfigs\.release/); + assert.match(androidBuild, /minifyEnabled true/); + assert.match(androidRules, /exclude domain="sharedpref"/); + assert.match(androidPackage, /7b2d96ab21a242f9b17ddc7c65d133033bb9f0322158b6aab57bf8d46a7d27bf/); + assert.match(androidPackage, /expected the permanent 1Helm release certificate/); + + assert.match(iosInfo, /com\.gitcommit90\.onehelm\.mobile/); + assert.match(iosInfo, /onehelm<\/string>/); + assert.match(iosInfo, /NSMicrophoneUsageDescription/); + assert.match(iosInfo, /NSSpeechRecognitionUsageDescription/); + assert.match(iosInfo, /ITSAppUsesNonExemptEncryption/); + assert.match(iosProject, /PRODUCT_BUNDLE_IDENTIFIER = com\.gitcommit90\.onehelm\.mobile/); + assert.match(iosProject, /PrivacyInfo\.xcprivacy in Resources/); + assert.match(privacy, /NSPrivacyTracking[\s\S]*/); + + const pkg = JSON.parse(packageJson); + for (const dependency of ["@capacitor/core", "@capacitor/android", "@capacitor/ios", "@aparajita/capacitor-secure-storage"]) assert.ok(pkg.dependencies[dependency]); + assert.ok(pkg.scripts["mobile:sync"] && pkg.scripts["package:android:release"] && pkg.scripts["package:ios:release"]); + + const iosIcon = await sharp(join(root, "ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png")).metadata(); + assert.equal(iosIcon.width, 1024); assert.equal(iosIcon.height, 1024); + assert.ok((await stat(join(root, "android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png"))).size > 10_000); +}); + +test("mobile server addresses normalize to an HTTPS origin and reject ambiguous input", async () => { + const { normalizeServerOrigin } = await import("../src/client/mobile.ts"); + assert.equal(normalizeServerOrigin("helm.example.com/"), "https://helm.example.com"); + assert.equal(normalizeServerOrigin(" https://helm.example.com:8443 "), "https://helm.example.com:8443"); + for (const value of ["", "http://helm.example.com", "https://user:pass@helm.example.com", "https://helm.example.com/path", "https://helm.example.com?query=1", "https://helm.example.com/#fragment"]) { + assert.throws(() => normalizeServerOrigin(value)); + } +}); + +test("the packaged phone gateway opens a fitting connection screen instead of host setup", { + skip: executablePath ? false : "No local Chrome executable; native-shell and transport contracts still run independently.", +}, async () => { + const dataDir = await mkdtemp(join(tmpdir(), "1helm-mobile-ui-test-")); + const port = await freePort(); + const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { + cwd: root, + env: { ...process.env, CTRL_DATA_DIR: dataDir, PORT: String(port), HELM_CHANNEL_COMPUTER_BACKEND: "native" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let browser; + try { + const base = `http://127.0.0.1:${port}`; + await waitFor(`${base}/api/setup/status`); + browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] }); + const page = await browser.newPage(); + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); + await page.evaluateOnNewDocument(() => { + // Minimal Capacitor Android bridge contract. Native methods resolve as + // they do on-device, while secure storage begins empty for this test. + Object.defineProperty(window, "androidBridge", { value: {}, configurable: true }); + const promiseMethods = (names) => names.map((name) => ({ name, rtype: "promise" })); + window.Capacitor = { + PluginHeaders: [ + { name: "SecureStorage", methods: promiseMethods(["setSynchronizeKeychain", "internalGetItem", "internalSetItem", "internalRemoveItem", "clearItemsWithPrefix", "getPrefixedKeys"]) }, + { name: "StatusBar", methods: promiseMethods(["setStyle", "setOverlaysWebView"]) }, + { name: "Keyboard", methods: promiseMethods(["setResizeMode"]) }, + { name: "App", methods: [{ name: "addListener", rtype: "callback" }, ...promiseMethods(["removeListener", "getLaunchUrl"]) ] }, + { name: "Browser", methods: promiseMethods(["open", "close"]) }, + ], + nativePromise(plugin, method) { + if (plugin === "SecureStorage" && method === "internalGetItem") return Promise.resolve({ data: null }); + if (plugin === "SecureStorage" && method === "internalRemoveItem") return Promise.resolve({ success: false }); + if (plugin === "SecureStorage" && method === "getPrefixedKeys") return Promise.resolve({ keys: [] }); + return Promise.resolve({}); + }, + nativeCallback() { return Promise.resolve("mobile-test-listener"); }, + }; + }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(base, { waitUntil: "networkidle0" }); + await page.waitForSelector('input[placeholder="https://your-1helm-server.com"]'); + const screen = await page.evaluate(() => { + const stage = document.querySelector(".auth-stage"); + const inputs = [...document.querySelectorAll("input")]; + return { + body: document.body.textContent || "", + serverType: inputs[0]?.getAttribute("type"), + passwordType: inputs.at(-1)?.getAttribute("type"), + overflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth, + stageOverflowY: stage ? stage.scrollHeight - stage.clientHeight : -1, + nativePlatform: document.documentElement.dataset.nativeMobile, + }; + }); + assert.match(screen.body, /Connect to your 1Helm/); + assert.match(screen.body, /must already be set up and reachable over HTTPS/); + assert.doesNotMatch(screen.body, /Create the Captain account/); + assert.equal(screen.serverType, "url"); + assert.equal(screen.passwordType, "password"); + assert.equal(screen.nativePlatform, "android"); + assert.ok(screen.overflowX <= 0, `phone gateway has ${screen.overflowX}px horizontal overflow`); + assert.ok(screen.stageOverflowY <= 0, `phone connection card has ${screen.stageOverflowY}px vertical overflow`); + assert.deepEqual(errors, []); + } finally { + if (browser) await browser.close().catch(() => undefined); + child.kill("SIGTERM"); + await new Promise((resolveWait) => child.once("exit", resolveWait)); + await rm(dataDir, { recursive: true, force: true }); + } +});