From 95bee910f38b6b9f8cdd3342cc892bcebd35474b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 08:34:58 +0000 Subject: [PATCH 1/2] feat(ios): launch fixes, SideStore CI, and AltStore source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime: - Fix main.dart/router.dart import cycle and GoRouter redirect blocking - Use app support storage on iOS (Android-only external storage) - Safe connectivity checks, update API timeouts, mobile init guards - Remote debug logging for future mobile diagnostics CI (upstream-compatible): - Restore full multi-platform debugbuild workflow - SideStore IPA packaging in MACOSIOS - AltStore source generation in DEPLOY (docs/altstore-source.json) - Drop fork-only OTA GitHub Pages flow Co-authored-by: Jakub Doboลก --- .github/workflows/debugbuild.yml | 280 ++++++++++------- README.md | 12 + docs/altstore-source.json | 131 ++++++++ docs/altstore-source.meta.json | 26 ++ docs/remote-debug-logging.md | 25 ++ lib/app/flavor_theme.dart | 4 + .../controllers/discord_rpc_controller.dart | 4 + .../controllers/platform_check_io.dart | 3 + .../controllers/platform_check_stub.dart | 1 + .../controllers/fp_download_service.dart | 37 ++- .../logs/repositories/log_service.dart | 197 +++++++++++- lib/features/logs/views/log_screen.dart | 46 +++ .../controllers/media_player_service.dart | 18 +- lib/features/router/controllers/router.dart | 39 ++- lib/features/router/views/splash_screen.dart | 27 ++ .../settings/views/settings_screen.dart | 2 +- .../respositories/updater_controllers.dart | 50 ++- lib/main.dart | 194 +++++++----- lib/shared/utils/platform_info_io.dart | 10 + lib/shared/utils/platform_info_stub.dart | 8 + lib/shared/utils/safe_connectivity.dart | 45 +++ linux/packaging/appimage/AppRun | 4 - pubspec.yaml | 2 +- scripts/generate-altstore-source.py | 290 ++++++++++++++++++ scripts/package-ios-ipa-for-sidestore.sh | 47 +++ .../controllers/fp_download_service_test.dart | 45 +++ .../logs/repositories/log_service_test.dart | 55 ++++ test/features/logs/views/log_screen_test.dart | 35 +++ 28 files changed, 1390 insertions(+), 247 deletions(-) create mode 100644 docs/altstore-source.json create mode 100644 docs/altstore-source.meta.json create mode 100644 docs/remote-debug-logging.md create mode 100644 lib/app/flavor_theme.dart create mode 100644 lib/features/discordrpc/controllers/platform_check_io.dart create mode 100644 lib/features/discordrpc/controllers/platform_check_stub.dart create mode 100644 lib/features/router/views/splash_screen.dart create mode 100644 lib/shared/utils/platform_info_io.dart create mode 100644 lib/shared/utils/platform_info_stub.dart create mode 100644 lib/shared/utils/safe_connectivity.dart create mode 100755 scripts/generate-altstore-source.py create mode 100755 scripts/package-ios-ipa-for-sidestore.sh create mode 100644 test/features/download/controllers/fp_download_service_test.dart create mode 100644 test/features/logs/repositories/log_service_test.dart create mode 100644 test/features/logs/views/log_screen_test.dart diff --git a/.github/workflows/debugbuild.yml b/.github/workflows/debugbuild.yml index f103b5f..13061f2 100644 --- a/.github/workflows/debugbuild.yml +++ b/.github/workflows/debugbuild.yml @@ -7,8 +7,8 @@ on: # Add explicit permissions for the workflow permissions: - contents: write # Required for creating releases - actions: read # Required for downloading artifacts + contents: write # Required for creating releases + actions: read # Required for downloading artifacts env: DEPLOYMENT_API_KEY: ${{ secrets.DEPLOYMENT_API_KEY }} @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - + - name: Check if version bump should be skipped id: check_skip run: | @@ -34,23 +34,23 @@ jobs: echo "skip=false" >> $GITHUB_OUTPUT echo "Proceeding with version bump" fi - + - name: Bump version if: steps.check_skip.outputs.skip != 'true' run: | CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1 | tr -d '\r') echo "Current version: $CURRENT_VERSION" - + IFS='.' read -r major minor patch <<< "$CURRENT_VERSION" NEW_PATCH=$((patch + 1)) NEW_VERSION="$major.$minor.$NEW_PATCH" echo "New version: $NEW_VERSION" - + sed -i "s/^version: .*/version: $NEW_VERSION/" pubspec.yaml - + echo "Updated pubspec.yaml:" grep '^version:' pubspec.yaml - + - name: Commit version bump if: steps.check_skip.outputs.skip != 'true' uses: stefanzweifel/git-auto-commit-action@v5 @@ -68,26 +68,26 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.ref }} - + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: '17' - + - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: "3.38.4" cache: true - + - name: Fix Git ownership for Flutter run: | git config --global --add safe.directory '*' - + - name: Install Flutter dependencies run: flutter pub get - + - name: Install Linux build dependencies run: | sudo apt-get update @@ -99,14 +99,14 @@ jobs: ninja-build \ clang \ pkg-config - + - name: Set glibc compatibility run: | # Build with maximum glibc compatibility export GLIBC_COMPATIBILITY=1 export CFLAGS="-march=x86-64 -mtune=generic" export CXXFLAGS="-march=x86-64 -mtune=generic" - + - name: Install dependencies for AppImage run: | wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" @@ -121,10 +121,10 @@ jobs: cp "$config_file" "${target_dir}/make_config.yaml" echo "Copied $config_file โ†’ ${target_dir}/make_config.yaml" done - + - name: Install Fastforge run: dart pub global activate fastforge - + - name: Build and release run: fastforge release --name ${{ github.ref_name }}linux @@ -136,26 +136,26 @@ jobs: echo "No AppImage found, skipping AppRun replacement" exit 0 fi - + # Convert to absolute path APPIMAGE_FILE="$(cd "$(dirname "$APPIMAGE_FILE")" && pwd)/$(basename "$APPIMAGE_FILE")" echo "Found AppImage: $APPIMAGE_FILE" - + # Make AppImage executable chmod +x "$APPIMAGE_FILE" - + # Extract the AppImage APPIMAGE_DIR=$(mktemp -d) echo "Extracting to: $APPIMAGE_DIR" cd "$APPIMAGE_DIR" "$APPIMAGE_FILE" --appimage-extract - + # Check if extraction was successful if [ ! -d squashfs-root ]; then echo "Error: AppImage extraction failed" exit 1 fi - + # Replace the AppRun script with our distro-agnostic version if [ -f squashfs-root/AppRun ]; then cp "$GITHUB_WORKSPACE/linux/packaging/appimage/AppRun" squashfs-root/AppRun @@ -164,7 +164,7 @@ jobs: else echo "Warning: AppRun not found in extracted AppImage" fi - + # Rebuild the AppImage cd "$GITHUB_WORKSPACE" appimagetool -n "$APPIMAGE_DIR/squashfs-root" "$APPIMAGE_FILE" @@ -186,7 +186,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.ref }} - + - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -201,10 +201,10 @@ jobs: echo "${{ secrets.uploadkeystore }}" | base64 -d > android/app/upload-keystore.jks echo "Created files:" ls -la lib/ lib/utils android/ android/app/ - + - name: Install Flutter dependencies run: flutter pub get - + - name: Verify signing configuration run: | echo "Checking signing files:" @@ -220,10 +220,10 @@ jobs: echo "โœ— Keystore missing" exit 1 fi - + - name: Clean before build run: flutter clean - + - name: Build signed Android APK run: | set -e @@ -265,19 +265,19 @@ jobs: ls -la build/app/outputs/flutter-apk/ || true echo "All APK files in build tree:" find . -path "*/build/*" -type f -name "*.apk" || true - + - name: Get version from pubspec.yaml id: get_version run: | VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1 | tr -d '\r') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - + - name: Prepare APK for release run: | VERSION="${{ steps.get_version.outputs.version }}" VERSION="$(echo -n "$VERSION" | tr -d '\r')" - + # Map 'release' branch to 'stable' flavor BRANCH="${{ github.ref_name }}" if [[ "$BRANCH" == "release" ]]; then @@ -285,9 +285,9 @@ jobs: else FLAVOR="$BRANCH" fi - + mkdir -p "dist/$VERSION" - + APK_FLAVORED="build/app/outputs/flutter-apk/app-${FLAVOR}-release.apk" APK_DEFAULT="build/app/outputs/flutter-apk/app-release.apk" @@ -305,7 +305,7 @@ jobs: echo "APK prepared:" ls -la "dist/$VERSION/" - + - name: Upload Android APK uses: actions/upload-artifact@v4 with: @@ -345,7 +345,7 @@ jobs: - name: Install Fastforge run: dart pub global activate fastforge - + - name: Build and release run: fastforge release --name ${{ github.ref_name }}macos @@ -367,8 +367,28 @@ jobs: echo "flavor=$FLAVOR" >> $GITHUB_OUTPUT echo "Building iOS with flavor: $FLAVOR" + - name: Parse version from pubspec.yaml + id: ios_version + run: | + set -euo pipefail + RAW=$(grep '^version:' pubspec.yaml | sed 's/version: //' | tr -d ' \r') + VERSION_NAME="${RAW%%+*}" + if [[ "$RAW" == *"+"* ]]; then + BUILD_NUMBER="${RAW#*+}" + else + BUILD_NUMBER="${VERSION_NAME##*.}" + fi + echo "name=$VERSION_NAME" >> $GITHUB_OUTPUT + echo "build=$BUILD_NUMBER" >> $GITHUB_OUTPUT + echo "Version name: $VERSION_NAME, build: $BUILD_NUMBER" + - name: Build iOS app - run: flutter build ios --release --no-codesign --flavor ${{ steps.ios_flavor.outputs.flavor }} --dart-define FLUTTER_FLAVOR=${{ github.ref_name }} + run: | + flutter build ios --release --no-codesign \ + --build-name="${{ steps.ios_version.outputs.name }}" \ + --build-number="${{ steps.ios_version.outputs.build }}" \ + --flavor ${{ steps.ios_flavor.outputs.flavor }} \ + --dart-define FLUTTER_FLAVOR=${{ github.ref_name }} - name: Get version from pubspec.yaml id: get_version @@ -377,20 +397,31 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - - name: Package iOS IPA + - name: Package iOS IPA for SideStore run: | VERSION="${{ steps.get_version.outputs.version }}" VERSION="$(echo -n "$VERSION" | tr -d '\r')" mkdir -p "dist/$VERSION" - mkdir -p Payload - cp -r build/ios/iphoneos/Runner.app Payload/ - cd dist/$VERSION - zip -r "floaty-${VERSION}-ios.ipa" ../../Payload - cd ../.. - rm -rf Payload - echo "Packaged IPA:" + chmod +x scripts/package-ios-ipa-for-sidestore.sh + scripts/package-ios-ipa-for-sidestore.sh \ + build/ios/iphoneos/Runner.app \ + "dist/$VERSION/floaty-${VERSION}-ios.ipa" ls -la "dist/$VERSION/" + - name: Verify IPA archive layout + run: | + VERSION="${{ steps.get_version.outputs.version }}" + IPA="dist/$VERSION/floaty-${VERSION}-ios.ipa" + if unzip -l "$IPA" | awk 'NR>3 {print $4}' | grep -q '\.\./'; then + echo "::error::IPA zip entries must not contain parent-directory paths (../)" + exit 1 + fi + if ! unzip -l "$IPA" | awk 'NR>3 && $4 != "" {print $4; exit}' | grep -q '^Payload/'; then + echo "::error::IPA must contain Payload/ at archive root" + unzip -l "$IPA" | head -10 + exit 1 + fi + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: @@ -417,7 +448,7 @@ jobs: run: flutter pub get - name: Prepare make_config.yaml - shell: bash #i hate powershell it can burn in hell + shell: bash #i hate powershell it can burn in hell run: | OS=windows for config_file in $(find "$OS/packaging" -type f -name "make_config_${GITHUB_REF_NAME}.yaml"); do @@ -428,7 +459,7 @@ jobs: - name: Install Fastforge run: dart pub global activate fastforge - + - name: Build and release run: fastforge release --name ${{ github.ref_name }}windows @@ -459,22 +490,22 @@ jobs: - name: Organize build files run: | mkdir -p release/{windows,linux,android,macos,ios} - + if [ -d "./artifacts/dist-linux" ]; then find ./artifacts/dist-linux -name "*.rpm" -exec cp {} release/linux/ \; find ./artifacts/dist-linux -name "*.deb" -exec cp {} release/linux/ \; find ./artifacts/dist-linux -name "*.AppImage" -exec cp {} release/linux/ \; fi - + if [ -d "./artifacts/dist-android" ]; then find ./artifacts/dist-android -name "*.apk" -exec cp {} release/android/ \; fi - + if [ -d "./artifacts/dist-windows" ]; then find ./artifacts/dist-windows -name "*.exe" -exec cp {} release/windows/ \; find ./artifacts/dist-windows -name "*.msi" -exec cp {} release/windows/ \; fi - + if [ -d "./artifacts/dist-macos-ios" ]; then find ./artifacts/dist-macos-ios -name "*.dmg" -exec cp {} release/macos/ \; find ./artifacts/dist-macos-ios -name "*.pkg" -exec cp {} release/macos/ \; @@ -485,126 +516,126 @@ jobs: run: | VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | tr -d ' ' || echo "1.0.0") FLAVOR="${{ github.ref_name }}" - + cat > release/manifest.json << EOF { "version": "$VERSION", "flavor": "$FLAVOR", "platforms": [ EOF - + if ls release/windows/*.exe >/dev/null 2>&1 || ls release/windows/*.msi >/dev/null 2>&1; then - echo ' {' >> release/manifest.json - echo ' "platform": "windows",' >> release/manifest.json - echo ' "files": [' >> release/manifest.json - + echo ' {' >> release/manifest.json + echo ' "platform": "windows",' >> release/manifest.json + echo ' "files": [' >> release/manifest.json + FIRST=true for file in release/windows/*; do if [ -f "$file" ]; then [ "$FIRST" = false ] && echo ',' >> release/manifest.json filename=$(basename "$file") if [[ "$filename" == *.exe ]]; then - echo -n " {\"type\": \".exe Installer\", \"path\": \"windows/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \".exe Installer\", \"path\": \"windows/$filename\"}" >> release/manifest.json elif [[ "$filename" == *.msi ]]; then - echo -n " {\"type\": \".msi Installer\", \"path\": \"windows/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \".msi Installer\", \"path\": \"windows/$filename\"}" >> release/manifest.json fi FIRST=false fi done echo '' >> release/manifest.json - echo ' ]' >> release/manifest.json - echo ' },' >> release/manifest.json + echo ' ]' >> release/manifest.json + echo ' },' >> release/manifest.json fi - + if ls release/linux/*.rpm >/dev/null 2>&1 || ls release/linux/*.deb >/dev/null 2>&1 || ls release/linux/*.AppImage >/dev/null 2>&1; then - echo ' {' >> release/manifest.json - echo ' "platform": "linux",' >> release/manifest.json - echo ' "files": [' >> release/manifest.json - + echo ' {' >> release/manifest.json + echo ' "platform": "linux",' >> release/manifest.json + echo ' "files": [' >> release/manifest.json + FIRST=true for file in release/linux/*; do if [ -f "$file" ]; then [ "$FIRST" = false ] && echo ',' >> release/manifest.json filename=$(basename "$file") if [[ "$filename" == *.rpm ]]; then - echo -n " {\"type\": \"RPM\", \"path\": \"linux/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \"RPM\", \"path\": \"linux/$filename\"}" >> release/manifest.json elif [[ "$filename" == *.deb ]]; then - echo -n " {\"type\": \"DEB\", \"path\": \"linux/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \"DEB\", \"path\": \"linux/$filename\"}" >> release/manifest.json elif [[ "$filename" == *.AppImage ]]; then - echo -n " {\"type\": \"AppImage\", \"path\": \"linux/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \"AppImage\", \"path\": \"linux/$filename\"}" >> release/manifest.json fi FIRST=false fi done echo '' >> release/manifest.json - echo ' ]' >> release/manifest.json - echo ' },' >> release/manifest.json + echo ' ]' >> release/manifest.json + echo ' },' >> release/manifest.json fi - + if ls release/android/*.apk >/dev/null 2>&1; then - echo ' {' >> release/manifest.json - echo ' "platform": "android",' >> release/manifest.json - echo ' "files": [' >> release/manifest.json - + echo ' {' >> release/manifest.json + echo ' "platform": "android",' >> release/manifest.json + echo ' "files": [' >> release/manifest.json + FIRST=true for file in release/android/*; do if [ -f "$file" ]; then [ "$FIRST" = false ] && echo ',' >> release/manifest.json filename=$(basename "$file") - echo -n " {\"type\": \"APK\", \"path\": \"android/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \"APK\", \"path\": \"android/$filename\"}" >> release/manifest.json FIRST=false fi done echo '' >> release/manifest.json - echo ' ]' >> release/manifest.json - echo ' },' >> release/manifest.json + echo ' ]' >> release/manifest.json + echo ' },' >> release/manifest.json fi - + if ls release/macos/*.dmg >/dev/null 2>&1 || ls release/macos/*.pkg >/dev/null 2>&1; then - echo ' {' >> release/manifest.json - echo ' "platform": "macos",' >> release/manifest.json - echo ' "files": [' >> release/manifest.json - + echo ' {' >> release/manifest.json + echo ' "platform": "macos",' >> release/manifest.json + echo ' "files": [' >> release/manifest.json + FIRST=true for file in release/macos/*; do if [ -f "$file" ]; then [ "$FIRST" = false ] && echo ',' >> release/manifest.json filename=$(basename "$file") if [[ "$filename" == *.dmg ]]; then - echo -n " {\"type\": \"DMG\", \"path\": \"macos/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \"DMG\", \"path\": \"macos/$filename\"}" >> release/manifest.json elif [[ "$filename" == *.pkg ]]; then - echo -n " {\"type\": \"PKG\", \"path\": \"macos/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \"PKG\", \"path\": \"macos/$filename\"}" >> release/manifest.json fi FIRST=false fi done echo '' >> release/manifest.json - echo ' ]' >> release/manifest.json - echo ' },' >> release/manifest.json + echo ' ]' >> release/manifest.json + echo ' },' >> release/manifest.json fi - + if ls release/ios/*.ipa >/dev/null 2>&1; then - echo ' {' >> release/manifest.json - echo ' "platform": "ios",' >> release/manifest.json - echo ' "files": [' >> release/manifest.json - + echo ' {' >> release/manifest.json + echo ' "platform": "ios",' >> release/manifest.json + echo ' "files": [' >> release/manifest.json + FIRST=true for file in release/ios/*; do if [ -f "$file" ]; then [ "$FIRST" = false ] && echo ',' >> release/manifest.json filename=$(basename "$file") - echo -n " {\"type\": \"IPA\", \"path\": \"ios/$filename\"}" >> release/manifest.json + echo -n " {\"type\": \"IPA\", \"path\": \"ios/$filename\"}" >> release/manifest.json FIRST=false fi done echo '' >> release/manifest.json - echo ' ]' >> release/manifest.json - echo ' },' >> release/manifest.json + echo ' ]' >> release/manifest.json + echo ' },' >> release/manifest.json fi - + sed -i '$ s/,$//' release/manifest.json - echo ' ]' >> release/manifest.json - echo ' }' >> release/manifest.json + echo ' ]' >> release/manifest.json + echo '}' >> release/manifest.json - name: Create deployment package run: | @@ -613,7 +644,7 @@ jobs: cd release zip -r "../$PACKAGE_NAME" . cd .. - + - name: Display manifest content run: cat release/manifest.json @@ -621,40 +652,40 @@ jobs: id: deploy run: | echo "Deploying package: $PACKAGE_NAME" - + if [ ! -f "$PACKAGE_NAME" ]; then echo "Error: Package file $PACKAGE_NAME not found!" ls -la exit 1 fi - + RESPONSE=$(curl -X POST \ -H "x-api-key: ${{ env.DEPLOYMENT_API_KEY }}" \ -F "artifact=@${PACKAGE_NAME}" \ -w "\n%{http_code}" \ "https://floaty.fyi/api/deploy" \ 2>/dev/null) - + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) RESPONSE_BODY=$(echo "$RESPONSE" | head -n -1) - + echo "HTTP Response Code: $HTTP_CODE" echo "Response Body: $RESPONSE_BODY" - + if [ "$HTTP_CODE" -eq 200 ]; then echo "Successfully deployed $PACKAGE_NAME" echo "Response: $RESPONSE_BODY" - + echo "deployment_response=$RESPONSE_BODY" >> $GITHUB_OUTPUT - + DEPLOYMENT_ID=$(echo "$RESPONSE_BODY" | jq -r '.deploymentId // empty') VERSION=$(echo "$RESPONSE_BODY" | jq -r '.version // empty') FLAVOR=$(echo "$RESPONSE_BODY" | jq -r '.flavor // empty') - + echo "deployment_id=$DEPLOYMENT_ID" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT echo "flavor=$FLAVOR" >> $GITHUB_OUTPUT - + echo "Deployment ID: $DEPLOYMENT_ID" echo "Version: $VERSION" echo "Flavor: $FLAVOR" @@ -666,22 +697,25 @@ jobs: - name: Create GitHub Release if: steps.deploy.outputs.deployment_id != '' + id: release uses: softprops/action-gh-release@v2 with: tag_name: "${{ steps.deploy.outputs.flavor }}-v${{ steps.deploy.outputs.version }}-${{ steps.deploy.outputs.deployment_id }}" name: "${{ steps.deploy.outputs.flavor }} v${{ steps.deploy.outputs.version }}" body: | ## ${{ steps.deploy.outputs.flavor }} Release v${{ steps.deploy.outputs.version }} - + ๐Ÿ”— **[View Changelogs](https://floaty.fyi/changelogs#${{ steps.deploy.outputs.deployment_id }})** - + This release includes builds for: ${{ needs.LINUX.result == 'success' && '- ๐Ÿง Linux (RPM, DEB, AppImage)' || '' }} ${{ needs.ANDROID.result == 'success' && '- ๐Ÿค– Android (APK - signed)' || '' }} ${{ needs.WINDOWS.result == 'success' && '- ๐ŸชŸ Windows (EXE Installer)' || '' }} ${{ needs.MACOSIOS.result == 'success' && '- ๐ŸŽ macOS (DMG, PKG)' || '' }} - ${{ needs.MACOSIOS.result == 'success' && '- ๐Ÿ“ฑ iOS (IPA)' || '' }} - + ${{ needs.MACOSIOS.result == 'success' && '- ๐Ÿ“ฑ iOS (IPA - SideStore/AltStore)' || '' }} + + **SideStore / AltStore:** Add source URL from [docs/altstore-source.json](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/docs/altstore-source.json) on this branch (updated after each iOS release). + **Deployment ID:** `${{ steps.deploy.outputs.deployment_id }}` **Branch:** `${{ github.ref_name }}` **Commit:** `${{ github.sha }}` @@ -695,9 +729,29 @@ jobs: release/ios/* release/manifest.json + - name: Generate AltStore source from GitHub Releases + if: steps.deploy.outputs.deployment_id != '' && needs.MACOSIOS.result == 'success' + env: + GITHUB_TOKEN: ${{ github.token }} + ALTSTORE_WAIT_RELEASE_TAG: ${{ steps.deploy.outputs.flavor }}-v${{ steps.deploy.outputs.version }}-${{ steps.deploy.outputs.deployment_id }} + run: python3 scripts/generate-altstore-source.py + + - name: Commit AltStore source + if: steps.deploy.outputs.deployment_id != '' && needs.MACOSIOS.result == 'success' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/altstore-source.json + if git diff --staged --quiet; then + echo "AltStore source already up to date" + else + git commit -m "chore: update AltStore source for ${{ steps.deploy.outputs.flavor }}-v${{ steps.deploy.outputs.version }}-${{ steps.deploy.outputs.deployment_id }} --no-bump" + git push + fi + - name: Upload final package as artifact uses: actions/upload-artifact@v4 with: name: deployment-package path: "${{ env.PACKAGE_NAME }}" - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/README.md b/README.md index 08957ef..9e28b81 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,18 @@ You can build the app in either release or debug mode. If you're building it in flutter build ios -- ``` +## iOS install via SideStore / AltStore + +CI builds an IPA on each deploy, packages it for SideStore (correct `Payload/` zip layout + ad-hoc `codesign` on macOS), and regenerates an [AltStore source](https://faq.altstore.io/developers/make-a-source) at `docs/altstore-source.json` from **all** GitHub Releases, including pre-releases. + +**Source URL** (add once in SideStore or AltStore): + +`https://github.com/floatyfp/floaty/blob/release/docs/altstore-source.json?raw=true` + +(or the equivalent raw URL for your fork/branch) + +Edit static metadata in `docs/altstore-source.meta.json`; version entries are filled automatically from release assets (`floaty-*-ios.ipa`). + - **Windows** ```bash flutter build windows -- diff --git a/docs/altstore-source.json b/docs/altstore-source.json new file mode 100644 index 0000000..54cb46c --- /dev/null +++ b/docs/altstore-source.json @@ -0,0 +1,131 @@ +{ + "name": "Floaty", + "subtitle": "Open-source Floatplane client for iOS", + "description": "Floaty builds for iOS from GitHub Releases. Includes pre-releases from nightly, beta, and development branches. IPAs are packaged for SideStore and AltStore.", + "iconURL": "https://floaty.fyi/assets/floaty.png", + "website": "https://floaty.fyi", + "tintColor": "#3b82f6", + "featuredApps": [ + "uk.bw86.floaty" + ], + "news": [], + "apps": [ + { + "name": "Floaty", + "bundleIdentifier": "uk.bw86.floaty", + "developerName": "Floaty", + "subtitle": "Floatplane client for iOS", + "localizedDescription": "An open-source Floatplane client. Install via SideStore or AltStore; pick a build matching your branch (release, beta, nightly, or development).", + "iconURL": "https://floaty.fyi/assets/floaty.png", + "tintColor": "#3b82f6", + "category": "entertainment", + "screenshots": [], + "appPermissions": { + "entitlements": [], + "privacy": {} + }, + "patreon": {}, + "versions": [ + { + "version": "0.0.22", + "buildVersion": "22", + "marketingVersion": "0.0.22 (22)", + "date": "2026-06-03T08:00:24Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/main-v0.0.22-build22/floaty-0.0.22-ios.ipa", + "size": 24517758, + "localizedDescription": "[main] ## main iOS build" + }, + { + "version": "0.0.21", + "buildVersion": "21", + "marketingVersion": "0.0.21 (21)", + "date": "2026-06-03T05:45:36Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/main-v0.0.21-build21/floaty-0.0.21-ios.ipa", + "size": 24517762, + "localizedDescription": "[main] ## main iOS build" + }, + { + "version": "0.0.20", + "buildVersion": "20", + "marketingVersion": "0.0.20 (20)", + "date": "2026-06-01T13:37:19Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/main-v0.0.20-build20/floaty-0.0.20-ios.ipa", + "size": 24544867, + "localizedDescription": "[main] ## main iOS build" + }, + { + "version": "0.0.19", + "buildVersion": "19", + "marketingVersion": "0.0.19 (19)", + "date": "2026-06-01T13:30:22Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/main-v0.0.19-build19/floaty-0.0.19-ios.ipa", + "size": 24946293, + "localizedDescription": "[main] ## main iOS build" + }, + { + "version": "0.0.17", + "buildVersion": "17", + "marketingVersion": "0.0.17 (17)", + "date": "2026-06-01T12:21:56Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/cursor/fix-altstore-duplicate-versions-81e9-v0.0.17-build17/floaty-0.0.17-ios.ipa", + "size": 24940996, + "localizedDescription": "[cursor/fix-altstore-duplicate-versions-81e9] ## cursor/fix-altstore-duplicate-versions-81e9 iOS build" + }, + { + "version": "0.0.16", + "buildVersion": "16", + "marketingVersion": "0.0.16 (16)", + "date": "2026-06-01T11:31:59Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/cursor/fix-altstore-duplicate-versions-81e9-v0.0.16-build16/floaty-0.0.16-ios.ipa", + "size": 24941003, + "localizedDescription": "[cursor/fix-altstore-duplicate-versions-81e9] ## cursor/fix-altstore-duplicate-versions-81e9 iOS build" + }, + { + "version": "0.0.15", + "buildVersion": "15", + "marketingVersion": "0.0.15 (15)", + "date": "2026-06-01T10:36:43Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/cursor/altstore-source-github-pages-81e9-v0.0.15-build15/floaty-0.0.15-ios.ipa", + "size": 24941018, + "localizedDescription": "[cursor/altstore-source-github-pages-81e9] ## cursor/altstore-source-github-pages-81e9 iOS build" + }, + { + "version": "0.0.14", + "buildVersion": "14", + "marketingVersion": "0.0.14 (14)", + "date": "2026-06-01T09:25:03Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/cursor/fix-ios-ipa-sidestore-packaging-81e9-v0.0.14-build14/floaty-0.0.14-ios.ipa", + "size": 24464495, + "localizedDescription": "[cursor/fix-ios-ipa-sidestore-packaging-81e9] ## cursor/fix-ios-ipa-sidestore-packaging-81e9 iOS build\r" + }, + { + "version": "0.0.13", + "buildVersion": "13", + "marketingVersion": "0.0.13 (13)", + "date": "2026-05-17T18:23:04Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/cursor/fix-ota-manifest-plist-367d-v0.0.13-build13/floaty-0.0.13-ios.ipa", + "size": 24469024, + "localizedDescription": "[cursor/fix-ota-manifest-plist-367d] ## cursor/fix-ota-manifest-plist-367d iOS build\r" + }, + { + "version": "0.0.12", + "buildVersion": "12", + "marketingVersion": "0.0.12 (12)", + "date": "2026-05-17T17:26:18Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/cursor/fix-ota-manifest-plist-367d-v0.0.12-build12/floaty-0.0.12-ios.ipa", + "size": 24469026, + "localizedDescription": "[cursor/fix-ota-manifest-plist-367d] ## cursor/fix-ota-manifest-plist-367d iOS build" + }, + { + "version": "0.0.11", + "buildVersion": "11", + "marketingVersion": "0.0.11 (11)", + "date": "2026-05-17T17:10:22Z", + "downloadURL": "https://github.com/tojemoc/floaty/releases/download/cursor/fix-ios-versioning-ota-6e6e-v0.0.11-build11/floaty-0.0.11-ios.ipa", + "size": 24468788, + "localizedDescription": "[cursor/fix-ios-versioning-ota-6e6e] ## cursor/fix-ios-versioning-ota-6e6e iOS build" + } + ] + } + ] +} diff --git a/docs/altstore-source.meta.json b/docs/altstore-source.meta.json new file mode 100644 index 0000000..b2a7e7d --- /dev/null +++ b/docs/altstore-source.meta.json @@ -0,0 +1,26 @@ +{ + "name": "Floaty", + "subtitle": "Open-source Floatplane client for iOS", + "description": "Floaty builds for iOS from GitHub Releases. Includes pre-releases from nightly, beta, and development branches. IPAs are packaged for SideStore and AltStore.", + "iconURL": "https://floaty.fyi/assets/floaty.png", + "website": "https://floaty.fyi", + "tintColor": "#3b82f6", + "featuredApps": ["uk.bw86.floaty"], + "news": [], + "app": { + "name": "Floaty", + "bundleIdentifier": "uk.bw86.floaty", + "developerName": "Floaty", + "subtitle": "Floatplane client for iOS", + "localizedDescription": "An open-source Floatplane client. Install via SideStore or AltStore; pick a build matching your branch (release, beta, nightly, or development).", + "iconURL": "https://floaty.fyi/assets/floaty.png", + "tintColor": "#3b82f6", + "category": "entertainment", + "screenshots": [], + "appPermissions": { + "entitlements": [], + "privacy": {} + }, + "patreon": {} + } +} diff --git a/docs/remote-debug-logging.md b/docs/remote-debug-logging.md new file mode 100644 index 0000000..3c55188 --- /dev/null +++ b/docs/remote-debug-logging.md @@ -0,0 +1,25 @@ +# Remote debug logging + +Floaty can mirror app logs and uncaught Flutter/Dart errors to an external HTTP +endpoint for debugging white-screen or startup issues. + +Remote logging is off by default. Enable it at build or run time with Dart +defines: + +```sh +flutter run -d linux \ + --dart-define=FLOATY_REMOTE_LOG_ENDPOINT=https://example.com/floaty-logs +``` + +If the endpoint requires bearer auth, also pass: + +```sh +--dart-define=FLOATY_REMOTE_LOG_TOKEN=your-token +``` + +The app sends JSON payloads with a session id, timestamp, platform, level, and +message. The developer logs screen can also upload the currently selected app or +download log view on demand. + +Before logs are saved or sent, common authorization, token, and cookie patterns +are redacted. diff --git a/lib/app/flavor_theme.dart b/lib/app/flavor_theme.dart new file mode 100644 index 0000000..b69a939 --- /dev/null +++ b/lib/app/flavor_theme.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +/// Set during [main] before [runApp]. Used by theme and settings UI. +late final Color? flavorPrimary; diff --git a/lib/features/discordrpc/controllers/discord_rpc_controller.dart b/lib/features/discordrpc/controllers/discord_rpc_controller.dart index d0a3172..bcc3b5b 100644 --- a/lib/features/discordrpc/controllers/discord_rpc_controller.dart +++ b/lib/features/discordrpc/controllers/discord_rpc_controller.dart @@ -5,7 +5,11 @@ import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:get_it/get_it.dart'; +import 'platform_check_stub.dart' if (dart.library.io) 'platform_check_io.dart' + as platform_check; + final discordRPCController = GetIt.I(); +bool get isDiscordRPCSupported => platform_check.isDiscordRPCSupported; class DiscordRPCController { bool initialized = false; diff --git a/lib/features/discordrpc/controllers/platform_check_io.dart b/lib/features/discordrpc/controllers/platform_check_io.dart new file mode 100644 index 0000000..5be0dd0 --- /dev/null +++ b/lib/features/discordrpc/controllers/platform_check_io.dart @@ -0,0 +1,3 @@ +import 'dart:io' show Platform; + +bool get isDiscordRPCSupported => Platform.isWindows || Platform.isLinux; diff --git a/lib/features/discordrpc/controllers/platform_check_stub.dart b/lib/features/discordrpc/controllers/platform_check_stub.dart new file mode 100644 index 0000000..26b2e96 --- /dev/null +++ b/lib/features/discordrpc/controllers/platform_check_stub.dart @@ -0,0 +1 @@ +bool get isDiscordRPCSupported => false; diff --git a/lib/features/download/controllers/fp_download_service.dart b/lib/features/download/controllers/fp_download_service.dart index 01b7fa0..41a1b09 100644 --- a/lib/features/download/controllers/fp_download_service.dart +++ b/lib/features/download/controllers/fp_download_service.dart @@ -15,6 +15,17 @@ import 'package:floaty/features/download/controllers/download_log.dart'; import 'package:floaty/features/download/controllers/fp_download_url_helper.dart'; import 'package:floaty/features/api/models/definitions.dart'; +Directory selectOfflineStorageDirectory({ + required Directory applicationSupportDirectory, + required bool useExternalStorage, + Directory? externalStorageDirectory, +}) { + if (useExternalStorage && externalStorageDirectory != null) { + return externalStorageDirectory; + } + return applicationSupportDirectory; +} + /// Floatplane Download Service - UI client for the FP download isolate class FPDownloadService { static final FPDownloadService _instance = FPDownloadService._internal(); @@ -57,13 +68,15 @@ class FPDownloadService { // Setup offline directory - ALWAYS use app's internal storage for offline library // The custom download_path is only for external downloads (useExternalPath=true) - Directory? directory; - if (Platform.isAndroid || Platform.isIOS) { - directory = await getExternalStorageDirectory(); - } else { - directory = await getApplicationSupportDirectory(); - } - _offlinePath = p.join(directory?.path ?? '', 'floatplane_offline'); + final appSupportDirectory = await getApplicationSupportDirectory(); + final externalStorageDirectory = + Platform.isAndroid ? await getExternalStorageDirectory() : null; + final directory = selectOfflineStorageDirectory( + applicationSupportDirectory: appSupportDirectory, + externalStorageDirectory: externalStorageDirectory, + useExternalStorage: Platform.isAndroid, + ); + _offlinePath = p.join(directory.path, 'floatplane_offline'); await Directory(_offlinePath!).create(recursive: true); _logger!.log( '[FPDownloadService] Offline library path (internal): $_offlinePath'); @@ -104,6 +117,16 @@ class FPDownloadService { Future _initNotifications() async { try { + final dbusSessionBusAddress = + Platform.environment['DBUS_SESSION_BUS_ADDRESS']; + if (Platform.isLinux && + (dbusSessionBusAddress == null || dbusSessionBusAddress.isEmpty)) { + _logger?.log( + '[FPDownloadService] Skipping notifications; D-Bus session bus is unavailable'); + _notificationsInitialized = false; + return; + } + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const iosSettings = DarwinInitializationSettings( diff --git a/lib/features/logs/repositories/log_service.dart b/lib/features/logs/repositories/log_service.dart index a3d9087..32562b2 100644 --- a/lib/features/logs/repositories/log_service.dart +++ b/lib/features/logs/repositories/log_service.dart @@ -1,13 +1,36 @@ +import 'dart:async'; +import 'dart:convert'; import 'package:logger/logger.dart'; import 'package:hive_ce/hive.dart'; import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; +class RemoteLogUploadResult { + const RemoteLogUploadResult({ + required this.success, + required this.message, + }); + + final bool success; + final String message; +} + class LogService { static final Logger _logger = Logger(); static const String _boxName = 'app_logs'; static const String _logKey = 'persisted_logs'; + static const String _remoteLogEndpoint = + String.fromEnvironment('FLOATY_REMOTE_LOG_ENDPOINT'); + static const String _remoteLogToken = + String.fromEnvironment('FLOATY_REMOTE_LOG_TOKEN'); + static final String _sessionId = DateTime.now().toUtc().toIso8601String(); static List _logs = []; + static bool _remoteEndpointRejectionLogged = false; + + static bool get remoteLoggingConfigured => _validatedRemoteLogUri != null; + static String get remoteLogEndpoint => _remoteLogEndpoint; static Future init() async { final box = await Hive.openBox(_boxName); @@ -21,27 +44,38 @@ class LogService { static void logInfo(String message) async { _logger.i(message); - await _saveLog('[INFO] $message'); + await _saveLog('[INFO] $message', level: 'INFO'); } static void logError(String message) async { _logger.e(message); - await _saveLog('[ERROR] $message'); + await _saveLog('[ERROR] $message', level: 'ERROR'); } static void logDebug(String message) async { _logger.d(message); - await _saveLog('[DEBUG] $message'); + await _saveLog('[DEBUG] $message', level: 'DEBUG'); + } + + static void logFlutterError(FlutterErrorDetails details) { + logError('Flutter error: ${details.exceptionAsString()}\n${details.stack}'); } - static Future _saveLog(String log) async { + static void logUncaughtError(Object error, StackTrace stackTrace, + {String source = 'dart'}) { + logError('Uncaught $source error: $error\n$stackTrace'); + } + + static Future _saveLog(String log, {String level = 'INFO'}) async { + final sanitizedLog = redactSensitiveLogData(log); final box = await Hive.openBox(_boxName); - _logs.add('${DateTime.now().toIso8601String()} $log'); + _logs.add('${DateTime.now().toIso8601String()} $sanitizedLog'); // Keep only the latest 500 logs if (_logs.length > 500) { _logs = _logs.sublist(_logs.length - 500); } await box.put(_logKey, _logs); + _sendRemoteLog(level: level, message: sanitizedLog); } static Future> getLogs() async { @@ -60,15 +94,164 @@ class LogService { } /// Add a log directly (used by logging framework listener) - static Future addLog(String log) async { + static Future addLog(String log, {String level = 'INFO'}) async { + final sanitizedLog = redactSensitiveLogData(log); final box = await Hive.openBox(_boxName); _logs.add( - '${DateTime.now().toIso8601String().split('T').join(' ').substring(0, 19)} $log'); + '${DateTime.now().toIso8601String().split('T').join(' ').substring(0, 19)} $sanitizedLog'); // Keep only the latest 1000 logs if (_logs.length > 1000) { _logs = _logs.sublist(_logs.length - 1000); } await box.put(_logKey, _logs); + _sendRemoteLog(level: level, message: sanitizedLog); + } + + static Future uploadLogSnapshot( + {required String source, required List logs}) async { + if (!remoteLoggingConfigured) { + _logInvalidRemoteLogEndpointIfNeeded(); + return const RemoteLogUploadResult( + success: false, + message: + 'Remote logging is not configured. Set FLOATY_REMOTE_LOG_ENDPOINT.', + ); + } + + final response = await _postRemotePayload({ + 'type': 'log_snapshot', + 'source': source, + 'sessionId': _sessionId, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + 'platform': _platformName, + 'logs': logs.map(redactSensitiveLogData).toList(growable: false), + }); + + if (response == null) { + return const RemoteLogUploadResult( + success: false, + message: 'Failed to send logs to remote endpoint.', + ); + } + + final success = response.statusCode >= 200 && response.statusCode < 300; + return RemoteLogUploadResult( + success: success, + message: success + ? 'Sent ${logs.length} log lines to the remote endpoint.' + : 'Remote endpoint returned HTTP ${response.statusCode}.', + ); + } + + @visibleForTesting + static bool isRemoteLogEndpointAllowed(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + if (!uri.hasScheme || !uri.hasAuthority || uri.host.isEmpty) { + return false; + } + if (scheme == 'https') { + return true; + } + if (scheme == 'http' && _isLoopbackHost(uri.host)) { + return true; + } + return false; + } + + @visibleForTesting + static String redactSensitiveLogData(String value) { + var redacted = value; + final patterns = [ + RegExp(r'authorization\s*[:=]\s*bearer\s+[^\s,;]+', caseSensitive: false), + RegExp(r'cookie\s*[:=]\s*[^,\n]+', caseSensitive: false), + RegExp(r'(token|accessToken|refreshToken|idToken)\s*[:=]\s*[^\s,;]+', + caseSensitive: false), + RegExp(r'(sails\.sid|__Host-sp-sess)\s*=\s*[^;\s]+', + caseSensitive: false), + ]; + + for (final pattern in patterns) { + redacted = redacted.replaceAllMapped(pattern, (match) { + final text = match.group(0) ?? ''; + final separatorIndex = text.indexOf(RegExp(r'[:=]')); + if (separatorIndex == -1) return '[REDACTED]'; + return '${text.substring(0, separatorIndex + 1)} [REDACTED]'; + }); + } + return redacted; + } + + static void _sendRemoteLog({ + required String level, + required String message, + }) { + if (!remoteLoggingConfigured) return; + + unawaited(_postRemotePayload({ + 'type': 'log', + 'level': level, + 'message': message, + 'sessionId': _sessionId, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + 'platform': _platformName, + })); + } + + static Future _postRemotePayload( + Map payload) async { + try { + final uri = _validatedRemoteLogUri; + if (uri == null) { + _logInvalidRemoteLogEndpointIfNeeded(); + return null; + } + + final headers = { + 'Content-Type': 'application/json', + if (_remoteLogToken.isNotEmpty) + 'Authorization': 'Bearer $_remoteLogToken', + }; + + return await http + .post(uri, headers: headers, body: jsonEncode(payload)) + .timeout(const Duration(seconds: 10)); + } catch (_) { + return null; + } + } + + static Uri? get _validatedRemoteLogUri { + if (_remoteLogEndpoint.isEmpty) return null; + + final uri = Uri.tryParse(_remoteLogEndpoint); + if (uri == null || !isRemoteLogEndpointAllowed(uri)) { + return null; + } + return uri; + } + + static void _logInvalidRemoteLogEndpointIfNeeded() { + if (_remoteLogEndpoint.isEmpty || + _validatedRemoteLogUri != null || + _remoteEndpointRejectionLogged) { + return; + } + + _remoteEndpointRejectionLogged = true; + _logger.w( + 'Remote logging endpoint rejected; use HTTPS or HTTP loopback only: $_remoteLogEndpoint'); + } + + static bool _isLoopbackHost(String host) { + final normalizedHost = host.toLowerCase(); + return normalizedHost == 'localhost' || + normalizedHost == '127.0.0.1' || + normalizedHost == '::1'; + } + + static String get _platformName { + if (kIsWeb) return 'web'; + return Platform.operatingSystem; } /// Get download logs from file diff --git a/lib/features/logs/views/log_screen.dart b/lib/features/logs/views/log_screen.dart index 7f49613..42b5261 100644 --- a/lib/features/logs/views/log_screen.dart +++ b/lib/features/logs/views/log_screen.dart @@ -13,6 +13,7 @@ class LogScreen extends StatefulWidget { class _LogScreenState extends State { List logs = []; bool loading = true; + bool uploading = false; LogType selectedLogType = LogType.app; @override @@ -40,6 +41,19 @@ class _LogScreenState extends State { await _loadLogs(); } + Future _uploadLogs() async { + setState(() => uploading = true); + final result = await LogService.uploadLogSnapshot( + source: selectedLogType.name, + logs: logs, + ); + if (!mounted) return; + setState(() => uploading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.message)), + ); + } + void _switchLogType(LogType? type) { if (type != null && type != selectedLogType) { setState(() { @@ -55,6 +69,16 @@ class _LogScreenState extends State { appBar: AppBar( title: const Text('App Logs'), actions: [ + IconButton( + icon: uploading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cloud_upload), + onPressed: uploading ? null : _uploadLogs, + tooltip: 'Upload selected logs', + ), IconButton( icon: const Icon(Icons.delete), onPressed: logs.isEmpty ? null : _clearLogs, @@ -91,6 +115,28 @@ class _LogScreenState extends State { }, ), ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row( + children: [ + Icon( + LogService.remoteLoggingConfigured + ? Icons.cloud_done + : Icons.cloud_off, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + LogService.remoteLoggingConfigured + ? 'Remote debug logging is configured. Use the upload button to send these logs.' + : 'Remote debug logging is off. Set FLOATY_REMOTE_LOG_ENDPOINT to send logs externally.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), // Logs display Expanded( child: loading diff --git a/lib/features/player/controllers/media_player_service.dart b/lib/features/player/controllers/media_player_service.dart index ea3ffdf..7f98d0d 100644 --- a/lib/features/player/controllers/media_player_service.dart +++ b/lib/features/player/controllers/media_player_service.dart @@ -401,9 +401,10 @@ class MediaPlayerService extends Notifier { _currentMediaType == MediaType.video ? 'video' : 'audio'); }); } - if (_currentArtist?.toLowerCase() != 'ecc squad' && !Platform.isMacOS || - _currentArtist?.toLowerCase() != 'eccsquad' && !Platform.isMacOS || - !_currentDiscoverable && !Platform.isMacOS) { + if (isDiscordRPCSupported && + _currentArtist?.toLowerCase() != 'ecc squad' && + _currentArtist?.toLowerCase() != 'eccsquad' && + _currentDiscoverable) { durationStream.listen((duration) { if (duration == Duration.zero) { discordRPCController.updateRPC( @@ -760,9 +761,10 @@ class MediaPlayerService extends Notifier { } const flavor = String.fromEnvironment('FLUTTER_FLAVOR', defaultValue: 'release'); - if (_currentArtist?.toLowerCase() != 'ecc squad' && !Platform.isMacOS || - _currentArtist?.toLowerCase() != 'eccsquad' && !Platform.isMacOS || - !_currentDiscoverable && !Platform.isMacOS) { + if (isDiscordRPCSupported && + _currentArtist?.toLowerCase() != 'ecc squad' && + _currentArtist?.toLowerCase() != 'eccsquad' && + _currentDiscoverable) { discordRPCController.updateRPC( _whitelabelName ?? 'Unknown Whitelabel', title ?? 'Unknown Title', @@ -993,7 +995,7 @@ class MediaPlayerService extends Notifier { } else { await windowsControls?.stop(); } - if (!Platform.isMacOS) { + if (isDiscordRPCSupported) { discordRPCController.clearRPC(); } break; @@ -1112,7 +1114,7 @@ class MediaPlayerService extends Notifier { } else { await windowsControls?.dispose(); } - if (!Platform.isMacOS) { + if (isDiscordRPCSupported) { discordRPCController.clearRPC(); } await _subtitleTextController.close(); diff --git a/lib/features/router/controllers/router.dart b/lib/features/router/controllers/router.dart index c09e7e5..aaa40b1 100644 --- a/lib/features/router/controllers/router.dart +++ b/lib/features/router/controllers/router.dart @@ -1,4 +1,5 @@ import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:floaty/shared/utils/safe_connectivity.dart'; import 'package:floaty/features/api/models/definitions.dart'; import 'package:floaty/features/api/utils/middleware.dart'; import 'package:floaty/features/authentication/views/login_screen.dart'; @@ -14,9 +15,9 @@ import 'package:floaty/features/post/views/post_screen.dart'; import 'package:floaty/features/profile/views/profile_screen.dart'; import 'package:floaty/features/settings/views/settings_screen.dart'; import 'package:floaty/features/router/views/root_layout.dart'; +import 'package:floaty/features/router/views/splash_screen.dart'; import 'package:floaty/features/updater/respositories/updater_controllers.dart'; import 'package:floaty/features/updater/views/update_screen.dart'; -import 'package:floaty/main.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -258,24 +259,35 @@ final GoRouter routerController = GoRouter( // Global redirect logic for authentication // This runs on every navigation to check if the user should be redirected redirect: (BuildContext context, GoRouterState state) async { + try { + return await _redirect(context, state); + } catch (e, st) { + debugPrint('Router redirect failed ($e), sending user to login: $st'); + final path = state.uri.path; + if (path == '/login' || path == '/update') { + return null; + } + return '/login'; + } + }, +); + +Future _redirect(BuildContext context, GoRouterState state) async { final currentPath = state.uri.path; if (currentPath == '/update') { - // Always allow access to update screen return null; } - final data = await updatercontroller.getUpdate(); - final packageInfo = await PackageInfo.fromPlatform(); - if (data != null && - data['deployment'] != null && - data['deployment']['version'] != packageInfo.version) { - if (data['deployment']['required'] == 1) { - routerController.go('/update'); - } + final updateRedirect = await updatercontroller.redirectPathIfUpdateRequired(); + if (updateRedirect != null) { + return updateRedirect; } - final connectivityResult = await (Connectivity().checkConnectivity()); - final isOffline = (connectivityResult.contains(ConnectivityResult.none)); + // Skip NetworkManager on Linux when D-Bus is unavailable (see safe_connectivity.dart). + final isOffline = connectivityLikelyUnavailableOnLinux + ? false + : (await safeCheckConnectivity()) + .contains(ConnectivityResult.none); // If offline and user was previously authenticated, give full app access // No point trying to validate tokens when there's no internet anyway @@ -379,5 +391,4 @@ final GoRouter routerController = GoRouter( return '/'; } return null; - }, -); +} diff --git a/lib/features/router/views/splash_screen.dart b/lib/features/router/views/splash_screen.dart new file mode 100644 index 0000000..e71d481 --- /dev/null +++ b/lib/features/router/views/splash_screen.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +/// Shown at `/` while [GoRouter] redirect resolves auth/update routing. +class SplashScreen extends StatelessWidget { + const SplashScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF0d47a1), + Color(0xFF1976d2), + ], + ), + ), + child: const Center( + child: CircularProgressIndicator(), + ), + ), + ); + } +} diff --git a/lib/features/settings/views/settings_screen.dart b/lib/features/settings/views/settings_screen.dart index 170c4c1..6c48fb2 100644 --- a/lib/features/settings/views/settings_screen.dart +++ b/lib/features/settings/views/settings_screen.dart @@ -4,7 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:floaty/features/authentication/services/oauth2_service.dart'; import 'package:floaty/features/player/controllers/media_player_service.dart'; -import 'package:floaty/main.dart'; +import 'package:floaty/app/flavor_theme.dart'; import 'package:floaty/shared/controllers/root_provider.dart'; import 'package:floaty/shared/utils/exceptions.dart'; import 'package:floaty/shared/views/error_screen.dart'; diff --git a/lib/features/updater/respositories/updater_controllers.dart b/lib/features/updater/respositories/updater_controllers.dart index ad342fa..070d2ec 100644 --- a/lib/features/updater/respositories/updater_controllers.dart +++ b/lib/features/updater/respositories/updater_controllers.dart @@ -1,13 +1,21 @@ import 'dart:async'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:get_it/get_it.dart'; -final UpdaterController updatercontroller = GetIt.I(); +UpdaterController get updatercontroller => GetIt.I(); class UpdaterController { - Dio dio = Dio(); + static const _updateTimeout = Duration(seconds: 5); + + final Dio dio = Dio( + BaseOptions( + connectTimeout: _updateTimeout, + receiveTimeout: _updateTimeout, + ), + ); late String flavor; bool updateReady = false; final StreamController updateStream = @@ -38,16 +46,19 @@ class UpdaterController { } Future initialCheck() async { - final response = - await dio.get('https://floaty.fyi/api/latest-update?flavor=$flavor'); - final packageInfo = await PackageInfo.fromPlatform(); - if (response.data != null && - response.data['deployment'] != null && - response.data['deployment']['version'] != packageInfo.version) { - updateReady = true; - updateStream.add(true); + try { + final response = + await dio.get('https://floaty.fyi/api/latest-update?flavor=$flavor'); + final packageInfo = await PackageInfo.fromPlatform(); + if (response.data != null && + response.data['deployment'] != null && + response.data['deployment']['version'] != packageInfo.version) { + updateReady = true; + updateStream.add(true); + } + } catch (e) { + debugPrint('Update check failed: $e'); } - return; } Future getUpdate() async { @@ -55,4 +66,21 @@ class UpdaterController { await dio.get('https://floaty.fyi/api/latest-update?flavor=$flavor'); return response.data; } + + /// Returns `/update` when a required update is available; otherwise null. + Future redirectPathIfUpdateRequired() async { + try { + final data = await getUpdate().timeout(_updateTimeout); + final packageInfo = await PackageInfo.fromPlatform(); + if (data != null && + data['deployment'] != null && + data['deployment']['version'] != packageInfo.version && + data['deployment']['required'] == 1) { + return '/update'; + } + } catch (e) { + debugPrint('Update redirect check skipped: $e'); + } + return null; + } } diff --git a/lib/main.dart b/lib/main.dart index dccbbf9..bbf95bb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,15 @@ +import 'dart:async'; + import 'package:floaty/features/deeplinks/controllers/deeplinks.dart'; import 'package:floaty/features/discordrpc/controllers/discord_rpc_controller.dart'; import 'package:floaty/features/download/controllers/fp_download_service.dart'; import 'package:floaty/features/updater/respositories/updater_controllers.dart'; import 'package:floaty/features/whenplane/repositories/whenplaneintergration.dart'; +import 'package:floaty/app/flavor_theme.dart'; import 'package:floaty/features/router/controllers/router.dart'; import 'package:floaty/whitelabels.dart'; import 'package:flutter/material.dart'; import 'package:floaty/features/logs/repositories/log_service.dart'; -import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:floaty/features/api/repositories/fpapi.dart'; import 'package:floaty/features/api/repositories/fpwebsockets.dart'; @@ -16,7 +18,6 @@ import 'package:floaty/shared/services/system/single_instance_service.dart'; import 'package:floaty/shared/services/system/tray_service.dart'; import 'package:path_provider/path_provider.dart'; import 'package:window_manager/window_manager.dart'; -import 'dart:io' show Platform, exit; import 'package:flutter/foundation.dart'; import 'package:app_links/app_links.dart'; import 'package:media_kit/media_kit.dart'; @@ -28,16 +29,26 @@ import 'package:floaty/features/deeplinks/controllers/protocol_handler.dart'; import 'package:logging/logging.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:path/path.dart' as p; -import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:floaty/shared/utils/platform_info_stub.dart' + if (dart.library.io) 'package:floaty/shared/utils/platform_info_io.dart' + as platform_info; +import 'package:floaty/shared/utils/safe_connectivity.dart'; // import 'package:floaty/features/notifications/controllers/firebase.dart'; // import 'package:floaty/features/notifications/controllers/notification.dart'; // import 'package:firebase_core/firebase_core.dart'; // import 'package:firebase_messaging/firebase_messaging.dart'; GetIt getIt = GetIt.instance; -late final Color? flavorPrimary; -void main() async { +void main() { + runZonedGuarded(() async { + await _main(); + }, (error, stackTrace) { + LogService.logUncaughtError(error, stackTrace, source: 'zone'); + }); +} + +Future _main() async { WidgetsFlutterBinding.ensureInitialized(); Logger.root.level = Level.ALL; @@ -47,6 +58,7 @@ void main() async { // Initialize LogService to capture logs await LogService.init(); + _installGlobalErrorHandlers(); // Configure logging to print to console and save to LogService Logger.root.onRecord.listen((record) { @@ -56,13 +68,18 @@ void main() async { print(logMessage); // Save to LogService for viewing in app - LogService.addLog(logMessage); + LogService.addLog(logMessage, level: record.level.name); }); await Hive.openBox('settings'); // Register Settings early so services/listeners started above can access it getIt.registerSingleton( Settings(), ); + + getIt.registerSingleton( + UpdaterController(), + ); + const flavor = String.fromEnvironment('FLUTTER_FLAVOR', defaultValue: 'release'); @@ -70,28 +87,18 @@ void main() async { MediaKit.ensureInitialized(); // Monitor connectivity and sync offline progress when online - if (!kIsWeb) { - final connectivity = Connectivity(); - connectivity.onConnectivityChanged.listen((result) async { - if (result.contains(ConnectivityResult.mobile) || - result.contains(ConnectivityResult.wifi) || - result.contains(ConnectivityResult.ethernet)) { - try { - final whitelabel = await Whitelabels().getSelectedWhitelabel(); - await fpApiRequests.syncOfflineProgress(whitelabel.friendlyName); - } catch (e) { - debugPrint( - 'Failed to sync offline progress on connectivity change: $e'); - } + if (!kIsWeb && !connectivityLikelyUnavailableOnLinux) { + listenForConnectivity((_) async { + try { + final whitelabel = await Whitelabels().getSelectedWhitelabel(); + await fpApiRequests.syncOfflineProgress(whitelabel.friendlyName); + } catch (e) { + debugPrint( + 'Failed to sync offline progress on connectivity change: $e'); } }); } - // Initialize protocol handler and register custom protocol - if (!kIsWeb) { - await ProtocolHandler.register(); - } - // Initialize deep link service final deepLinkService = DeepLinkService(); deepLinkService.setRouter(routerController); @@ -136,28 +143,13 @@ void main() async { WhenPlaneIntegration(), ); - // Initialize Floatplane download service - await _initFPDownloadService(); - - // Sync offline progress on app startup - try { - final whitelabel = await Whitelabels().getSelectedWhitelabel(); - await fpApiRequests.syncOfflineProgress(whitelabel.friendlyName); - } catch (e) { - debugPrint('Failed to sync offline progress on startup: $e'); - } - - getIt.registerSingleton( - UpdaterController(), - ); - - if (!Platform.isMacOS) { + if (isDiscordRPCSupported) { getIt.registerSingleton( DiscordRPCController(), ); } - // if (Platform.isAndroid) { + // if (platform_info.isAndroid) { // //init notifications // await LogService.init(); // await Firebase.initializeApp( @@ -210,17 +202,17 @@ void main() async { break; } - if (!Platform.isAndroid && !Platform.isIOS) { + if (!platform_info.isAndroid && !platform_info.isIOS) { // Initialize single instance service final singleInstanceService = await SingleInstanceService.getInstance(); await singleInstanceService.initialize(); // Only continue if this is the first instance // Note: For Windows, this is handled in initialize() - if (!Platform.isWindows) { + if (!platform_info.isWindows) { final isFirstInstance = await singleInstanceService.isFirstInstance(); if (!isFirstInstance) { - exit(0); + platform_info.exitApp(0); } } @@ -242,7 +234,7 @@ void main() async { switch (flavor) { case 'release': - if (Platform.isWindows) { + if (platform_info.isWindows) { await windowManager.setIcon('assets/icon/app_icon_win.ico'); } else { //await windowManager.setIcon('assets/app_icon.png'); @@ -250,7 +242,7 @@ void main() async { await windowManager.setTitle('Floaty'); break; case 'beta': - if (Platform.isWindows) { + if (platform_info.isWindows) { await windowManager.setIcon('assets/icon/beta_icon_win.ico'); } else { await windowManager.setIcon('assets/beta_icon.png'); @@ -258,7 +250,7 @@ void main() async { await windowManager.setTitle('Floaty Beta'); break; case 'nightly': - if (Platform.isWindows) { + if (platform_info.isWindows) { await windowManager.setIcon('assets/icon/nightly_icon_win.ico'); } else { await windowManager.setIcon('assets/nightly_icon.png'); @@ -266,7 +258,7 @@ void main() async { await windowManager.setTitle('Floaty Nightly'); break; case 'dev': - if (Platform.isWindows) { + if (platform_info.isWindows) { await windowManager.setIcon('assets/icon/dev_icon_win.ico'); } else { await windowManager.setIcon('assets/dev_icon.png'); @@ -274,7 +266,7 @@ void main() async { await windowManager.setTitle('Floaty Development'); break; default: - if (Platform.isWindows) { + if (platform_info.isWindows) { await windowManager.setIcon('assets/icon/app_icon_win.ico'); } else { await windowManager.setIcon('assets/app_icon.png'); @@ -293,12 +285,34 @@ void main() async { }, ), )); + + _schedulePostFrameStartup(); +} + +void _installGlobalErrorHandlers() { + final previousFlutterErrorHandler = FlutterError.onError; + FlutterError.onError = (details) { + LogService.logFlutterError(details); + if (previousFlutterErrorHandler != null) { + previousFlutterErrorHandler(details); + } else { + FlutterError.presentError(details); + } + }; + + final previousPlatformErrorHandler = PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = (error, stackTrace) { + LogService.logUncaughtError(error, stackTrace, source: 'platform'); + return previousPlatformErrorHandler?.call(error, stackTrace) ?? true; + }; } class MyApp extends StatelessWidget { MyApp({super.key, this.lightDynamic, this.darkDynamic}) { // Set up window manager event handlers - windowManager.addListener(_AppWindowListener()); + if (!platform_info.isAndroid && !platform_info.isIOS) { + windowManager.addListener(_AppWindowListener()); + } } final ColorScheme? lightDynamic; final ColorScheme? darkDynamic; @@ -569,48 +583,66 @@ class MyApp extends StatelessWidget { } } -class SplashScreen extends StatelessWidget { - const SplashScreen({super.key}); - +class _AppWindowListener extends WindowListener { @override - Widget build(BuildContext context) { - Future.delayed(const Duration(seconds: 2), () { - if (context.mounted) { - context.go('/'); - } - }); + void onWindowClose() async { + await windowManager.hide(); + } +} + +void _schedulePostFrameStartup() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!kIsWeb) { + _runStartupTask( + 'protocol handler registration', + ProtocolHandler.register, + ); + } - return Scaffold( - body: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xFF0d47a1), - Color(0xFF1976d2), - ], - ), - ), - child: const Center( - child: CircularProgressIndicator(), - ), - ), + _runStartupTask( + 'download service initialization', + _initFPDownloadService, ); - } + + _runStartupTask( + 'offline progress sync', + _syncOfflineProgressOnStartup, + ); + }); } -class _AppWindowListener extends WindowListener { - @override - void onWindowClose() async { - await windowManager.hide(); +void _runStartupTask(String name, Future Function() task) { + unawaited(() async { + try { + await task(); + } catch (error, stackTrace) { + debugPrint('Startup task failed ($name): $error'); + LogService.logError('Startup task failed ($name): $error\n$stackTrace'); + } + }()); +} + +Future _syncOfflineProgressOnStartup() async { + try { + final whitelabel = await Whitelabels().getSelectedWhitelabel(); + await fpApiRequests + .syncOfflineProgress(whitelabel.friendlyName) + .timeout(const Duration(seconds: 15)); + } on TimeoutException catch (e, stackTrace) { + debugPrint('Timed out syncing offline progress on startup'); + LogService.logError( + 'Timed out syncing offline progress on startup: $e\n$stackTrace'); + } catch (e, stackTrace) { + debugPrint('Failed to sync offline progress on startup: $e'); + LogService.logError( + 'Failed to sync offline progress on startup: $e\n$stackTrace'); } } /// Initialize the Floatplane download service Future _initFPDownloadService() async { // Initialize FFI database for desktop platforms - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + if (platform_info.isDesktop) { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; } diff --git a/lib/shared/utils/platform_info_io.dart b/lib/shared/utils/platform_info_io.dart new file mode 100644 index 0000000..f5d7301 --- /dev/null +++ b/lib/shared/utils/platform_info_io.dart @@ -0,0 +1,10 @@ +import 'dart:io' as io; + +bool get isAndroid => io.Platform.isAndroid; +bool get isIOS => io.Platform.isIOS; +bool get isWindows => io.Platform.isWindows; +bool get isLinux => io.Platform.isLinux; +bool get isMacOS => io.Platform.isMacOS; +bool get isDesktop => isWindows || isLinux || isMacOS; + +void exitApp(int code) => io.exit(code); diff --git a/lib/shared/utils/platform_info_stub.dart b/lib/shared/utils/platform_info_stub.dart new file mode 100644 index 0000000..425e9b5 --- /dev/null +++ b/lib/shared/utils/platform_info_stub.dart @@ -0,0 +1,8 @@ +bool get isAndroid => false; +bool get isIOS => false; +bool get isWindows => false; +bool get isLinux => false; +bool get isMacOS => false; +bool get isDesktop => false; + +void exitApp(int code) {} diff --git a/lib/shared/utils/safe_connectivity.dart b/lib/shared/utils/safe_connectivity.dart new file mode 100644 index 0000000..36f4c32 --- /dev/null +++ b/lib/shared/utils/safe_connectivity.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; + +/// connectivity_plus on Linux talks to NetworkManager over D-Bus. When D-Bus is +/// missing (headless CI, some AppImage/sandbox setups, minimal distros) those +/// calls throw and break GoRouter redirects โ€” resulting in a blank window. +Future> safeCheckConnectivity() async { + if (kIsWeb) { + return [ConnectivityResult.wifi]; + } + try { + return await Connectivity().checkConnectivity(); + } catch (e) { + debugPrint('Connectivity check unavailable, assuming online: $e'); + return [ConnectivityResult.wifi]; + } +} + +/// Registers [onOnline] when connectivity changes. Returns false if listening +/// could not be started (e.g. no D-Bus on Linux). +bool listenForConnectivity(void Function(List) onOnline) { + if (kIsWeb) { + return false; + } + try { + Connectivity().onConnectivityChanged.listen((result) { + if (result.contains(ConnectivityResult.mobile) || + result.contains(ConnectivityResult.wifi) || + result.contains(ConnectivityResult.ethernet)) { + onOnline(result); + } + }); + return true; + } catch (e) { + debugPrint('Connectivity listener unavailable: $e'); + return false; + } +} + +bool get connectivityLikelyUnavailableOnLinux => + Platform.isLinux && + !File('/var/run/dbus/system_bus_socket').existsSync() && + !(Platform.environment['DBUS_SESSION_BUS_ADDRESS']?.isNotEmpty ?? false); diff --git a/linux/packaging/appimage/AppRun b/linux/packaging/appimage/AppRun index 04aa6bd..0de3c7f 100644 --- a/linux/packaging/appimage/AppRun +++ b/linux/packaging/appimage/AppRun @@ -24,10 +24,6 @@ for libdir in "${SYSTEM_LIBDIRS[@]}"; do fi done -# Preserve any existing LD_LIBRARY_PATH from the user's environment -if [ -n "${LD_LIBRARY_PATH:-}" ]; then - export LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${LD_LIBRARY_PATH}" -fi if [ -x "$FLOATY_BIN" ]; then # check if AOT library exists and is readable diff --git a/pubspec.yaml b/pubspec.yaml index 574afd1..66bf2a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.0.4 +version: 0.0.23 environment: sdk: ^3.5.0 diff --git a/scripts/generate-altstore-source.py b/scripts/generate-altstore-source.py new file mode 100755 index 0000000..699bec1 --- /dev/null +++ b/scripts/generate-altstore-source.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Generate docs/altstore-source.json from GitHub Releases (including pre-releases).""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone + +# Fork tags: {flavor}-v{version}-build{build} +# Upstream tags: {flavor}-v{version}-{deploymentId} +TAG_SUFFIX_RE = re.compile( + r"-v(?P[\d.]+)(?:-build(?P\d+)|-(?P[^-]+))?$", + re.IGNORECASE, +) +IPA_NAME_RE = re.compile(r"^floaty-(?P[\d.]+)-ios\.ipa$", re.IGNORECASE) + +# When multiple GitHub releases share the same CFBundle version + build (e.g. CI on +# different branches), AltStore only allows one entry per (version, buildVersion). +FLAVOR_PRIORITY: dict[str, int] = { + "release": 0, + "beta": 1, + "nightly": 2, + "development": 3, + "dev": 3, +} + + +def flavor_rank(flavor: str) -> int: + key = flavor.lower() + if key in FLAVOR_PRIORITY: + return FLAVOR_PRIORITY[key] + if "/" in flavor: + return 100 + return 50 + + +def api_get(url: str, token: str | None) -> object: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=60) as response: + return json.loads(response.read().decode()) + + +def release_has_ios_ipa(release: dict) -> bool: + for asset in release.get("assets") or []: + if IPA_NAME_RE.match(asset.get("name") or ""): + return True + return False + + +def fetch_release_by_tag(repo: str, tag: str, token: str | None) -> dict | None: + url = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" + try: + data = api_get(url, token) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise + return data if isinstance(data, dict) else None + + +def wait_for_release_ipa( + repo: str, + tag: str, + token: str | None, + *, + timeout_seconds: int = 180, + poll_seconds: float = 5.0, +) -> None: + """Block until GitHub's Releases API lists an iOS IPA for *tag*.""" + deadline = time.monotonic() + timeout_seconds + attempt = 0 + while time.monotonic() < deadline: + attempt += 1 + release = fetch_release_by_tag(repo, tag, token) + if release and release_has_ios_ipa(release): + print( + f"Release {tag} has iOS IPA on API (attempt {attempt})", + file=sys.stderr, + ) + return + remaining = max(0, int(deadline - time.monotonic())) + print( + f"Waiting for {tag} IPA on GitHub API " + f"(attempt {attempt}, ~{remaining}s left)", + file=sys.stderr, + ) + time.sleep(poll_seconds) + raise TimeoutError( + f"Timed out after {timeout_seconds}s waiting for iOS IPA on release {tag}" + ) + + +def fetch_all_releases(repo: str, token: str | None) -> list[dict]: + releases: list[dict] = [] + page = 1 + while True: + url = ( + f"https://api.github.com/repos/{repo}/releases" + f"?per_page=100&page={page}" + ) + batch = api_get(url, token) + if not isinstance(batch, list) or not batch: + break + releases.extend(batch) + if len(batch) < 100: + break + page += 1 + return releases + + +def build_number_from_version(version: str) -> str: + """Match CI --build-number when pubspec has no +build suffix.""" + return version.rsplit(".", 1)[-1] + + +def parse_tag(tag_name: str) -> tuple[str | None, str | None, str | None]: + match = TAG_SUFFIX_RE.search(tag_name) + if not match: + return None, None, None + version = match.group("version") + build = match.group("build") + if build is None: + build = build_number_from_version(version) + flavor = tag_name[: match.start()].strip("-") or "release" + return flavor, version, build + + +def iso_date(release: dict) -> str: + raw = release.get("published_at") or release.get("created_at") + if not raw: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return raw.replace("+00:00", "Z") + + +def is_preferred(candidate: dict, incumbent: dict) -> bool: + """Prefer canonical release channels; tie-break with newer published date.""" + c_rank = flavor_rank(candidate["_flavor"]) + i_rank = flavor_rank(incumbent["_flavor"]) + if c_rank != i_rank: + return c_rank < i_rank + return candidate["date"] > incumbent["date"] + + +def build_versions(releases: list[dict]) -> list[dict]: + best_by_version_build: dict[tuple[str, str], dict] = {} + + for release in releases: + if release.get("draft"): + continue + + tag = release.get("tag_name") or "" + flavor, tag_version, tag_build = parse_tag(tag) + body = (release.get("body") or "").strip() + date = iso_date(release) + + for asset in release.get("assets") or []: + name = asset.get("name") or "" + ipa_match = IPA_NAME_RE.match(name) + if not ipa_match: + continue + + version = tag_version or ipa_match.group("version") + build = tag_build or build_number_from_version(version) + flavor_label = flavor or "unknown" + key = (version, build) + + description = body.split("\n")[0][:500] if body else None + if flavor_label and flavor_label != "release": + prefix = f"[{flavor_label}] " + description = ( + f"{prefix}{description}" + if description + else f"{prefix}Build {build}" + ) + + entry: dict = { + "version": version, + "buildVersion": build, + "marketingVersion": f"{version} ({build})", + "date": date, + "downloadURL": asset["browser_download_url"], + "size": asset["size"], + "_flavor": flavor_label, + } + if description: + entry["localizedDescription"] = description + + existing = best_by_version_build.get(key) + if existing is None or is_preferred(entry, existing): + best_by_version_build[key] = entry + + versions: list[dict] = [] + for entry in best_by_version_build.values(): + entry.pop("_flavor", None) + versions.append(entry) + + versions.sort(key=lambda v: v["date"], reverse=True) + return versions + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument( + "--meta", + default="docs/altstore-source.meta.json", + help="Static source metadata JSON", + ) + parser.add_argument( + "--output", + default="docs/altstore-source.json", + help="Generated AltStore source path", + ) + parser.add_argument( + "--wait-tag", + default=os.environ.get("ALTSTORE_WAIT_RELEASE_TAG"), + help=( + "After creating a release, poll until this tag's IPA appears " + "in the GitHub API (avoids eventual-consistency races)" + ), + ) + parser.add_argument( + "--wait-timeout", + type=int, + default=180, + help="Seconds to wait for --wait-tag IPA (default: 180)", + ) + args = parser.parse_args() + + if not args.repo: + print("error: --repo or GITHUB_REPOSITORY required", file=sys.stderr) + return 1 + + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + + with open(args.meta, encoding="utf-8") as handle: + meta = json.load(handle) + + app_template = meta.pop("app") + if args.wait_tag: + try: + wait_for_release_ipa( + args.repo, + args.wait_tag, + token, + timeout_seconds=args.wait_timeout, + ) + except TimeoutError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + try: + releases = fetch_all_releases(args.repo, token) + except urllib.error.HTTPError as exc: + print(f"error: GitHub API {exc.code}: {exc.reason}", file=sys.stderr) + return 1 + + versions = build_versions(releases) + if not versions: + print("warning: no iOS IPA assets found in releases", file=sys.stderr) + + app = {**app_template, "versions": versions} + source = { + **meta, + "apps": [app], + } + + with open(args.output, "w", encoding="utf-8") as handle: + json.dump(source, handle, indent=2) + handle.write("\n") + + print(f"Wrote {args.output} with {len(versions)} version(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/package-ios-ipa-for-sidestore.sh b/scripts/package-ios-ipa-for-sidestore.sh new file mode 100755 index 0000000..e32c725 --- /dev/null +++ b/scripts/package-ios-ipa-for-sidestore.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Build a SideStore/AltStore-friendly .ipa from a .app bundle (macOS only: uses codesign). +set -euo pipefail + +RUNNER_APP="${1:?Usage: $0 }" +OUT_IPA="${2:?}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "error: codesign requires macOS" >&2 + exit 1 +fi + +if [[ ! -d "$RUNNER_APP" ]]; then + echo "error: app bundle not found: $RUNNER_APP" >&2 + exit 1 +fi + +OUT_IPA="$(cd "$(dirname "$OUT_IPA")" && pwd)/$(basename "$OUT_IPA")" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +mkdir -p "$WORKDIR/Payload" +cp -a "$RUNNER_APP" "$WORKDIR/Payload/" +APP="$WORKDIR/Payload/$(basename "$RUNNER_APP")" + +rm -rf "$APP/_CodeSignature" +rm -rf "$APP/Frameworks"/*/_CodeSignature 2>/dev/null || true + +if [[ -d "$APP/Frameworks" ]] && compgen -G "$APP/Frameworks"/* >/dev/null; then + codesign -s - -f "$APP/Frameworks"/* +fi +codesign -s - -f "$APP" + +mkdir -p "$(dirname "$OUT_IPA")" +rm -f "$OUT_IPA" +( + cd "$WORKDIR" + zip -r "$OUT_IPA" Payload +) + +if unzip -l "$OUT_IPA" | awk 'NR>3 {print $4}' | grep -q '\.\./'; then + echo "error: IPA still contains ../ in zip paths" >&2 + exit 1 +fi + +SIZE="$(stat -f%z "$OUT_IPA")" +echo "Wrote $OUT_IPA ($SIZE bytes)" diff --git a/test/features/download/controllers/fp_download_service_test.dart b/test/features/download/controllers/fp_download_service_test.dart new file mode 100644 index 0000000..17fbc87 --- /dev/null +++ b/test/features/download/controllers/fp_download_service_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:floaty/features/download/controllers/fp_download_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('selectOfflineStorageDirectory', () { + test('uses external storage when requested and available', () { + final appSupportDirectory = Directory('/app-support'); + final externalStorageDirectory = Directory('/external-storage'); + + final directory = selectOfflineStorageDirectory( + applicationSupportDirectory: appSupportDirectory, + externalStorageDirectory: externalStorageDirectory, + useExternalStorage: true, + ); + + expect(directory.path, externalStorageDirectory.path); + }); + + test('uses app support storage when external storage is unavailable', () { + final appSupportDirectory = Directory('/app-support'); + + final directory = selectOfflineStorageDirectory( + applicationSupportDirectory: appSupportDirectory, + useExternalStorage: true, + ); + + expect(directory.path, appSupportDirectory.path); + }); + + test('uses app support storage when external storage is not requested', () { + final appSupportDirectory = Directory('/app-support'); + final externalStorageDirectory = Directory('/external-storage'); + + final directory = selectOfflineStorageDirectory( + applicationSupportDirectory: appSupportDirectory, + externalStorageDirectory: externalStorageDirectory, + useExternalStorage: false, + ); + + expect(directory.path, appSupportDirectory.path); + }); + }); +} diff --git a/test/features/logs/repositories/log_service_test.dart b/test/features/logs/repositories/log_service_test.dart new file mode 100644 index 0000000..ca5a34a --- /dev/null +++ b/test/features/logs/repositories/log_service_test.dart @@ -0,0 +1,55 @@ +import 'package:floaty/features/logs/repositories/log_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('LogService.isRemoteLogEndpointAllowed', () { + test('allows https endpoints', () { + expect( + LogService.isRemoteLogEndpointAllowed( + Uri.parse('https://logs.example.com/floaty'), + ), + isTrue, + ); + }); + + test('allows http loopback endpoints for local debugging', () { + expect( + LogService.isRemoteLogEndpointAllowed( + Uri.parse('http://localhost:8080/logs'), + ), + isTrue, + ); + expect( + LogService.isRemoteLogEndpointAllowed( + Uri.parse('http://127.0.0.1:8080/logs'), + ), + isTrue, + ); + }); + + test('rejects insecure non-loopback endpoints', () { + expect( + LogService.isRemoteLogEndpointAllowed( + Uri.parse('http://logs.example.com/floaty'), + ), + isFalse, + ); + }); + }); + + group('LogService.redactSensitiveLogData', () { + test('redacts common auth tokens and cookies', () { + const input = + 'Authorization: Bearer abc123 token=secret accessToken=abc sails.sid=session-cookie'; + + final redacted = LogService.redactSensitiveLogData(input); + + expect(redacted, isNot(contains('abc123'))); + expect(redacted, isNot(contains('secret'))); + expect(redacted, isNot(contains('session-cookie'))); + expect(redacted, contains('Authorization: [REDACTED]')); + expect(redacted, contains('token= [REDACTED]')); + expect(redacted, contains('sails.sid= [REDACTED]')); + }); + }); +} diff --git a/test/features/logs/views/log_screen_test.dart b/test/features/logs/views/log_screen_test.dart new file mode 100644 index 0000000..273187c --- /dev/null +++ b/test/features/logs/views/log_screen_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:floaty/features/logs/repositories/log_service.dart'; +import 'package:floaty/features/logs/views/log_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; + +void main() { + late Directory hiveDirectory; + + setUp(() async { + hiveDirectory = await Directory.systemTemp.createTemp('floaty_logs_test'); + Hive.init(hiveDirectory.path); + await LogService.init(); + }); + + tearDown(() async { + await Hive.close(); + await hiveDirectory.delete(recursive: true); + }); + + testWidgets('shows remote debug upload status and action', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: LogScreen(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.cloud_upload), findsOneWidget); + expect(find.byIcon(Icons.cloud_off), findsOneWidget); + expect(find.textContaining('Remote debug logging is off'), findsOneWidget); + }); +} From 936583a4da9741d4fc42f852058de1e9cf1ec16f Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:35:18 +0000 Subject: [PATCH 2/2] Automated Version Bump --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 66bf2a9..d726ca1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.0.23 +version: 0.0.24 environment: sdk: ^3.5.0